Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b74ffd80 | ||
|
|
2b393f9ebe | ||
|
|
562a17707d | ||
|
|
7d800b0f38 | ||
|
|
a19479b578 | ||
|
|
9b4a810e21 | ||
|
|
1388d61b07 | ||
|
|
8dbe42288b | ||
|
|
45e31e7212 | ||
|
|
7872b18108 | ||
|
|
15ac5fa84d | ||
|
|
3c85b36b1b | ||
|
|
ee0bc5e500 | ||
|
|
4729863826 | ||
|
|
174fb048f7 | ||
|
|
63fd3f6afd | ||
|
|
c3944f2fe7 | ||
|
|
ddd937d42f | ||
|
|
5e225f9173 | ||
|
|
d2d110fcd5 | ||
|
|
724f30070d | ||
|
|
b8a44f09fc | ||
|
|
f85f1aa1d1 | ||
|
|
707b8f903b | ||
|
|
9e4fd6d708 | ||
|
|
8572065ef7 | ||
|
|
01c3870b22 | ||
|
|
420c103f41 | ||
|
|
b4970d321d | ||
|
|
f5a4a03898 | ||
|
|
dfae6c8abd | ||
|
|
2b6734bb78 | ||
|
|
b9fd29b906 | ||
|
|
caf8f22fec | ||
|
|
599c599588 | ||
|
|
cfed235d1f | ||
|
|
c9eacff71f | ||
|
|
b2526a5be9 | ||
|
|
cffe3f57fa | ||
|
|
8b2506cc3d | ||
|
|
60356a95fe | ||
|
|
1195567da8 | ||
|
|
7617929700 | ||
|
|
7df3ee6ecd | ||
|
|
dc788dbf85 | ||
|
|
89c4f4005e | ||
|
|
53ed3b3465 | ||
|
|
e37fbf2a5c | ||
|
|
4df08bfa79 | ||
|
|
f74231cf33 | ||
|
|
e1558d35b0 | ||
|
|
51672890df | ||
|
|
fc357fe3ae | ||
|
|
1a0e6d9404 | ||
|
|
b9e8a6dbc8 | ||
|
|
c704f67e57 | ||
|
|
4629a947a4 | ||
|
|
da92b9d067 | ||
|
|
458442f364 | ||
|
|
400b295dae | ||
|
|
6ddee6e85b | ||
|
|
2db88ac192 | ||
|
|
0342870bbd | ||
|
|
4280a6c2c3 | ||
|
|
0a3232419a | ||
|
|
4dc3a756a8 | ||
|
|
cd91e83817 | ||
|
|
f476710c29 | ||
|
|
0d255c8962 | ||
|
|
bf25a65d8e | ||
|
|
f1e16b65ee | ||
|
|
201ad987ce | ||
|
|
c12ce2fbcf | ||
|
|
6e77738ecf | ||
|
|
8a83bc99e9 |
No files matched your search
@@ -151,6 +151,7 @@ LOG_LEVEL="debug"
|
||||
- `ORIGIN`: Origin for CORS purposes
|
||||
- `TOKEN_TTL`: Token time to live (in vercel/ms format)
|
||||
- `LOG_LEVEL`: Debug level (debug, info, warn, error)
|
||||
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`)
|
||||
|
||||
Start the backend server:
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.7.1",
|
||||
"version": "3.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.7.1",
|
||||
"version": "3.8.1",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.7.1",
|
||||
"version": "3.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -78,6 +78,14 @@ function buildDefaults(data: Notification | null): NotificationFormData {
|
||||
accessToken: data.accessToken || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "ntfy") {
|
||||
return {
|
||||
type: "ntfy",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
topic: data.topic || "",
|
||||
};
|
||||
}
|
||||
// Default: email (covers both data === null and data.type === "email")
|
||||
return {
|
||||
type: "email",
|
||||
|
||||
@@ -264,10 +264,10 @@ const CreateMonitorPage = () => {
|
||||
reset(defaults);
|
||||
}, [defaults, reset]);
|
||||
|
||||
// Multi-step wizard, uptime create only (showTypeSelector). Other flows
|
||||
// keep the flat form, where showStep() is always true.
|
||||
// Multi-step wizard on every create flow; edit mode keeps the flat form
|
||||
// where showStep() is always true.
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const showStep = (step: number) => !showTypeSelector || currentStep === step;
|
||||
const showStep = (step: number) => isEditMode || currentStep === step;
|
||||
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||
@@ -362,7 +362,7 @@ const CreateMonitorPage = () => {
|
||||
refetch={refetchMonitor}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
{showTypeSelector && (
|
||||
{!isEditMode && (
|
||||
<StepProgress
|
||||
steps={totalSteps}
|
||||
current={currentStep}
|
||||
@@ -1335,9 +1335,9 @@ const CreateMonitorPage = () => {
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={showTypeSelector ? "space-between" : "flex-end"}
|
||||
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
||||
>
|
||||
{showTypeSelector && (
|
||||
{!isEditMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
@@ -1348,7 +1348,7 @@ const CreateMonitorPage = () => {
|
||||
{t("common.buttons.back")}
|
||||
</Button>
|
||||
)}
|
||||
{showTypeSelector && currentStep < totalSteps - 1 ? (
|
||||
{!isEditMode && currentStep < totalSteps - 1 ? (
|
||||
<Button
|
||||
key="wizard-next"
|
||||
type="button"
|
||||
|
||||
@@ -2,8 +2,9 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { TableJobs, TableFailedJobs } from "./components/TableJobs";
|
||||
|
||||
import { TableWorkers } from "./components/TableWorkers";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useGet, usePost } from "@/Hooks/UseApi";
|
||||
import type { QueueData } from "@/Types/Queue";
|
||||
@@ -15,16 +16,27 @@ import { SPACING } from "@/Utils/Theme/constants";
|
||||
export const TabQueue = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const {
|
||||
data: queueData,
|
||||
isLoading,
|
||||
// error,
|
||||
refetch,
|
||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
|
||||
} = useGet<QueueData>(
|
||||
`/queue/all-metrics?page=${page}&rowsPerPage=${rowsPerPage}`,
|
||||
{},
|
||||
{ refreshInterval: 1000, keepPreviousData: true }
|
||||
);
|
||||
|
||||
const { post, loading: isFlushing } = usePost();
|
||||
|
||||
const jobs = queueData?.jobs ?? [];
|
||||
const jobsCount = queueData?.count ?? 0;
|
||||
const workers = (queueData?.metrics?.workers ?? []).map((worker, idx) => ({
|
||||
...worker,
|
||||
id: idx,
|
||||
}));
|
||||
const metrics = queueData?.metrics ?? null;
|
||||
const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0;
|
||||
const noQueueData = !isLoading && metrics === null && jobs.length === 0;
|
||||
@@ -46,6 +58,34 @@ export const TabQueue = () => {
|
||||
return (
|
||||
<Stack gap={theme.spacing(16)}>
|
||||
<Metrics metrics={metrics} />
|
||||
<Stack gap={theme.spacing(3)}>
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.logs.workers")}
|
||||
</Typography>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
<Trans
|
||||
i18nKey="pages.logs.workersExplainers"
|
||||
components={{
|
||||
highlight: (
|
||||
<Box
|
||||
component="span"
|
||||
bgcolor={theme.palette.rowStatus.running}
|
||||
color={theme.palette.text.primary}
|
||||
px={SPACING.SM}
|
||||
py={SPACING.XXS}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<TableWorkers queueWorkers={workers} />
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(3)}>
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
@@ -72,7 +112,17 @@ export const TabQueue = () => {
|
||||
/>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<TableJobs jobs={jobs} />
|
||||
<TableJobs
|
||||
jobs={jobs}
|
||||
count={jobsCount}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
onRowsPerPageChange={(event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(3)}>
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import { Table, Pagination } from "@/Components/design-elements";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
@@ -6,14 +6,27 @@ import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { TablePaginationProps } from "@mui/material/TablePagination";
|
||||
|
||||
type QueueJobWithId = QueueJobSummary & { id: string | number };
|
||||
|
||||
interface TableJobsProps {
|
||||
jobs: QueueJobSummary[];
|
||||
count: number;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
onPageChange: TablePaginationProps["onPageChange"];
|
||||
onRowsPerPageChange: TablePaginationProps["onRowsPerPageChange"];
|
||||
}
|
||||
|
||||
export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
export const TableJobs = ({
|
||||
jobs,
|
||||
count,
|
||||
page,
|
||||
rowsPerPage,
|
||||
onPageChange,
|
||||
onRowsPerPageChange,
|
||||
}: TableJobsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -127,21 +140,53 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lockedBy",
|
||||
content: t("pages.logs.table.headers.lockedBy"),
|
||||
render: (row) => {
|
||||
const lockedBy = row.lockedBy;
|
||||
if (!lockedBy) {
|
||||
return <Typography sx={cellSx}>-</Typography>;
|
||||
}
|
||||
|
||||
const workerId = lockedBy.split(":")[2];
|
||||
const shortWorkerId = workerId.slice(workerId.length - 8);
|
||||
return (
|
||||
<Typography
|
||||
title={workerId}
|
||||
sx={cellSx}
|
||||
>
|
||||
{`...${shortWorkerId}`}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
headers={headers}
|
||||
data={jobsWithId}
|
||||
getRowSx={(row) => ({
|
||||
...(row.lockedAt && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.running },
|
||||
}),
|
||||
...(row.monitorActive === false && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.paused },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
<>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={jobsWithId}
|
||||
getRowSx={(row) => ({
|
||||
...(row.lockedAt && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.running },
|
||||
}),
|
||||
...(row.monitorActive === false && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.paused },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
count={count}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={onPageChange}
|
||||
onRowsPerPageChange={onRowsPerPageChange}
|
||||
itemsOnPage={jobs.length}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { QueueWorker } from "@/Types/Queue";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
|
||||
type QueueWorkerWithId = QueueWorker & { id: number };
|
||||
|
||||
interface TableWorkersProps {
|
||||
queueWorkers: QueueWorkerWithId[];
|
||||
}
|
||||
|
||||
export const TableWorkers = ({ queueWorkers }: TableWorkersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const cellSx = {
|
||||
whiteSpace: "nowrap" as const,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 220,
|
||||
};
|
||||
|
||||
const headers: Header<QueueWorkerWithId>[] = [
|
||||
{
|
||||
id: "host",
|
||||
content: t("pages.logs.table.headers.host"),
|
||||
render: (row) => {
|
||||
const host = row.workerId.split(":")[0];
|
||||
return (
|
||||
<Typography
|
||||
fontFamily={theme.typography.fontFamilyMonospace}
|
||||
sx={cellSx}
|
||||
>
|
||||
{host}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "processId",
|
||||
content: t("pages.logs.table.headers.processId"),
|
||||
render: (row) => {
|
||||
const processId = row.workerId.split(":")[1];
|
||||
return (
|
||||
<Typography
|
||||
fontFamily={theme.typography.fontFamilyMonospace}
|
||||
sx={cellSx}
|
||||
>
|
||||
{processId}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
content: t("pages.logs.table.headers.workerId"),
|
||||
render: (row) => {
|
||||
const workerId = row.workerId.split(":")[2];
|
||||
const shortWorkerId = workerId.slice(workerId.length - 8);
|
||||
return (
|
||||
<Typography
|
||||
fontFamily={theme.typography.fontFamilyMonospace}
|
||||
title={workerId}
|
||||
sx={cellSx}
|
||||
>
|
||||
{`...${shortWorkerId}`}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
content: t("pages.logs.table.headers.workerMode"),
|
||||
render: (row) => <Typography sx={cellSx}>{row.mode}</Typography>,
|
||||
},
|
||||
{
|
||||
id: "lastSeen",
|
||||
content: t("pages.logs.table.headers.lastSeenAt"),
|
||||
render: (row) => (
|
||||
<Typography sx={cellSx}>{formatTimestamp(row.lastSeenAt)}</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
headers={headers}
|
||||
data={queueWorkers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const LogsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
const [activeTab, setActiveTab] = useState<number>(1);
|
||||
return (
|
||||
<BasePage headerKey="logs">
|
||||
<Tabs
|
||||
|
||||
@@ -150,7 +150,8 @@ const NotificationsCreatePage = () => {
|
||||
{watchedType !== "matrix" &&
|
||||
watchedType !== "telegram" &&
|
||||
watchedType !== "pushover" &&
|
||||
watchedType !== "twilio" && (
|
||||
watchedType !== "twilio" &&
|
||||
watchedType !== "ntfy" && (
|
||||
<ConfigBox
|
||||
title={addressConfig.title}
|
||||
subtitle={addressConfig.description}
|
||||
@@ -174,6 +175,48 @@ const NotificationsCreatePage = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "ntfy" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.ntfy.title")}
|
||||
subtitle={t("pages.notifications.form.ntfy.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="topic"
|
||||
control={control}
|
||||
defaultValue={"topic" in defaults ? defaults.topic : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "telegram" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.telegram.title")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
@@ -26,6 +26,7 @@ import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
|
||||
import type { StatusPageFormData } from "@/Validation/statusPage";
|
||||
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
|
||||
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
|
||||
@@ -86,13 +87,27 @@ const CreateStatusPage = () => {
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { control, reset, handleSubmit } = form;
|
||||
const { control, reset, handleSubmit, trigger } = form;
|
||||
|
||||
// Reset form when defaults change (from fetched data)
|
||||
useEffect(() => {
|
||||
reset(defaults);
|
||||
}, [defaults, reset]);
|
||||
|
||||
// Multi-step wizard, create only. The configure (edit) flow keeps the flat
|
||||
// form, where showStep() is always true.
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const showStep = (step: number) => !isCreate || currentStep === step;
|
||||
const totalSteps = statusPageStepCount();
|
||||
|
||||
const handleNext = async () => {
|
||||
const isValid = await trigger(statusPageStepFieldsFor(currentStep));
|
||||
if (isValid) {
|
||||
setCurrentStep((step) => step + 1);
|
||||
}
|
||||
};
|
||||
const handleBack = () => setCurrentStep((step) => step - 1);
|
||||
|
||||
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
|
||||
const computedTypes: MonitorDisplayType[] = useMemo(() => {
|
||||
const selectedMonitors = (watchedMonitorIds ?? [])
|
||||
@@ -200,315 +215,334 @@ const CreateStatusPage = () => {
|
||||
onSubmit={handleSubmit(onSubmit, onError)}
|
||||
>
|
||||
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.access.title")}
|
||||
subtitle={t("pages.statusPages.form.access.description")}
|
||||
rightContent={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(SPACING.MD)}
|
||||
>
|
||||
<Controller
|
||||
name="isPublished"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SwitchComponent
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Typography>
|
||||
{t("pages.statusPages.form.access.option.published.name")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.basicInfo.title")}
|
||||
subtitle={t("pages.statusPages.form.basicInfo.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="companyName"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.name.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.url.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.monitors.title")}
|
||||
subtitle={t("pages.statusPages.form.monitors.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="monitors"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => {
|
||||
const selectedMonitors = field.value
|
||||
.map((id: string) => monitors.find((m) => m.id === id))
|
||||
.filter((m): m is Monitor => m !== undefined);
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const reordered = Array.from(field.value);
|
||||
const [removed] = reordered.splice(result.source.index, 1);
|
||||
reordered.splice(result.destination.index, 0, removed);
|
||||
field.onChange(reordered);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={monitors}
|
||||
getOptionLabel={(option: Monitor) => option.name}
|
||||
value={selectedMonitors}
|
||||
onChange={(_, newValue) => {
|
||||
field.onChange(newValue.map((m: Monitor) => m.id));
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.monitors.option.monitors.label"
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={
|
||||
selectedMonitors.length === 0
|
||||
? t(
|
||||
"pages.statusPages.form.monitors.option.monitors.placeholder"
|
||||
)
|
||||
: ""
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{selectedMonitors.length > 0 && (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="monitors-list">
|
||||
{(provided) => (
|
||||
<Stack
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{selectedMonitors.map((monitor, index) => (
|
||||
<Draggable
|
||||
key={monitor.id}
|
||||
draggableId={monitor.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<Stack
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
marginTop={theme.spacing(SPACING.LG)}
|
||||
borderRadius={1}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
cursor: "grab",
|
||||
"&:active": { cursor: "grabbing" },
|
||||
}}
|
||||
>
|
||||
<GripVertical size={20} />
|
||||
<Typography
|
||||
flexGrow={1}
|
||||
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value.filter(
|
||||
(id: string) => id !== monitor.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
aria-label="Remove monitor"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.timezone.title")}
|
||||
subtitle={t("pages.statusPages.form.timezone.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="timezone"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={timezones}
|
||||
getOptionLabel={(option: TimezoneOption) => option.name}
|
||||
value={
|
||||
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
|
||||
}
|
||||
onChange={(_, newValue: TimezoneOption | null) => {
|
||||
field.onChange(newValue?._id ?? "");
|
||||
}}
|
||||
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.timezone.option.timezone.placeholder"
|
||||
)}
|
||||
{isCreate && (
|
||||
<StepProgress
|
||||
steps={totalSteps}
|
||||
current={currentStep}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.access.title")}
|
||||
subtitle={t("pages.statusPages.form.access.description")}
|
||||
rightContent={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(SPACING.MD)}
|
||||
>
|
||||
<Controller
|
||||
name="isPublished"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SwitchComponent
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.appearance.title")}
|
||||
subtitle={t("pages.statusPages.form.appearance.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack alignItems={"center"}>
|
||||
<Typography>
|
||||
{t("pages.statusPages.form.access.option.published.name")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.basicInfo.title")}
|
||||
subtitle={t("pages.statusPages.form.basicInfo.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="logo"
|
||||
name="companyName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ImageUpload
|
||||
src={field.value?.data}
|
||||
onChange={(file) => {
|
||||
if (file) {
|
||||
field.onChange({
|
||||
data: file.src,
|
||||
contentType: file.file.type,
|
||||
});
|
||||
} else {
|
||||
field.onChange(null);
|
||||
}
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.name.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.url.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.monitors.title")}
|
||||
subtitle={t("pages.statusPages.form.monitors.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="color"
|
||||
name="monitors"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorInput
|
||||
format="hex"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.theme.title")}
|
||||
subtitle={t("pages.statusPages.form.theme.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="theme"
|
||||
control={control}
|
||||
render={({ field: themeField }) => (
|
||||
<Controller
|
||||
name="themeMode"
|
||||
control={control}
|
||||
render={({ field: modeField }) => (
|
||||
<ThemePicker
|
||||
theme={themeField.value ?? "refined"}
|
||||
themeMode={modeField.value ?? "auto"}
|
||||
onThemeChange={themeField.onChange}
|
||||
onThemeModeChange={modeField.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
subtitle={t("pages.statusPages.form.features.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="showCharts"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
render={({ field, fieldState }) => {
|
||||
const selectedMonitors = field.value
|
||||
.map((id: string) => monitors.find((m) => m.id === id))
|
||||
.filter((m): m is Monitor => m !== undefined);
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const reordered = Array.from(field.value);
|
||||
const [removed] = reordered.splice(result.source.index, 1);
|
||||
reordered.splice(result.destination.index, 0, removed);
|
||||
field.onChange(reordered);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={monitors}
|
||||
getOptionLabel={(option: Monitor) => option.name}
|
||||
value={selectedMonitors}
|
||||
onChange={(_, newValue) => {
|
||||
field.onChange(newValue.map((m: Monitor) => m.id));
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.monitors.option.monitors.label"
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={
|
||||
selectedMonitors.length === 0
|
||||
? t(
|
||||
"pages.statusPages.form.monitors.option.monitors.placeholder"
|
||||
)
|
||||
: ""
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
label={t("pages.statusPages.form.features.option.showCharts.label")}
|
||||
/>
|
||||
)}
|
||||
{selectedMonitors.length > 0 && (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="monitors-list">
|
||||
{(provided) => (
|
||||
<Stack
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{selectedMonitors.map((monitor, index) => (
|
||||
<Draggable
|
||||
key={monitor.id}
|
||||
draggableId={monitor.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<Stack
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
marginTop={theme.spacing(SPACING.LG)}
|
||||
borderRadius={1}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
cursor: "grab",
|
||||
"&:active": { cursor: "grabbing" },
|
||||
}}
|
||||
>
|
||||
<GripVertical size={20} />
|
||||
<Typography
|
||||
flexGrow={1}
|
||||
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value.filter(
|
||||
(id: string) => id !== monitor.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
aria-label="Remove monitor"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.timezone.title")}
|
||||
subtitle={t("pages.statusPages.form.timezone.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="showInfrastructure"
|
||||
name="timezone"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<Autocomplete
|
||||
options={timezones}
|
||||
getOptionLabel={(option: TimezoneOption) => option.name}
|
||||
value={
|
||||
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
|
||||
}
|
||||
label={t(
|
||||
"pages.statusPages.form.features.option.showInfrastructure.label"
|
||||
onChange={(_, newValue: TimezoneOption | null) => {
|
||||
field.onChange(newValue?._id ?? "");
|
||||
}}
|
||||
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.timezone.option.timezone.placeholder"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <Controller
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.appearance.title")}
|
||||
subtitle={t("pages.statusPages.form.appearance.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack alignItems={"center"}>
|
||||
<Controller
|
||||
name="logo"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ImageUpload
|
||||
src={field.value?.data}
|
||||
onChange={(file) => {
|
||||
if (file) {
|
||||
field.onChange({
|
||||
data: file.src,
|
||||
contentType: file.file.type,
|
||||
});
|
||||
} else {
|
||||
field.onChange(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorInput
|
||||
format="hex"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.theme.title")}
|
||||
subtitle={t("pages.statusPages.form.theme.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="theme"
|
||||
control={control}
|
||||
render={({ field: themeField }) => (
|
||||
<Controller
|
||||
name="themeMode"
|
||||
control={control}
|
||||
render={({ field: modeField }) => (
|
||||
<ThemePicker
|
||||
theme={themeField.value ?? "refined"}
|
||||
themeMode={modeField.value ?? "auto"}
|
||||
onThemeChange={themeField.onChange}
|
||||
onThemeModeChange={modeField.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
subtitle={t("pages.statusPages.form.features.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="showCharts"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label={t("pages.statusPages.form.features.option.showCharts.label")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="showInfrastructure"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label={t(
|
||||
"pages.statusPages.form.features.option.showInfrastructure.label"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <Controller
|
||||
name="showUptimePercentage"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
@@ -542,21 +576,46 @@ const CreateStatusPage = () => {
|
||||
/>
|
||||
)}
|
||||
/> */}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
justifyContent={isCreate ? "space-between" : "flex-end"}
|
||||
>
|
||||
<Button
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={currentStep === 0}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t("common.buttons.back")}
|
||||
</Button>
|
||||
)}
|
||||
{isCreate && currentStep < totalSteps - 1 ? (
|
||||
<Button
|
||||
key="wizard-next"
|
||||
type="button"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleNext}
|
||||
>
|
||||
{t("common.buttons.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="wizard-save"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
@@ -25,6 +26,7 @@ export interface Notification {
|
||||
accessToken?: string;
|
||||
accountSid?: string;
|
||||
twilioPhoneNumber?: string;
|
||||
topic?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { MonitorType } from "./Monitor";
|
||||
|
||||
export const QueueModes = ["primary", "worker"] as const;
|
||||
export type QueueMode = (typeof QueueModes)[number];
|
||||
|
||||
export type QueueWorker = {
|
||||
workerId: string; // hostname:pid:uuid
|
||||
mode: QueueMode;
|
||||
lastSeenAt: number; // epoch ms of the last heartbeat
|
||||
};
|
||||
|
||||
export interface QueueJobFailure {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
@@ -9,23 +18,26 @@ export interface QueueJobFailure {
|
||||
failReason: string | null;
|
||||
}
|
||||
|
||||
export interface QueueMetrics {
|
||||
export type QueueMetrics = {
|
||||
jobs: number;
|
||||
activeJobs: number;
|
||||
failingJobs: number;
|
||||
jobsWithFailures: QueueJobFailure[];
|
||||
totalRuns: number;
|
||||
totalFailures: number;
|
||||
}
|
||||
workers: QueueWorker[];
|
||||
};
|
||||
|
||||
export interface QueueJobSummary {
|
||||
export type QueueJobSummary = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: MonitorType | null;
|
||||
monitorType: string | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedBy: string | null;
|
||||
lockedUntil: number | null;
|
||||
lockedAt: number | null;
|
||||
runCount: number;
|
||||
failCount: number;
|
||||
@@ -35,9 +47,10 @@ export interface QueueJobSummary {
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
}
|
||||
};
|
||||
|
||||
export interface QueueData {
|
||||
jobs: QueueJobSummary[];
|
||||
count: number;
|
||||
metrics: QueueMetrics;
|
||||
}
|
||||
@@ -128,19 +128,23 @@ const hardwareSchema = baseSchema.extend({
|
||||
cpuAlertThreshold: z
|
||||
.number()
|
||||
.min(0, "CPU threshold must be at least 0")
|
||||
.max(100, "CPU threshold must be at most 100"),
|
||||
.max(100, "CPU threshold must be at most 100")
|
||||
.register(monitorStepRegistry, { step: 1 }),
|
||||
memoryAlertThreshold: z
|
||||
.number()
|
||||
.min(0, "Memory threshold must be at least 0")
|
||||
.max(100, "Memory threshold must be at most 100"),
|
||||
.max(100, "Memory threshold must be at most 100")
|
||||
.register(monitorStepRegistry, { step: 1 }),
|
||||
diskAlertThreshold: z
|
||||
.number()
|
||||
.min(0, "Disk threshold must be at least 0")
|
||||
.max(100, "Disk threshold must be at most 100"),
|
||||
.max(100, "Disk threshold must be at most 100")
|
||||
.register(monitorStepRegistry, { step: 1 }),
|
||||
tempAlertThreshold: z
|
||||
.number()
|
||||
.min(0, "Temperature threshold must be at least 0")
|
||||
.max(150, "Temperature threshold must be at most 150"),
|
||||
.max(150, "Temperature threshold must be at most 150")
|
||||
.register(monitorStepRegistry, { step: 1 }),
|
||||
selectedDisks: z.array(z.string()),
|
||||
});
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ const twilioSchema = baseSchema.extend({
|
||||
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
|
||||
});
|
||||
|
||||
const ntfySchema = baseSchema.extend({
|
||||
type: z.literal("ntfy"),
|
||||
address: z.string().min(1, "Server URL is required").url("Please enter a valid URL"),
|
||||
topic: z.string().min(1, "Topic is required"),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
emailSchema,
|
||||
slackSchema,
|
||||
@@ -81,6 +87,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
telegramSchema,
|
||||
pushoverSchema,
|
||||
twilioSchema,
|
||||
ntfySchema,
|
||||
]);
|
||||
|
||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||
@@ -1,6 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import type { FieldPath } from "react-hook-form";
|
||||
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
|
||||
|
||||
// Wizard step a field is validated on. Attached inline to each field below so
|
||||
// the grouping lives next to the field definition; unannotated fields default
|
||||
// to step 0. Read via `statusPageStepFieldsFor`. Mirrors the monitor schema's
|
||||
// `monitorStepRegistry` so the create-status-page wizard can't drift from the
|
||||
// field definitions.
|
||||
export const statusPageStepRegistry = z.registry<{ step: number }>();
|
||||
|
||||
export const statusPageSchema = z.object({
|
||||
companyName: z
|
||||
.string()
|
||||
@@ -18,23 +26,51 @@ export const statusPageSchema = z.object({
|
||||
type: z
|
||||
.array(z.enum(["uptime", "infrastructure"]))
|
||||
.min(1, "At least one type is required"),
|
||||
color: z.string().min(1, "Color is required"),
|
||||
color: z.string().min(1, "Color is required").register(statusPageStepRegistry, {
|
||||
step: 1,
|
||||
}),
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
isPublished: z.boolean(),
|
||||
showCharts: z.boolean(),
|
||||
showUptimePercentage: z.boolean(),
|
||||
showAdminLoginLink: z.boolean(),
|
||||
showInfrastructure: z.boolean(),
|
||||
customCSS: z.string().optional(),
|
||||
theme: z.enum(STATUS_PAGE_THEMES).optional(),
|
||||
themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(),
|
||||
showCharts: z.boolean().register(statusPageStepRegistry, { step: 1 }),
|
||||
showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }),
|
||||
showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }),
|
||||
showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }),
|
||||
customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }),
|
||||
theme: z
|
||||
.enum(STATUS_PAGE_THEMES)
|
||||
.optional()
|
||||
.register(statusPageStepRegistry, { step: 1 }),
|
||||
themeMode: z
|
||||
.enum(STATUS_PAGE_THEME_MODES)
|
||||
.optional()
|
||||
.register(statusPageStepRegistry, { step: 1 }),
|
||||
logo: z
|
||||
.object({
|
||||
data: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
.optional()
|
||||
.register(statusPageStepRegistry, { step: 1 }),
|
||||
});
|
||||
|
||||
export type StatusPageFormData = z.infer<typeof statusPageSchema>;
|
||||
|
||||
// The step each field is validated on, from the per-field
|
||||
// `statusPageStepRegistry` metadata (unannotated fields default to step 0).
|
||||
const fieldSteps = (): [name: FieldPath<StatusPageFormData>, step: number][] =>
|
||||
Object.entries(statusPageSchema.shape).map(([name, field]) => [
|
||||
name as FieldPath<StatusPageFormData>,
|
||||
statusPageStepRegistry.get(field)?.step ?? 0,
|
||||
]);
|
||||
|
||||
// The fields that belong to a given wizard step. Derived from the schema so it
|
||||
// can't drift from the field definitions.
|
||||
export const statusPageStepFieldsFor = (step: number): FieldPath<StatusPageFormData>[] =>
|
||||
fieldSteps()
|
||||
.filter(([, fieldStep]) => fieldStep === step)
|
||||
.map(([name]) => name);
|
||||
|
||||
// Number of wizard steps, derived from the highest step its fields declare.
|
||||
export const statusPageStepCount = (): number =>
|
||||
Math.max(0, ...fieldSteps().map(([, step]) => step)) + 1;
|
||||
@@ -933,6 +933,8 @@
|
||||
"logLevelSelect": {
|
||||
"label": "Log level"
|
||||
},
|
||||
"workers": "Workers",
|
||||
"workersExplainers": "Shows worker processes. Worker IDs are truncated, hover for full ID",
|
||||
"jobQueue": "Job queue",
|
||||
"jobQueueExplainer": "<highlight>Highlighted rows</highlight> are jobs currently being processed by a worker.",
|
||||
"failedJobs": "Failed jobs",
|
||||
@@ -1000,6 +1002,11 @@
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"host": "Host",
|
||||
"processId": "Process ID",
|
||||
"workerId": "Worker ID",
|
||||
"workerMode": "Worker mode",
|
||||
"lastSeenAt": "Last seen at",
|
||||
"state": "State",
|
||||
"timestamp": "Timestamp",
|
||||
"level": "Level",
|
||||
@@ -1010,6 +1017,7 @@
|
||||
"failCount": "Count",
|
||||
"lastRunAt": "Last run at",
|
||||
"lockedAt": "Locked at",
|
||||
"lockedBy": "Locked by",
|
||||
"lastFinishedAt": "Last finished at",
|
||||
"lastRunTook": "Last run took",
|
||||
"lastFailedAt": "Last failed",
|
||||
@@ -1157,6 +1165,14 @@
|
||||
"placeholderFromNumber": "+15551234567",
|
||||
"optionToNumber": "To number (recipient)",
|
||||
"placeholderToNumber": "+15559876543"
|
||||
},
|
||||
"ntfy": {
|
||||
"title": "ntfy configuration",
|
||||
"description": "Configure an ntfy server and topic for push notifications.",
|
||||
"optionServerUrl": "Server URL",
|
||||
"placeholderServerUrl": "https://ntfy.sh",
|
||||
"optionTopic": "Topic",
|
||||
"placeholderTopic": "checkmate-alerts"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
||||
@@ -11,6 +11,7 @@ LOG_LEVEL=debug
|
||||
# Database
|
||||
# --------
|
||||
DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
|
||||
QUEUE_TYPE=superSimpleQueue
|
||||
|
||||
# JWT Authentication
|
||||
# ------------------
|
||||
|
||||
+10
-1
@@ -12,5 +12,14 @@
|
||||
"quoteProps": "as-needed",
|
||||
"arrowParens": "always",
|
||||
"trailingComma": "es5",
|
||||
"htmlWhitespaceSensitivity": "css"
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"plugins": ["prettier-plugin-ini"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [".env", ".env.*"],
|
||||
"options": {
|
||||
"parser": "ini"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+107
-2
@@ -392,6 +392,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -469,6 +475,7 @@
|
||||
"ignoreTlsErrors",
|
||||
"useAdvancedMatching",
|
||||
"notifications",
|
||||
"tags",
|
||||
"cpuAlertThreshold",
|
||||
"memoryAlertThreshold",
|
||||
"diskAlertThreshold",
|
||||
@@ -545,6 +552,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/TwilioNotification"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NtfyNotification"
|
||||
}
|
||||
],
|
||||
"discriminator": {
|
||||
@@ -559,7 +569,8 @@
|
||||
"teams": "#/components/schemas/TeamsNotification",
|
||||
"telegram": "#/components/schemas/TelegramNotification",
|
||||
"pushover": "#/components/schemas/PushoverNotification",
|
||||
"twilio": "#/components/schemas/TwilioNotification"
|
||||
"twilio": "#/components/schemas/TwilioNotification",
|
||||
"ntfy": "#/components/schemas/NtfyNotification"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1003,6 +1014,34 @@
|
||||
"phone": "+15551234567",
|
||||
"twilioPhoneNumber": "+15557654321"
|
||||
}
|
||||
},
|
||||
"NtfyNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notificationName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["ntfy"]
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["notificationName", "type", "address", "topic"],
|
||||
"example": {
|
||||
"notificationName": "ntfy topic",
|
||||
"type": "ntfy",
|
||||
"address": "https://ntfy.sh",
|
||||
"topic": "checkmate-alerts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {}
|
||||
@@ -2362,6 +2401,16 @@
|
||||
"profilePicture": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
|
||||
},
|
||||
"newPassword": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
|
||||
},
|
||||
"profileImage": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
@@ -5207,7 +5256,8 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"duration": {
|
||||
"type": "number"
|
||||
@@ -5478,6 +5528,24 @@
|
||||
"required": false,
|
||||
"name": "filter",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"name": "tags",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -5672,6 +5740,24 @@
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"name": "tags",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
@@ -6924,6 +7010,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7586,6 +7678,13 @@
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8104,6 +8203,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -68,6 +68,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
|
||||
twilioPhoneNumber: "+15557654321",
|
||||
},
|
||||
},
|
||||
ntfy: {
|
||||
component: "NtfyNotification",
|
||||
example: { notificationName: "ntfy topic", type: "ntfy", address: "https://ntfy.sh", topic: "checkmate-alerts" },
|
||||
},
|
||||
};
|
||||
|
||||
const decoratedVariants = createNotificationBodyValidation.options.map((variant) => {
|
||||
|
||||
Generated
+131
-4
@@ -31,6 +31,7 @@
|
||||
"jmespath": "^0.16.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"less-simple-scheduler": "2.6.0",
|
||||
"mailersend": "^2.2.0",
|
||||
"mjml": "^5.0.0-alpha.4",
|
||||
"mongoose": "^8.3.3",
|
||||
@@ -75,6 +76,7 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"nodemon": "^3.1.11",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-ini": "1.3.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
@@ -5468,16 +5470,42 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "30.2.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
|
||||
@@ -10388,6 +10416,95 @@
|
||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/less-simple-scheduler": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.6.0.tgz",
|
||||
"integrity": "sha512-Vi8ouiodLqe2fyh+DqCmxE5cCJZDI3xOyUAH4jcj9sf7GunLUzH71PBcYJy2P8lxTK3LxEkHtr1reymiogs6JQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"mongodb": "7.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/less-simple-scheduler/node_modules/@types/whatwg-url": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/less-simple-scheduler/node_modules/bson": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz",
|
||||
"integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/less-simple-scheduler/node_modules/mongodb": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz",
|
||||
"integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@mongodb-js/saslprep": "^1.3.0",
|
||||
"bson": "^7.2.0",
|
||||
"mongodb-connection-string-url": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.806.0",
|
||||
"@mongodb-js/zstd": "^7.0.0",
|
||||
"gcp-metadata": "^7.0.1",
|
||||
"kerberos": "^7.0.0",
|
||||
"mongodb-client-encryption": ">=7.0.0 <7.1.0",
|
||||
"snappy": "^7.3.2",
|
||||
"socks": "^2.8.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/credential-providers": {
|
||||
"optional": true
|
||||
},
|
||||
"@mongodb-js/zstd": {
|
||||
"optional": true
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"optional": true
|
||||
},
|
||||
"kerberos": {
|
||||
"optional": true
|
||||
},
|
||||
"mongodb-client-encryption": {
|
||||
"optional": true
|
||||
},
|
||||
"snappy": {
|
||||
"optional": true
|
||||
},
|
||||
"socks": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/less-simple-scheduler/node_modules/mongodb-connection-string-url": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
|
||||
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/whatwg-url": "^13.0.0",
|
||||
"whatwg-url": "^14.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -12991,6 +13108,16 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-ini": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-ini/-/prettier-plugin-ini-1.3.0.tgz",
|
||||
"integrity": "sha512-kX1KnPwITCTtYV0MTBZQKuFLxcNcbHOS9r7Ta1jHLLvRvZRTzHHqX0MEZMrCkedjLuxpQSgzr4aC5IRd+R3FPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier": ">=3.0.0-alpha.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "30.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
|
||||
|
||||
+5
-1
@@ -9,8 +9,10 @@
|
||||
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
|
||||
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
|
||||
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
|
||||
"dev": "nodemon --exec tsx src/index.js",
|
||||
"start": "node --watch ./dist/index.js",
|
||||
"dev": "nodemon --exec tsx src/index.js",
|
||||
"worker": "QUEUE_MODE=worker node ./dist/index.js",
|
||||
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
|
||||
"clean": "rm -rf dist",
|
||||
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates",
|
||||
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
|
||||
@@ -51,6 +53,7 @@
|
||||
"jmespath": "^0.16.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"less-simple-scheduler": "2.6.0",
|
||||
"mailersend": "^2.2.0",
|
||||
"mjml": "^5.0.0-alpha.4",
|
||||
"mongoose": "^8.3.3",
|
||||
@@ -95,6 +98,7 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"nodemon": "^3.1.11",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-ini": "1.3.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
EmailService,
|
||||
BufferService,
|
||||
GlobalPingService,
|
||||
LessSimpleQueue,
|
||||
SuperSimpleQueue,
|
||||
SuperSimpleQueueHelper,
|
||||
QueueHelper,
|
||||
NotificationsService,
|
||||
TagsService,
|
||||
StatusService,
|
||||
@@ -33,11 +34,12 @@ import {
|
||||
TelegramProvider,
|
||||
PushoverProvider,
|
||||
TwilioProvider,
|
||||
NtfyProvider,
|
||||
// Interfaces
|
||||
INetworkService,
|
||||
IEmailService,
|
||||
IBufferService,
|
||||
ISuperSimpleQueue,
|
||||
IJobQueue,
|
||||
INotificationsService,
|
||||
ITagsService,
|
||||
IStatusService,
|
||||
@@ -106,6 +108,7 @@ import {
|
||||
MongoTeamsRepository,
|
||||
MongoMaintenanceWindowsRepository,
|
||||
MongoSettingsRepository,
|
||||
MongoQueueWorkersRepository,
|
||||
IMonitorsRepository,
|
||||
IChecksRepository,
|
||||
IGeoChecksRepository,
|
||||
@@ -122,7 +125,8 @@ import {
|
||||
IMaintenanceWindowsRepository,
|
||||
} from "@/repositories/index.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
// import { AppError } from "@/utils/AppError.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { type QueueMode } from "@/types/settings.js";
|
||||
|
||||
export type InitializedServices = {
|
||||
settingsService: ISettingsService;
|
||||
@@ -131,7 +135,7 @@ export type InitializedServices = {
|
||||
emailService: IEmailService;
|
||||
bufferService: IBufferService;
|
||||
statusService: IStatusService;
|
||||
jobQueue: ISuperSimpleQueue;
|
||||
jobQueue: IJobQueue;
|
||||
userService: IUserService;
|
||||
checkService: ICheckService;
|
||||
geoChecksService: IGeoChecksService;
|
||||
@@ -167,10 +171,12 @@ export const initializeServices = async ({
|
||||
logger,
|
||||
envSettings,
|
||||
settingsService,
|
||||
queueMode = "primary",
|
||||
}: {
|
||||
logger: ILogger;
|
||||
envSettings: EnvConfig;
|
||||
settingsService: ISettingsService;
|
||||
queueMode?: QueueMode;
|
||||
}): Promise<InitializedServices> => {
|
||||
// Create DB
|
||||
|
||||
@@ -244,6 +250,7 @@ export const initializeServices = async ({
|
||||
const incidentsRepository = new MongoIncidentsRepository();
|
||||
const teamsRepository = new MongoTeamsRepository();
|
||||
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
||||
const queueWorkersRepository = new MongoQueueWorkersRepository();
|
||||
|
||||
// Inject settings repository into settings service (now that DB is connected)
|
||||
(settingsService as SettingsService).setRepository(settingsRepository);
|
||||
@@ -304,6 +311,7 @@ export const initializeServices = async ({
|
||||
const telegramProvider = new TelegramProvider(logger);
|
||||
const pushoverProvider = new PushoverProvider(logger);
|
||||
const twilioProvider = new TwilioProvider(logger);
|
||||
const ntfyProvider = new NtfyProvider(logger);
|
||||
|
||||
const notificationsService = new NotificationsService(
|
||||
notificationsRepository,
|
||||
@@ -318,6 +326,7 @@ export const initializeServices = async ({
|
||||
telegramProvider,
|
||||
pushoverProvider,
|
||||
twilioProvider,
|
||||
ntfyProvider,
|
||||
settingsService,
|
||||
logger,
|
||||
notificationMessageBuilder
|
||||
@@ -325,7 +334,7 @@ export const initializeServices = async ({
|
||||
|
||||
const tagsService = new TagsService(tagsRepository, monitorsRepository);
|
||||
|
||||
const superSimpleQueueHelper = new SuperSimpleQueueHelper(
|
||||
const queueHelper = new QueueHelper(
|
||||
logger,
|
||||
networkService,
|
||||
statusService,
|
||||
@@ -344,7 +353,23 @@ export const initializeServices = async ({
|
||||
geoChecksRepository
|
||||
);
|
||||
|
||||
const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository);
|
||||
if (queueMode === "worker" && envSettings.queueType !== "lessSimpleQueue") {
|
||||
throw new AppError({
|
||||
message: `Worker mode requires QUEUE_TYPE="lessSimpleQueue" got ${envSettings.queueType}`,
|
||||
status: 500,
|
||||
service: "config services",
|
||||
method: "initializeServices",
|
||||
});
|
||||
}
|
||||
|
||||
let jobQueue: IJobQueue;
|
||||
switch (envSettings.queueType) {
|
||||
case "lessSimpleQueue":
|
||||
jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode);
|
||||
break;
|
||||
default:
|
||||
jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository);
|
||||
}
|
||||
|
||||
// Business services
|
||||
const userService = new UserService({
|
||||
@@ -353,7 +378,7 @@ export const initializeServices = async ({
|
||||
settingsService,
|
||||
logger,
|
||||
jwt,
|
||||
jobQueue: superSimpleQueue,
|
||||
jobQueue,
|
||||
monitorsRepository,
|
||||
usersRepository,
|
||||
invitesRepository,
|
||||
@@ -373,7 +398,7 @@ export const initializeServices = async ({
|
||||
maintenanceWindowsRepository,
|
||||
});
|
||||
const monitorService = new MonitorService({
|
||||
jobQueue: superSimpleQueue,
|
||||
jobQueue,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
@@ -393,7 +418,7 @@ export const initializeServices = async ({
|
||||
emailService,
|
||||
bufferService,
|
||||
statusService,
|
||||
jobQueue: superSimpleQueue,
|
||||
jobQueue,
|
||||
userService,
|
||||
checkService,
|
||||
geoChecksService,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ISuperSimpleQueue } from "@/service/index.js";
|
||||
import { IJobQueue } from "@/service/index.js";
|
||||
import { getQueueJobsQueryValidation } from "@/validation/queueValidation.js";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
const SERVICE_NAME = "JobQueueController";
|
||||
@@ -12,8 +13,8 @@ export interface IJobQueueController {
|
||||
|
||||
class JobQueueController implements IJobQueueController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
private jobQueue: ISuperSimpleQueue;
|
||||
constructor(jobQueue: ISuperSimpleQueue) {
|
||||
private jobQueue: IJobQueue;
|
||||
constructor(jobQueue: IJobQueue) {
|
||||
this.jobQueue = jobQueue;
|
||||
}
|
||||
|
||||
@@ -36,11 +37,12 @@ class JobQueueController implements IJobQueueController {
|
||||
|
||||
getJobs = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const jobs = await this.jobQueue.getJobs();
|
||||
const pagination = getQueueJobsQueryValidation.parse(req.query);
|
||||
const { jobs, count } = await this.jobQueue.getJobs(pagination);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Queue jobs fetched successfully",
|
||||
data: jobs,
|
||||
data: { jobs, count },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -49,12 +51,13 @@ class JobQueueController implements IJobQueueController {
|
||||
|
||||
getAllMetrics = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const jobs = await this.jobQueue.getJobs();
|
||||
const pagination = getQueueJobsQueryValidation.parse(req.query);
|
||||
const { jobs, count } = await this.jobQueue.getJobs(pagination);
|
||||
const metrics = await this.jobQueue.getMetrics();
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Queue metrics fetched successfully",
|
||||
data: { jobs, metrics },
|
||||
data: { jobs, count, metrics },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -5,6 +5,7 @@ const CHECKS_COLLECTION = "checks";
|
||||
const BACKUP_COLLECTION = "checks_backup";
|
||||
const FAILED_DOCS_COLLECTION = "checks_migration_failed";
|
||||
const BATCH_SIZE = 1000;
|
||||
const NAMESPACE_EXISTS_CODE = 48;
|
||||
|
||||
interface MigrationStats {
|
||||
totalSource: number;
|
||||
@@ -41,6 +42,13 @@ const backupAndDropExistingCollection = async () => {
|
||||
if (collections.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The Check model declares `timeseries`, so on a fresh database Mongoose's autoCreate
|
||||
// already creates `checks` as a time-series collection. Skip and return false
|
||||
if (collections[0]?.type === "timeseries") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray();
|
||||
if (backupExists.length > 0) {
|
||||
throw new Error(`Backup collection "${BACKUP_COLLECTION}" already exists. ` + `Please remove it manually before running migration.`);
|
||||
@@ -59,13 +67,21 @@ const createTimeSeriesCollection = async (backedUp: boolean) => {
|
||||
try {
|
||||
const existing = await db.listCollections({ name: CHECKS_COLLECTION }).toArray();
|
||||
if (existing.length === 0) {
|
||||
await db.createCollection(CHECKS_COLLECTION, {
|
||||
timeseries: {
|
||||
timeField: "createdAt",
|
||||
metaField: "metadata",
|
||||
granularity: "seconds",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await db.createCollection(CHECKS_COLLECTION, {
|
||||
timeseries: {
|
||||
timeField: "createdAt",
|
||||
metaField: "metadata",
|
||||
granularity: "seconds",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Mongoose's autoCreate can create `checks` concurrently with this migration,
|
||||
// already exists as a time-series collection, this is success, otherwise throw
|
||||
if ((error as { code?: number })?.code !== NAMESPACE_EXISTS_CODE) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
@@ -80,7 +96,11 @@ const createTimeSeriesCollection = async (backedUp: boolean) => {
|
||||
]);
|
||||
} catch (error) {
|
||||
if (backedUp) {
|
||||
await restoreFromBackup();
|
||||
try {
|
||||
await restoreFromBackup();
|
||||
} catch (restoreError) {
|
||||
console.error("rollback failed:", restoreError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -123,8 +143,7 @@ const migrateBackupData = async (backedUp: boolean): Promise<MigrationStats> =>
|
||||
const target = db.collection(CHECKS_COLLECTION);
|
||||
|
||||
stats.totalSource = await source.countDocuments();
|
||||
|
||||
const cursor = source.find().addCursorFlag("noCursorTimeout", true);
|
||||
const cursor = source.find();
|
||||
const operations: AnyBulkWriteOperation<Document>[] = [];
|
||||
const invalidDocs: Document[] = [];
|
||||
|
||||
@@ -166,7 +185,11 @@ const migrateBackupData = async (backedUp: boolean): Promise<MigrationStats> =>
|
||||
return stats;
|
||||
} catch (error) {
|
||||
if (backedUp) {
|
||||
await restoreFromBackup();
|
||||
try {
|
||||
await restoreFromBackup();
|
||||
} catch (restoreError) {
|
||||
console.error("rollback failed:", restoreError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,19 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
|
||||
enum: [
|
||||
"email",
|
||||
"slack",
|
||||
"discord",
|
||||
"webhook",
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as NotificationChannel[],
|
||||
required: true,
|
||||
},
|
||||
notificationName: {
|
||||
@@ -39,6 +51,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
accessToken: { type: String },
|
||||
accountSid: { type: String },
|
||||
twilioPhoneNumber: { type: String },
|
||||
topic: { type: String },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Schema, model } from "mongoose";
|
||||
import { QueueModes, type QueueMode } from "@/types/settings.js";
|
||||
|
||||
// Heartbeat presence record for a scheduler instance. _id is the scheduler's
|
||||
// workerId (hostname:pid:uuid). The TTL index on lastSeenAt garbage-collects
|
||||
// records for instances that have stopped heartbeating.
|
||||
interface QueueWorkerDocument {
|
||||
_id: string;
|
||||
mode: QueueMode;
|
||||
lastSeenAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const QueueWorkerSchema = new Schema<QueueWorkerDocument>(
|
||||
{
|
||||
_id: { type: String, required: true },
|
||||
mode: { type: String, enum: QueueModes, required: true },
|
||||
lastSeenAt: { type: Date, default: Date.now, expires: 30 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
const QueueWorkerModel = model<QueueWorkerDocument>("QueueWorker", QueueWorkerSchema);
|
||||
|
||||
export type { QueueWorkerDocument };
|
||||
export { QueueWorkerModel };
|
||||
export default QueueWorkerModel;
|
||||
@@ -41,3 +41,6 @@ export * from "@/db/models/DLQItem.js";
|
||||
export { default as DLQItemModel } from "@/db/models/DLQItem.js";
|
||||
export * from "@/db/models/Tag.js";
|
||||
export { default as TagModel } from "@/db/models/Tag.js";
|
||||
|
||||
export * from "@/db/models/QueueWorker.js";
|
||||
export { default as QueueWorkerModel } from "@/db/models/QueueWorker.js";
|
||||
+19
-10
@@ -17,22 +17,31 @@ const startApp = async () => {
|
||||
// Validate environment variables first
|
||||
const env = validateEnv();
|
||||
|
||||
// Create settings service (env only — DB repository injected after connect)
|
||||
const settingsService = new SettingsService(env);
|
||||
const envSettings = settingsService.loadSettings();
|
||||
// Create logger
|
||||
logger = new Logger({ envSettings });
|
||||
|
||||
// Initialize services (connects DB, creates repositories, injects settingsRepository)
|
||||
const services = await initializeServices({ logger, envSettings, settingsService, queueMode: envSettings.queueMode });
|
||||
|
||||
// If this is a worker instance, we're done. No need for express
|
||||
if (envSettings.queueMode === "worker") {
|
||||
logger.info({
|
||||
message: "Worker instance started. API will not be started",
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
initShutdownListener(null, services);
|
||||
return;
|
||||
}
|
||||
|
||||
// FE path
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
|
||||
const frontendPath = path.join(__dirname, "..", "public");
|
||||
|
||||
// Create settings service (env only — DB repository injected after connect)
|
||||
const settingsService = new SettingsService(env);
|
||||
const envSettings = settingsService.loadSettings();
|
||||
|
||||
// Create logger
|
||||
logger = new Logger({ envSettings });
|
||||
|
||||
// Initialize services (connects DB, creates repositories, injects settingsRepository)
|
||||
const services = await initializeServices({ logger, envSettings, settingsService });
|
||||
|
||||
// Initialize controllers
|
||||
const controllers = initializeControllers(services);
|
||||
|
||||
|
||||
@@ -42,3 +42,6 @@ export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/M
|
||||
|
||||
export * from "@/repositories/dlq/IDLQRepository.js";
|
||||
export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js";
|
||||
|
||||
export * from "@/repositories/queue-workers/IQueueWorkersRepository.js";
|
||||
export { default as MongoQueueWorkersRepository } from "@/repositories/queue-workers/MongoQueueWorkersRepository.js";
|
||||
@@ -34,6 +34,7 @@ class MongoNotificationsRepository implements INotificationsRepository {
|
||||
accessToken: doc.accessToken ?? undefined,
|
||||
accountSid: doc.accountSid ?? undefined,
|
||||
twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined,
|
||||
topic: doc.topic ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { QueueMode, QueueWorker } from "@/types/settings.js";
|
||||
|
||||
export interface IQueueWorkersRepository {
|
||||
upsert(workerId: string, mode: QueueMode): Promise<void>;
|
||||
findRecent(maxAgeMs: number): Promise<QueueWorker[]>;
|
||||
deleteById(workerId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { QueueMode, QueueWorker } from "@/types/settings.js";
|
||||
import type { IQueueWorkersRepository } from "./IQueueWorkersRepository.js";
|
||||
import type { QueueWorkerDocument } from "@/db/models/QueueWorker.js";
|
||||
import { QueueWorkerModel } from "@/db/models/QueueWorker.js";
|
||||
const SERVICE_NAME = "MongoQueueWorkersRepository";
|
||||
|
||||
class MongoQueueWorkersRepository implements IQueueWorkersRepository {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
protected toEntity = (doc: QueueWorkerDocument): QueueWorker => {
|
||||
return {
|
||||
workerId: doc._id,
|
||||
mode: doc.mode,
|
||||
lastSeenAt: doc.lastSeenAt.getTime(),
|
||||
};
|
||||
};
|
||||
|
||||
upsert = async (workerId: string, mode: QueueMode): Promise<void> => {
|
||||
await QueueWorkerModel.updateOne({ _id: workerId }, { $set: { mode, lastSeenAt: new Date() } }, { upsert: true });
|
||||
};
|
||||
|
||||
findRecent = async (maxAgeMs: number): Promise<QueueWorker[]> => {
|
||||
const cutoff = new Date(Date.now() - maxAgeMs);
|
||||
const docs = await QueueWorkerModel.find({ lastSeenAt: { $gte: cutoff } });
|
||||
return docs.map(this.toEntity);
|
||||
};
|
||||
|
||||
deleteById = async (workerId: string): Promise<void> => {
|
||||
await QueueWorkerModel.deleteOne({ _id: workerId });
|
||||
};
|
||||
}
|
||||
|
||||
export default MongoQueueWorkersRepository;
|
||||
@@ -5,7 +5,7 @@ import { AppError } from "@/utils/AppError.js";
|
||||
import { getDateForRange } from "@/utils/dataUtils.js";
|
||||
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js";
|
||||
import type { Incident, IncidentSummary, User } from "@/types/index.js";
|
||||
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js";
|
||||
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
|
||||
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "MonitorService";
|
||||
@@ -96,7 +96,7 @@ export interface IMonitorService {
|
||||
export class MonitorService implements IMonitorService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private jobQueue: ISuperSimpleQueue;
|
||||
private jobQueue: IJobQueue;
|
||||
private logger: ILogger;
|
||||
private games: GamesMap;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
@@ -117,7 +117,7 @@ export class MonitorService implements IMonitorService {
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
}: {
|
||||
jobQueue: ISuperSimpleQueue;
|
||||
jobQueue: IJobQueue;
|
||||
logger: ILogger;
|
||||
games: GamesMap;
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { User } from "@/types/index.js";
|
||||
import { canManageRole, type UserRole } from "@/types/user.js";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js";
|
||||
import { IEmailService } from "@/service/infrastructure/emailService.js";
|
||||
import { EnvConfig, ISettingsService } from "@/service/system/settingsService.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
@@ -55,7 +55,7 @@ export class UserService implements IUserService {
|
||||
private settingsService: ISettingsService;
|
||||
private logger: ILogger;
|
||||
private jwt: JwtType;
|
||||
private jobQueue: ISuperSimpleQueue;
|
||||
private jobQueue: IJobQueue;
|
||||
private crypto: CryptoType;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private usersRepository: IUsersRepository;
|
||||
@@ -83,7 +83,7 @@ export class UserService implements IUserService {
|
||||
settingsService: ISettingsService;
|
||||
logger: ILogger;
|
||||
jwt: JwtType;
|
||||
jobQueue: ISuperSimpleQueue;
|
||||
jobQueue: IJobQueue;
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
usersRepository: IUsersRepository;
|
||||
invitesRepository: IInvitesRepository;
|
||||
|
||||
@@ -10,8 +10,10 @@ export * from "@/service/business/statusPageService.js";
|
||||
export * from "@/service/business/userService.js";
|
||||
|
||||
// Infrastructure services
|
||||
export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
export * from "@/service/infrastructure/JobQueues/IJobQueue.js";
|
||||
export * from "@/service/infrastructure/JobQueues/SuperSimpleQueue.js";
|
||||
export * from "@/service/infrastructure/JobQueues/LessSimpleQueue.js";
|
||||
export * from "@/service/infrastructure/JobQueues/QueueHelper.js";
|
||||
export * from "@/service/infrastructure/notificationMessageBuilder.js";
|
||||
export * from "@/service/infrastructure/bufferService.js";
|
||||
export * from "@/service/infrastructure/emailService.js";
|
||||
@@ -33,6 +35,7 @@ export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/telegram.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/pushover.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/twilio.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/ntfy.js";
|
||||
|
||||
// System services
|
||||
export * from "@/service/system/settingsService.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Monitor } from "@/types/monitor.js";
|
||||
import { QueueWorker } from "@/types/settings.js";
|
||||
|
||||
export type QueueJobFailure = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
failedAt: number | null;
|
||||
failCount: number;
|
||||
failReason: string | null;
|
||||
};
|
||||
|
||||
export type QueueMetrics = {
|
||||
jobs: number;
|
||||
activeJobs: number;
|
||||
failingJobs: number;
|
||||
jobsWithFailures: QueueJobFailure[];
|
||||
totalRuns: number;
|
||||
totalFailures: number;
|
||||
workers: QueueWorker[];
|
||||
};
|
||||
|
||||
export type QueueJobSummary = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedBy: string | null;
|
||||
lockedUntil: number | null;
|
||||
lockedAt: number | null;
|
||||
runCount: number;
|
||||
failCount: number;
|
||||
failReason: string | null;
|
||||
lastRunAt: number | null;
|
||||
lastFinishedAt: number | null;
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
};
|
||||
|
||||
export type QueueJobsPagination = {
|
||||
page?: number;
|
||||
rowsPerPage?: number;
|
||||
};
|
||||
|
||||
export type QueueJobsPage = {
|
||||
jobs: QueueJobSummary[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
export interface IJobQueue {
|
||||
readonly serviceName: string;
|
||||
init(): Promise<boolean>;
|
||||
addJob(monitorId: string, monitor: Monitor): Promise<void>;
|
||||
deleteJob(monitor: Monitor): Promise<void>;
|
||||
pauseJob(monitor: Monitor): Promise<void>;
|
||||
resumeJob(monitor: Monitor): Promise<void>;
|
||||
updateJob(monitor: Monitor): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
getMetrics(): Promise<QueueMetrics>;
|
||||
getJobs(pagination: QueueJobsPagination): Promise<QueueJobsPage>;
|
||||
flushQueues(): Promise<{ success: boolean }>;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { IMonitorsRepository, IQueueWorkersRepository } from "@/repositories/index.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { MongoStore, Scheduler, IJob, AddJobInput } from "less-simple-scheduler";
|
||||
import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js";
|
||||
import { Monitor, supportsGeoCheck } from "@/types/monitor.js";
|
||||
import { type QueueMetrics, type QueueJobsPagination, type QueueJobsPage, IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js";
|
||||
import { type QueueMode } from "@/types/settings.js";
|
||||
import { EnvConfig } from "@/service/system/settingsService.js";
|
||||
const SERVICE_NAME = "JobQueue";
|
||||
|
||||
// How long after its last heartbeat a worker is still considered alive. Must be longer than the scheduler's heartbeat
|
||||
const WORKER_TTL_MS = 30_000;
|
||||
|
||||
// For discriminating job types
|
||||
type MonitorJob = Omit<IJob, "data"> & { data: Monitor };
|
||||
const MONITOR_TEMPLATES = new Set<string>(["monitor-job", "geo-check-job"]);
|
||||
const isMonitorJob = (job: IJob): job is MonitorJob => MONITOR_TEMPLATES.has(job.template);
|
||||
|
||||
export class LessSimpleQueue implements IJobQueue {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
private helper: IQueueHelper;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private workersRepository: IQueueWorkersRepository;
|
||||
private readonly scheduler: Scheduler;
|
||||
private queueMode: QueueMode;
|
||||
private heartbeatListener: ((workerId: string) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
helper: IQueueHelper,
|
||||
monitorsRepository: IMonitorsRepository,
|
||||
workersRepository: IQueueWorkersRepository,
|
||||
scheduler: Scheduler,
|
||||
queueMode: QueueMode
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.helper = helper;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
this.workersRepository = workersRepository;
|
||||
this.scheduler = scheduler;
|
||||
this.queueMode = queueMode;
|
||||
this.registerListeners();
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return LessSimpleQueue.SERVICE_NAME;
|
||||
}
|
||||
|
||||
private registerListeners = () => {
|
||||
this.scheduler.on("scheduler:start", () => {
|
||||
this.logger.info({
|
||||
message: "Scheduler started",
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("scheduler:stop", () => {
|
||||
this.logger.info({
|
||||
message: "Scheduler stopped",
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("scheduler:error", (workerId, error) => {
|
||||
this.logger.error({
|
||||
message: `${workerId} error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:locked", (workerId, job) => {
|
||||
this.logger.debug({
|
||||
message: `${workerId} ${job.id} locked`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:abort", (workerId, job, reason) => {
|
||||
this.logger.warn({
|
||||
message: `${workerId} ${job.id} aborted: ${reason}`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:attempt", (workerId, job, attempt) => {
|
||||
this.logger.debug({
|
||||
message: `${workerId} ${job.id} attempt ${attempt}`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:complete", (workerId, job) => {
|
||||
this.logger.debug({
|
||||
message: `${workerId} ${job.id} completed successfully`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:exhausted", (workerId, job, error) => {
|
||||
this.logger.error({
|
||||
message: `${workerId} ${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:fail", (workerId, job, error, attempt) => {
|
||||
this.logger.warn({
|
||||
message: `${workerId} ${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:start", (workerId, job) => {
|
||||
this.logger.debug({
|
||||
message: `${workerId} ${job.id} started`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
static async create(
|
||||
logger: ILogger,
|
||||
helper: IQueueHelper,
|
||||
monitorsRepository: IMonitorsRepository,
|
||||
workersRepository: IQueueWorkersRepository,
|
||||
envSettings: EnvConfig,
|
||||
queueMode: QueueMode
|
||||
) {
|
||||
const store = new MongoStore({
|
||||
url: envSettings.dbConnectionString ?? "mongodb://localhost:27017/uptime_db",
|
||||
});
|
||||
const scheduler = new Scheduler(store, {
|
||||
concurrency: envSettings.queueMode === "primary" ? 0 : 50,
|
||||
processEvery: 100,
|
||||
});
|
||||
const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode);
|
||||
await instance.init(queueMode);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private startHeartbeat = (queueMode: QueueMode) => {
|
||||
const beat = (
|
||||
workerId: string // This is the heartbeat function that registers workers
|
||||
) =>
|
||||
this.workersRepository.upsert(workerId, queueMode).catch((error: unknown) => {
|
||||
this.logger.warn({
|
||||
message: `Worker heartbeat failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "startHeartbeat",
|
||||
});
|
||||
});
|
||||
beat(this.scheduler.workerId); // Beat immediately to register
|
||||
if (this.heartbeatListener) {
|
||||
this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); // remove old listeners
|
||||
}
|
||||
this.heartbeatListener = beat; /// Keep a reference to the listener
|
||||
this.scheduler.on("scheduler:heartbeat", beat); // beat on every heartbeat
|
||||
};
|
||||
|
||||
init = async (queueMode: QueueMode = "primary") => {
|
||||
try {
|
||||
await this.scheduler.start();
|
||||
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
|
||||
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
|
||||
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
|
||||
this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob());
|
||||
|
||||
// Register in the worker registry
|
||||
this.startHeartbeat(queueMode);
|
||||
|
||||
// If this is a worker, return early - primary will populate queue
|
||||
if (queueMode === "worker") {
|
||||
this.logger.info({
|
||||
message: `Worker queue: ${this.scheduler.workerId} initialized`,
|
||||
service: SERVICE_NAME,
|
||||
method: "init",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const monitors = await this.monitorsRepository.findAll();
|
||||
if (!monitors) {
|
||||
return true;
|
||||
}
|
||||
const jobInputs = monitors.flatMap((monitor) => this.buildJobInputs(monitor));
|
||||
if (jobInputs.length > 0) {
|
||||
await this.scheduler.addJobs(jobInputs);
|
||||
}
|
||||
|
||||
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, upsert: true });
|
||||
this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000, upsert: true });
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: `Failed to initialize LessSimpleQueue: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "init",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Builds the scheduler inputs for a monitor: always a monitor-job, plus a
|
||||
// geo-check job when the monitor supports and enables geo checks.
|
||||
private buildJobInputs = (monitor: Monitor): AddJobInput[] => {
|
||||
const inputs: AddJobInput[] = [
|
||||
{
|
||||
id: monitor.id,
|
||||
template: "monitor-job",
|
||||
repeat: monitor.interval,
|
||||
active: monitor.isActive,
|
||||
data: monitor,
|
||||
upsert: true,
|
||||
jitter: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) {
|
||||
inputs.push({
|
||||
id: `${monitor.id}-geo`,
|
||||
template: "geo-check-job",
|
||||
repeat: monitor.geoCheckInterval,
|
||||
active: monitor.isActive,
|
||||
data: monitor,
|
||||
upsert: true,
|
||||
jitter: true,
|
||||
});
|
||||
}
|
||||
|
||||
return inputs;
|
||||
};
|
||||
|
||||
addJob = async (_monitorId: string, monitor: Monitor) => {
|
||||
await this.scheduler.addJobs(this.buildJobInputs(monitor));
|
||||
};
|
||||
|
||||
deleteJob = async (monitor: Monitor) => {
|
||||
this.scheduler.removeJob(monitor.id);
|
||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
|
||||
};
|
||||
|
||||
pauseJob = async (monitor: Monitor) => {
|
||||
const result = await this.scheduler.pauseJob(monitor.id);
|
||||
if (result === false) {
|
||||
throw new Error("Failed to pause monitor");
|
||||
}
|
||||
this.scheduler.updateJob(monitor.id, { data: monitor });
|
||||
|
||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||
if (geoJob) {
|
||||
const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`);
|
||||
if (geoRes === false) {
|
||||
this.logger.error({
|
||||
message: `Failed to pause geo check job for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "pauseJob",
|
||||
});
|
||||
}
|
||||
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
message: `Paused monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "pauseJob",
|
||||
});
|
||||
};
|
||||
|
||||
resumeJob = async (monitor: Monitor) => {
|
||||
const result = await this.scheduler.resumeJob(monitor.id);
|
||||
if (result === false) {
|
||||
throw new Error("Failed to resume monitor");
|
||||
}
|
||||
this.scheduler.updateJob(monitor.id, { data: monitor });
|
||||
|
||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||
if (geoJob) {
|
||||
const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`);
|
||||
if (geoRes === false) {
|
||||
this.logger.error({
|
||||
message: `Failed to resume geo check job for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "resumeJob",
|
||||
});
|
||||
}
|
||||
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
|
||||
}
|
||||
this.logger.debug({
|
||||
message: `Resumed monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "resumeJob",
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
upsert: true,
|
||||
});
|
||||
};
|
||||
|
||||
updateJob = async (monitor: Monitor) => {
|
||||
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
|
||||
await this.syncGeoJob(monitor);
|
||||
};
|
||||
|
||||
shutdown = async () => {
|
||||
// Stop beating before we remove this worker, so a heartbeat firing between
|
||||
// the remove and the scheduler stopping can't re-insert it.
|
||||
if (this.heartbeatListener) {
|
||||
this.scheduler.off("scheduler:heartbeat", this.heartbeatListener);
|
||||
this.heartbeatListener = null;
|
||||
}
|
||||
await this.workersRepository.deleteById(this.scheduler.workerId).catch((error: unknown) => {
|
||||
this.logger.warn({
|
||||
message: `Failed to deregister worker on shutdown: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "shutdown",
|
||||
});
|
||||
});
|
||||
this.scheduler.stop();
|
||||
};
|
||||
|
||||
getMetrics = async (): Promise<QueueMetrics> => {
|
||||
const stats = await this.scheduler.getStats();
|
||||
|
||||
const jobsWithFailures = stats.jobsWithFailures.map((failure) => {
|
||||
// `data` is untyped at the store level: for monitor jobs it's the Monitor,
|
||||
// for system jobs (e.g. cleanup) it has no url/type => null
|
||||
const monitor = failure.data as Monitor | undefined;
|
||||
return {
|
||||
monitorId: failure.id,
|
||||
monitorUrl: monitor?.url ?? null,
|
||||
monitorType: monitor?.type ?? null,
|
||||
failedAt: failure.failedAt,
|
||||
failCount: failure.failCount,
|
||||
failReason: failure.failReason,
|
||||
};
|
||||
});
|
||||
|
||||
const workers = await this.workersRepository.findRecent(WORKER_TTL_MS);
|
||||
return {
|
||||
jobs: stats.jobs,
|
||||
activeJobs: stats.activeJobs,
|
||||
failingJobs: stats.failingJobs,
|
||||
totalRuns: stats.totalRuns,
|
||||
totalFailures: stats.totalFailures,
|
||||
jobsWithFailures,
|
||||
workers,
|
||||
};
|
||||
};
|
||||
|
||||
getJobs = async ({ page = 0, rowsPerPage = 0 }: QueueJobsPagination): Promise<QueueJobsPage> => {
|
||||
const listOptions = rowsPerPage > 0 ? { skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage } : undefined;
|
||||
const [jobs, count] = await Promise.all([this.scheduler.getJobs(listOptions), this.scheduler.countJobs()]);
|
||||
const mapped = jobs.map((job) => {
|
||||
const monitor = isMonitorJob(job) ? job.data : undefined;
|
||||
|
||||
return {
|
||||
monitorId: job.id,
|
||||
monitorUrl: monitor?.url || null,
|
||||
monitorType: monitor?.type || null,
|
||||
monitorInterval: monitor?.interval || null,
|
||||
monitorGeoInterval: monitor?.geoCheckInterval || null,
|
||||
monitorActive: monitor?.isActive ?? null,
|
||||
active: job.active,
|
||||
repeat: job.repeat ?? null,
|
||||
lockedBy: job.lockedBy ?? null,
|
||||
lockedUntil: job.lockedUntil ?? null,
|
||||
lockedAt: job.lockedAt ?? null,
|
||||
runCount: job.runCount ?? 0,
|
||||
failCount: job.failCount ?? 0,
|
||||
failReason: job.lastError ?? null,
|
||||
lastRunAt: job.lastStartedAt ?? null,
|
||||
lastFinishedAt: job.lastFinishedAt ?? null,
|
||||
lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastStartedAt ? null : job.lastFinishedAt - job.lastStartedAt,
|
||||
lastFailedAt: job.lastFailedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return { jobs: mapped, count };
|
||||
};
|
||||
|
||||
flushQueues = async () => {
|
||||
if (this.queueMode !== "primary") {
|
||||
// Don't allow workers to flush, though this isn't currently reachable since workers have no API endpoints
|
||||
this.logger.warn({ message: "Ignoring flush on non-primary worker", service: SERVICE_NAME, method: "flushQueues" });
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const flushRes = await this.scheduler.flushJobs(); // Less Simple Queue must flush before stopping as it needs DB
|
||||
const stopRes = await this.scheduler.stop(); // Destructive, other workers will abort their jobs
|
||||
const initRes = await this.init();
|
||||
return {
|
||||
success: Boolean(stopRes && flushRes && initRes),
|
||||
};
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -25,7 +25,7 @@ import {
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { IBufferService } from "@/service/index.js";
|
||||
|
||||
export interface ISuperSimpleQueueHelper {
|
||||
export interface IQueueHelper {
|
||||
readonly serviceName: string;
|
||||
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
|
||||
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
|
||||
@@ -48,7 +48,7 @@ export interface MonitorActionDecision {
|
||||
};
|
||||
}
|
||||
|
||||
export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
export class QueueHelper implements IQueueHelper {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
@@ -105,7 +105,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
return SuperSimpleQueueHelper.SERVICE_NAME;
|
||||
return QueueHelper.SERVICE_NAME;
|
||||
}
|
||||
|
||||
getHeartbeatJob = () => {
|
||||
+50
-61
@@ -1,74 +1,31 @@
|
||||
import { IMonitorsRepository } from "@/repositories/index.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import Scheduler from "super-simple-scheduler";
|
||||
import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js";
|
||||
import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js";
|
||||
import { hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { QueueMode } from "@/types/settings.js";
|
||||
import { IJobQueue, type QueueJobSummary } from "@/service/infrastructure/JobQueues/IJobQueue.js";
|
||||
|
||||
const SERVICE_NAME = "JobQueue";
|
||||
|
||||
type QueueJobFailure = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
failedAt: number | null;
|
||||
failCount: number;
|
||||
failReason: string | null;
|
||||
};
|
||||
|
||||
type QueueMetrics = {
|
||||
jobs: number;
|
||||
activeJobs: number;
|
||||
failingJobs: number;
|
||||
jobsWithFailures: QueueJobFailure[];
|
||||
totalRuns: number;
|
||||
totalFailures: number;
|
||||
};
|
||||
|
||||
type QueueJobSummary = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedAt: number | null;
|
||||
runCount: number;
|
||||
failCount: number;
|
||||
failReason: string | null;
|
||||
lastRunAt: number | null;
|
||||
lastFinishedAt: number | null;
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
};
|
||||
|
||||
export interface ISuperSimpleQueue {
|
||||
readonly serviceName: string;
|
||||
init(): Promise<boolean>;
|
||||
addJob(monitorId: string, monitor: Monitor): Promise<void>;
|
||||
deleteJob(monitor: Monitor): Promise<void>;
|
||||
pauseJob(monitor: Monitor): Promise<void>;
|
||||
resumeJob(monitor: Monitor): Promise<void>;
|
||||
updateJob(monitor: Monitor): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
getMetrics(): Promise<QueueMetrics>;
|
||||
getJobs(): Promise<QueueJobSummary[]>;
|
||||
flushQueues(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
export class SuperSimpleQueue implements IJobQueue {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
private helper: ISuperSimpleQueueHelper;
|
||||
private helper: IQueueHelper;
|
||||
private workerId: string;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private readonly scheduler: Scheduler;
|
||||
|
||||
constructor(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) {
|
||||
constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) {
|
||||
this.logger = logger;
|
||||
this.helper = helper;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
this.scheduler = scheduler;
|
||||
this.registerListeners();
|
||||
this.workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
|
||||
}
|
||||
|
||||
get serviceName() {
|
||||
@@ -143,7 +100,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
});
|
||||
};
|
||||
|
||||
static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
|
||||
static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository) {
|
||||
const scheduler = new Scheduler({
|
||||
concurrency: 50,
|
||||
// storeType: "mongo",
|
||||
@@ -158,8 +115,6 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
|
||||
init = async () => {
|
||||
try {
|
||||
this.registerListeners();
|
||||
|
||||
this.scheduler.start();
|
||||
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
|
||||
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
|
||||
@@ -312,7 +267,26 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
|
||||
getMetrics = async () => {
|
||||
const jobs = await this.scheduler.getJobs();
|
||||
const metrics = jobs.reduce(
|
||||
const metrics: {
|
||||
jobs: number;
|
||||
activeJobs: number;
|
||||
failingJobs: number;
|
||||
jobsWithFailures: Array<{
|
||||
monitorId: string | number;
|
||||
monitorUrl: string;
|
||||
monitorType: MonitorType;
|
||||
failedAt: number | null;
|
||||
failCount: number;
|
||||
failReason: string | null;
|
||||
}>;
|
||||
totalRuns: number;
|
||||
totalFailures: number;
|
||||
workers: Array<{
|
||||
workerId: string;
|
||||
mode: QueueMode;
|
||||
lastSeenAt: number;
|
||||
}>;
|
||||
} = jobs.reduce(
|
||||
(acc, job) => {
|
||||
const runCount = job.runCount ?? 0;
|
||||
const failCount = job.failCount ?? 0;
|
||||
@@ -355,14 +329,21 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
}>,
|
||||
totalRuns: 0,
|
||||
totalFailures: 0,
|
||||
workers: [
|
||||
{
|
||||
workerId: this.workerId,
|
||||
mode: "primary",
|
||||
lastSeenAt: Date.now(),
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
return metrics;
|
||||
};
|
||||
|
||||
getJobs = async () => {
|
||||
getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: QueueJobSummary[]; count: number }> => {
|
||||
const jobs = await this.scheduler.getJobs();
|
||||
return jobs.map((job) => {
|
||||
const mapped = jobs.map((job) => {
|
||||
return {
|
||||
monitorId: job.id,
|
||||
monitorUrl: job.data?.url || null,
|
||||
@@ -372,6 +353,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
monitorActive: job.data?.isActive ?? null,
|
||||
active: job.active,
|
||||
repeat: job.repeat ?? null,
|
||||
lockedBy: job.lockedAt ? this.workerId : null,
|
||||
lockedUntil: null,
|
||||
lockedAt: job.lockedAt ?? null,
|
||||
runCount: job.runCount ?? 0,
|
||||
failCount: job.failCount ?? 0,
|
||||
@@ -382,6 +365,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
lastFailedAt: job.lastFailedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const start = Math.max(page, 0) * rowsPerPage;
|
||||
return {
|
||||
jobs: rowsPerPage > 0 ? mapped.slice(start, start + rowsPerPage) : mapped,
|
||||
count: mapped.length,
|
||||
};
|
||||
};
|
||||
|
||||
flushQueues = async () => {
|
||||
@@ -26,7 +26,7 @@ export class BufferService implements IBufferService {
|
||||
private geoChecksService: IGeoChecksService;
|
||||
|
||||
constructor(logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, settingsService: ISettingsService) {
|
||||
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute
|
||||
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 1000 : 1000 * 60 * 1; // 1 minute
|
||||
this.logger = logger;
|
||||
this.checksService = checkService;
|
||||
this.geoChecksService = geoChecksService;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvide
|
||||
import { HttpStatusPayload } from "@/types/network.js";
|
||||
import { MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Agent as HttpsAgent } from "https";
|
||||
import { Agent as HttpAgent } from "http";
|
||||
import { Monitor, MonitorType } from "@/types/monitor.js";
|
||||
import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js";
|
||||
import CacheableLookup from "cacheable-lookup";
|
||||
@@ -11,6 +12,11 @@ import CacheableLookup from "cacheable-lookup";
|
||||
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
readonly type = "http";
|
||||
|
||||
// Shared, pooled agents reused across every check
|
||||
private readonly httpAgent: HttpAgent;
|
||||
private readonly httpsAgent: HttpsAgent;
|
||||
private readonly httpsAgentInsecure: HttpsAgent;
|
||||
|
||||
constructor(
|
||||
private got: Got,
|
||||
private advancedMatcher: IAdvancedMatcher
|
||||
@@ -23,6 +29,11 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
},
|
||||
retry: { limit: 1 },
|
||||
});
|
||||
|
||||
const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256 };
|
||||
this.httpAgent = new HttpAgent(agentOptions);
|
||||
this.httpsAgent = new HttpsAgent({ ...agentOptions, rejectUnauthorized: true });
|
||||
this.httpsAgentInsecure = new HttpsAgent({ ...agentOptions, rejectUnauthorized: false });
|
||||
}
|
||||
|
||||
supports(type: MonitorType) {
|
||||
@@ -38,7 +49,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
status: false,
|
||||
code: error.response?.statusCode ?? NETWORK_ERROR,
|
||||
message: error.message,
|
||||
responseTime: error.timings?.phases?.total ?? 0,
|
||||
responseTime: error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0,
|
||||
timings: error.timings,
|
||||
payload: null as T,
|
||||
};
|
||||
@@ -68,7 +79,8 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
};
|
||||
|
||||
options.agent = {
|
||||
https: new HttpsAgent({ rejectUnauthorized: !ignoreTlsErrors }),
|
||||
http: this.httpAgent,
|
||||
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -84,7 +96,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
status: false,
|
||||
code: response.statusCode,
|
||||
message: "Response is not JSON",
|
||||
responseTime: response.timings.phases.total ?? 0,
|
||||
responseTime: response.timings.phases.firstByte ?? 0,
|
||||
timings: response.timings,
|
||||
payload: response.body as unknown as T,
|
||||
};
|
||||
@@ -109,7 +121,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
status: response.ok && matchResult.ok,
|
||||
code: response.statusCode,
|
||||
message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message,
|
||||
responseTime: response.timings.phases.total ?? 0,
|
||||
responseTime: response.timings.phases.firstByte ?? 0,
|
||||
timings: response.timings,
|
||||
payload,
|
||||
extracted: matchResult.extracted,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HardwareStatusPayload, Monitor, MonitorStatusResponse } from "@/types/index.js";
|
||||
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js";
|
||||
import type {
|
||||
NotificationMessage,
|
||||
NotificationType,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
const SERVICE_NAME = "NtfyProvider";
|
||||
import type { Notification } from "@/types/index.js";
|
||||
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||
import got from "got";
|
||||
|
||||
export class NtfyProvider extends NotificationProvider {
|
||||
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
|
||||
if (!notification.address || !notification.topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
|
||||
body: getTestMessage(),
|
||||
headers: {
|
||||
Title: "Checkmate test notification",
|
||||
Priority: "default",
|
||||
},
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: "ntfy test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
|
||||
if (!notification.address || !notification.topic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
|
||||
body: this.buildNtfyText(message),
|
||||
headers: {
|
||||
Title: message.content.title,
|
||||
Priority: this.mapPriority(message.severity),
|
||||
Tags: this.mapTags(message.severity),
|
||||
},
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
this.logger.info({
|
||||
message: "ntfy notification sent",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: "ntfy alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private buildTopicUrl(address: string, topic: string): string {
|
||||
return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
private buildNtfyText(message: NotificationMessage): string {
|
||||
const lines = [
|
||||
message.content.summary,
|
||||
"",
|
||||
"Monitor Details:",
|
||||
`- Name: ${message.monitor.name}`,
|
||||
`- URL: ${message.monitor.url}`,
|
||||
`- Type: ${message.monitor.type}`,
|
||||
`- Status: ${message.monitor.status}`,
|
||||
];
|
||||
|
||||
if (message.content.details && message.content.details.length > 0) {
|
||||
lines.push("", "Additional Information:");
|
||||
message.content.details.forEach((detail) => lines.push(`- ${detail}`));
|
||||
}
|
||||
|
||||
if (message.content.thresholds && message.content.thresholds.length > 0) {
|
||||
lines.push("", "Threshold Breaches:");
|
||||
message.content.thresholds.forEach((breach) => {
|
||||
lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (message.content.incident) {
|
||||
lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private mapPriority(severity: NotificationMessage["severity"]): string {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "high";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "success":
|
||||
return "low";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
private mapTags(severity: NotificationMessage["severity"]): string {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "rotating_light";
|
||||
case "warning":
|
||||
return "warning";
|
||||
case "success":
|
||||
return "white_check_mark";
|
||||
default:
|
||||
return "information_source";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Monitor, MonitorStatusResponse, Notification } from "@/types/index
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js";
|
||||
import { INotificationProvider } from "./notificationProviders/INotificationProvider.js";
|
||||
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js";
|
||||
import type { ISettingsService } from "@/service/system/settingsService.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
|
||||
@@ -36,6 +36,7 @@ export class NotificationsService implements INotificationsService {
|
||||
private telegramProvider: INotificationProvider;
|
||||
private pushoverProvider: INotificationProvider;
|
||||
private twilioProvider: INotificationProvider;
|
||||
private ntfyProvider: INotificationProvider;
|
||||
private logger: ILogger;
|
||||
private settingsService: ISettingsService;
|
||||
private notificationMessageBuilder: INotificationMessageBuilder;
|
||||
@@ -53,6 +54,7 @@ export class NotificationsService implements INotificationsService {
|
||||
telegramProvider: INotificationProvider,
|
||||
pushoverProvider: INotificationProvider,
|
||||
twilioProvider: INotificationProvider,
|
||||
ntfyProvider: INotificationProvider,
|
||||
settingsService: ISettingsService,
|
||||
logger: ILogger,
|
||||
notificationMessageBuilder: INotificationMessageBuilder
|
||||
@@ -69,6 +71,7 @@ export class NotificationsService implements INotificationsService {
|
||||
this.telegramProvider = telegramProvider;
|
||||
this.pushoverProvider = pushoverProvider;
|
||||
this.twilioProvider = twilioProvider;
|
||||
this.ntfyProvider = ntfyProvider;
|
||||
this.settingsService = settingsService;
|
||||
this.logger = logger;
|
||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||
@@ -112,6 +115,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
|
||||
case "twilio":
|
||||
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
|
||||
case "ntfy":
|
||||
return await this.ntfyProvider.sendMessage!(notification, notificationMessage);
|
||||
default:
|
||||
this.logger.warn({
|
||||
message: `Unknown notification type: ${notification.type}`,
|
||||
@@ -178,6 +183,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.pushoverProvider.sendTestAlert(notification);
|
||||
case "twilio":
|
||||
return await this.twilioProvider.sendTestAlert(notification);
|
||||
case "ntfy":
|
||||
return await this.ntfyProvider.sendTestAlert(notification);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ISettingsRepository } from "@/repositories/index.js";
|
||||
import { type DbType, Settings, SettingsUpdate } from "@/types/index.js";
|
||||
import { type DbType, type QueueType, type QueueMode, Settings, SettingsUpdate } from "@/types/index.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ValidatedEnv } from "@/validation/envValidation.js";
|
||||
import type { StringValue } from "ms";
|
||||
@@ -13,6 +13,8 @@ export type EnvConfig = {
|
||||
clientHost: string;
|
||||
dbConnectionString: string;
|
||||
dbType: DbType;
|
||||
queueType: QueueType;
|
||||
queueMode: QueueMode;
|
||||
statusPageThemesEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -39,6 +41,8 @@ export class SettingsService implements ISettingsService {
|
||||
clientHost: env.CLIENT_HOST,
|
||||
dbConnectionString: env.DB_CONNECTION_STRING,
|
||||
dbType: env.DB_TYPE,
|
||||
queueType: env.QUEUE_TYPE,
|
||||
queueMode: env.QUEUE_MODE,
|
||||
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { InitializedServices } from "./config/services.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import type { Server } from "http";
|
||||
|
||||
export const initShutdownListener = (server: Server, services: InitializedServices) => {
|
||||
export const initShutdownListener = (server: Server | null, services: InitializedServices) => {
|
||||
const SERVICE_NAME = "Server";
|
||||
|
||||
let isShuttingDown = false;
|
||||
@@ -15,7 +15,7 @@ export const initShutdownListener = (server: Server, services: InitializedServic
|
||||
logger.info({ message: "Attempting graceful shutdown" });
|
||||
|
||||
try {
|
||||
server.close();
|
||||
server?.close();
|
||||
await services.jobQueue.shutdown();
|
||||
await services.db.disconnect();
|
||||
logger.info({ message: "Graceful shutdown complete" });
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
@@ -25,6 +26,7 @@ export interface Notification {
|
||||
accessToken?: string;
|
||||
accountSid?: string;
|
||||
twilioPhoneNumber?: string;
|
||||
topic?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
export const DbTypes = ["mongodb"] as const;
|
||||
export type DbType = (typeof DbTypes)[number];
|
||||
|
||||
export const QueueTypes = ["lessSimpleQueue", "superSimpleQueue"] as const;
|
||||
export type QueueType = (typeof QueueTypes)[number];
|
||||
|
||||
export const QueueModes = ["primary", "worker"] as const;
|
||||
export type QueueMode = (typeof QueueModes)[number];
|
||||
|
||||
export type QueueWorker = {
|
||||
workerId: string; // hostname:pid:uuid
|
||||
mode: QueueMode;
|
||||
lastSeenAt: number; // epoch ms of the last heartbeat
|
||||
};
|
||||
|
||||
export interface SettingsThresholds {
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { DbTypes } from "@/types/settings.js";
|
||||
import { DbTypes, QueueTypes, QueueModes } from "@/types/settings.js";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
|
||||
const envSchema = z.object({
|
||||
@@ -12,6 +12,9 @@ const envSchema = z.object({
|
||||
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
|
||||
DB_TYPE: z.enum(DbTypes).default("mongodb"),
|
||||
|
||||
QUEUE_TYPE: z.enum(QueueTypes).default("superSimpleQueue"),
|
||||
QUEUE_MODE: z.enum(QueueModes).default("primary"),
|
||||
|
||||
// JWT Authentication
|
||||
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
|
||||
TOKEN_TTL: z.string().default("99d"),
|
||||
|
||||
@@ -88,6 +88,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
phone: z.string().min(1, "Recipient phone number is required"),
|
||||
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
|
||||
}),
|
||||
// ntfy notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("ntfy"),
|
||||
address: z.url({ message: "Please enter a valid ntfy server URL" }),
|
||||
topic: z.string().min(1, "Topic is required"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const getQueueJobsQueryValidation = z.object({
|
||||
page: z.coerce.number().int().min(0).optional(),
|
||||
rowsPerPage: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import { QueueHelper } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts";
|
||||
import { StatusService } from "../../src/service/infrastructure/statusService.ts";
|
||||
import { IncidentService } from "../../src/service/business/incidentService.ts";
|
||||
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
|
||||
@@ -143,7 +143,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
|
||||
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const helper = new SuperSimpleQueueHelper(
|
||||
const helper = new QueueHelper(
|
||||
logger,
|
||||
networkService as any,
|
||||
statusService as any,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createMockLogger } from "../helpers/createMockLogger.ts";
|
||||
import type { IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts";
|
||||
import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts";
|
||||
import type { Monitor } from "../../src/types/monitor.ts";
|
||||
import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import type { MonitorActionDecision } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const makeGotResponse = (overrides?: Record<string, any>) => ({
|
||||
statusMessage: "OK",
|
||||
headers: { "content-type": "text/html" },
|
||||
body: "<html></html>",
|
||||
timings: { phases: { total: 100 } },
|
||||
timings: { phases: { firstByte: 100, total: 120 } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -181,8 +181,8 @@ describe("HttpProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 when timings.phases.total is undefined", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } }));
|
||||
it("defaults responseTime to 0 when timings.phases.firstByte is undefined", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { firstByte: undefined } } }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
@@ -223,11 +223,11 @@ describe("HttpProvider", () => {
|
||||
expect(result.message).toBe("Response is not JSON");
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => {
|
||||
it("defaults responseTime to 0 in non-JSON jsonPath response when firstByte is undefined", async () => {
|
||||
mockGot.mockResolvedValue(
|
||||
makeGotResponse({
|
||||
headers: { "content-type": "text/html" },
|
||||
timings: { phases: { total: undefined } },
|
||||
timings: { phases: { firstByte: undefined } },
|
||||
})
|
||||
);
|
||||
const { provider } = createProvider();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { NtfyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/ntfy.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new NtfyProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
const makeNtfyNotification = (overrides = {}) =>
|
||||
makeNotification({
|
||||
address: "https://ntfy.sh/",
|
||||
topic: "checkmate-alerts",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
testNotificationProviderContract("NtfyProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNtfyNotification(),
|
||||
});
|
||||
|
||||
describe("NtfyProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("posts the test message to the configured topic", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/checkmate-alerts",
|
||||
expect.objectContaining({
|
||||
body: expect.any(String),
|
||||
headers: expect.objectContaining({ Title: "Checkmate test notification" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when topic is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ topic: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNtfyNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("posts plain text with severity headers to the configured topic", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/checkmate-alerts",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("Monitor Details"),
|
||||
headers: expect.objectContaining({
|
||||
Title: "Monitor Down: Test Monitor",
|
||||
Priority: "high",
|
||||
Tags: "rotating_light",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("url-encodes topics", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNtfyNotification({ topic: "ops alerts" }) as any, makeMessage());
|
||||
expect(mockGotPost.mock.calls[0][0]).toBe("https://ntfy.sh/ops%20alerts");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNtfyNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when topic is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNtfyNotification({ topic: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes threshold breaches in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithThresholds());
|
||||
expect(mockGotPost.mock.calls[0][1].body).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident links in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithIncident());
|
||||
expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,7 @@ describe("BufferService", () => {
|
||||
const { logger } = createService("development");
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("0.01s"),
|
||||
message: expect.stringContaining("1s"),
|
||||
service: "BufferService",
|
||||
method: "constructor",
|
||||
})
|
||||
@@ -231,7 +231,7 @@ describe("BufferService", () => {
|
||||
service.addToBuffer(makeCheck());
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalled();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalled();
|
||||
@@ -242,11 +242,11 @@ describe("BufferService", () => {
|
||||
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Add another check and advance again to confirm rescheduling
|
||||
service.addToBuffer(makeCheck({ id: "c2" }));
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -256,7 +256,7 @@ describe("BufferService", () => {
|
||||
(checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down"));
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -268,7 +268,7 @@ describe("BufferService", () => {
|
||||
// Should still reschedule — add another check and flush
|
||||
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
||||
service.addToBuffer(makeCheck({ id: "c2" }));
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -278,7 +278,7 @@ describe("BufferService", () => {
|
||||
// Override flushBuffer to throw past its own try/catch
|
||||
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("unexpected"));
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -292,7 +292,7 @@ describe("BufferService", () => {
|
||||
const { service, logger } = createService();
|
||||
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce("string error");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from
|
||||
import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
|
||||
import type { Monitor } from "../../../src/types/monitor.ts";
|
||||
import type { Incident } from "../../../src/types/index.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { Monitor } from "../../../src/types/monitor.ts";
|
||||
|
||||
// ── Mock Scheduler / MongoStore ──────────────────────────────────────────────
|
||||
|
||||
const createScheduler = () => {
|
||||
const listeners: Record<string, Function[]> = {};
|
||||
return {
|
||||
workerId: "host-1:1234:abcd",
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
listeners[event] = listeners[event] || [];
|
||||
listeners[event].push(cb);
|
||||
}),
|
||||
off: jest.fn((event: string, cb: Function) => {
|
||||
listeners[event] = (listeners[event] || []).filter((fn) => fn !== cb);
|
||||
}),
|
||||
emit: (event: string, ...args: unknown[]) => {
|
||||
(listeners[event] || []).forEach((cb) => cb(...args));
|
||||
},
|
||||
start: jest.fn().mockResolvedValue(true),
|
||||
stop: jest.fn().mockResolvedValue(true),
|
||||
addTemplate: jest.fn(),
|
||||
addJob: jest.fn(),
|
||||
addJobs: jest.fn().mockResolvedValue({ inserted: 0, upserted: 0, modified: 0, failures: [] }),
|
||||
removeJob: jest.fn(),
|
||||
getJob: jest.fn().mockResolvedValue(null),
|
||||
getJobs: jest.fn().mockResolvedValue([]),
|
||||
countJobs: jest.fn().mockResolvedValue(0),
|
||||
getStats: jest.fn().mockResolvedValue({ jobs: 0, activeJobs: 0, failingJobs: 0, totalRuns: 0, totalFailures: 0, jobsWithFailures: [] }),
|
||||
pauseJob: jest.fn().mockResolvedValue(true),
|
||||
resumeJob: jest.fn().mockResolvedValue(true),
|
||||
updateJob: jest.fn(),
|
||||
flushJobs: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
};
|
||||
|
||||
const mockSchedulerInstance = createScheduler();
|
||||
const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance);
|
||||
const MockMongoStore = jest.fn();
|
||||
|
||||
jest.unstable_mockModule("less-simple-scheduler", () => ({
|
||||
Scheduler: MockScheduler,
|
||||
MongoStore: MockMongoStore,
|
||||
}));
|
||||
|
||||
const { LessSimpleQueue } = await import("../../../src/service/infrastructure/JobQueues/LessSimpleQueue.ts");
|
||||
|
||||
const createQueueHelper = () => ({
|
||||
getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()),
|
||||
getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()),
|
||||
getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()),
|
||||
getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()),
|
||||
});
|
||||
|
||||
const createMonitorsRepo = () => ({
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const createWorkersRepo = () => ({
|
||||
upsert: jest.fn().mockResolvedValue(undefined),
|
||||
findRecent: jest.fn().mockResolvedValue([]),
|
||||
deleteById: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const createQueue = (overrides?: Record<string, unknown>) => {
|
||||
const logger = createMockLogger();
|
||||
const helper = createQueueHelper();
|
||||
const monitorsRepository = createMonitorsRepo();
|
||||
const workersRepository = createWorkersRepo();
|
||||
const scheduler = createScheduler();
|
||||
|
||||
const defaults = { logger, helper, monitorsRepository, workersRepository, scheduler, queueMode: "primary", ...overrides };
|
||||
|
||||
const queue = new LessSimpleQueue(
|
||||
defaults.logger as any,
|
||||
defaults.helper as any,
|
||||
defaults.monitorsRepository as any,
|
||||
defaults.workersRepository as any,
|
||||
defaults.scheduler as any,
|
||||
defaults.queueMode as any
|
||||
);
|
||||
return { queue, ...defaults };
|
||||
};
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
interval: 60000,
|
||||
isActive: true,
|
||||
geoCheckEnabled: false,
|
||||
geoCheckInterval: 300000,
|
||||
geoCheckLocations: [],
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("LessSimpleQueue", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns JobQueue", () => {
|
||||
const { queue } = createQueue();
|
||||
expect(queue.serviceName).toBe("JobQueue");
|
||||
});
|
||||
});
|
||||
|
||||
// ── init (primary) ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("init - primary mode", () => {
|
||||
it("starts scheduler, registers all templates, seeds monitors and system jobs", async () => {
|
||||
const { queue, scheduler, monitorsRepository } = createQueue();
|
||||
|
||||
const result = await queue.init();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(scheduler.start).toHaveBeenCalled();
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function));
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function));
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function));
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function));
|
||||
expect(monitorsRepository.findAll).toHaveBeenCalled();
|
||||
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
|
||||
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" }));
|
||||
});
|
||||
|
||||
it("registers existing monitors in a single bulk write", async () => {
|
||||
const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })];
|
||||
const { queue, scheduler, monitorsRepository } = createQueue();
|
||||
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors);
|
||||
|
||||
await queue.init();
|
||||
|
||||
expect(scheduler.addJobs).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.addJobs).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "m1", template: "monitor-job", jitter: true }),
|
||||
expect.objectContaining({ id: "m2", template: "monitor-job", jitter: true }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true and skips system jobs when findAll returns null", async () => {
|
||||
const { queue, monitorsRepository, scheduler } = createQueue();
|
||||
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await queue.init();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
|
||||
});
|
||||
|
||||
it("returns false and logs error on failure", async () => {
|
||||
const { queue, logger, monitorsRepository } = createQueue();
|
||||
(monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down"));
|
||||
|
||||
const result = await queue.init();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") }));
|
||||
});
|
||||
|
||||
it("logs error with String(error) for non-Error exceptions", async () => {
|
||||
const { queue, logger, monitorsRepository } = createQueue();
|
||||
(monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
const result = await queue.init();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── init (worker) ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("init - worker mode", () => {
|
||||
it("starts scheduler and registers all templates", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
|
||||
const result = await queue.init("worker");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(scheduler.start).toHaveBeenCalled();
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function));
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function));
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function));
|
||||
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function));
|
||||
});
|
||||
|
||||
it("does NOT seed monitor jobs (no findAll, primary populates the queue)", async () => {
|
||||
const { queue, monitorsRepository } = createQueue();
|
||||
|
||||
await queue.init("worker");
|
||||
|
||||
expect(monitorsRepository.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT seed cleanup system jobs", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
|
||||
await queue.init("worker");
|
||||
|
||||
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
|
||||
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" }));
|
||||
expect(scheduler.addJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs the initialized worker with its workerId", async () => {
|
||||
const { queue, logger } = createQueue();
|
||||
|
||||
await queue.init("worker");
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Worker queue: host-1:1234:abcd initialized" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── registerListeners ──────────────────────────────────────────────────────
|
||||
|
||||
describe("registerListeners (via constructor)", () => {
|
||||
it("logs on scheduler:start", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("scheduler:start", "host-1:1234:abcd");
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" }));
|
||||
});
|
||||
|
||||
it("logs on scheduler:stop", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("scheduler:stop", "host-1:1234:abcd");
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" }));
|
||||
});
|
||||
|
||||
it("logs on scheduler:error with Error", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("scheduler:error", "w1", new Error("boom"));
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: boom" }));
|
||||
});
|
||||
|
||||
it("logs on scheduler:error with non-Error", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("scheduler:error", "w1", "string error");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: string error", stack: undefined }));
|
||||
});
|
||||
|
||||
it("logs on job:locked", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:locked", "w1", { id: "m1" });
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 locked" }));
|
||||
});
|
||||
|
||||
it("logs on job:abort", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:abort", "w1", { id: "m1" }, "timeout");
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 aborted: timeout" }));
|
||||
});
|
||||
|
||||
it("logs on job:attempt", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:attempt", "w1", { id: "m1" }, 2);
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 attempt 2" }));
|
||||
});
|
||||
|
||||
it("logs on job:complete", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:complete", "w1", { id: "m1" });
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 completed successfully" }));
|
||||
});
|
||||
|
||||
it("logs on job:exhausted with Error", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:exhausted", "w1", { id: "m1" }, new Error("gave up"));
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up" }));
|
||||
});
|
||||
|
||||
it("logs on job:exhausted with non-Error", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:exhausted", "w1", { id: "m1" }, "gave up");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up", stack: undefined }));
|
||||
});
|
||||
|
||||
it("logs on job:fail with Error", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:fail", "w1", { id: "m1" }, new Error("oops"), 1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops" }));
|
||||
});
|
||||
|
||||
it("logs on job:fail with non-Error", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:fail", "w1", { id: "m1" }, "oops", 1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops", stack: undefined }));
|
||||
});
|
||||
|
||||
it("logs on job:start", () => {
|
||||
const { scheduler, logger } = createQueue();
|
||||
scheduler.emit("job:start", "w1", { id: "m1" });
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 started" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── addJob ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("addJob", () => {
|
||||
it("adds monitor job to scheduler", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.addJob("mon-1", makeMonitor());
|
||||
expect(scheduler.addJobs).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true })])
|
||||
);
|
||||
});
|
||||
|
||||
it("adds geo check job when geoCheckEnabled and type supports it", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }));
|
||||
expect(scheduler.addJobs).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 })])
|
||||
);
|
||||
});
|
||||
|
||||
it("skips geo job when type does not support geo checks", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
|
||||
expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })]));
|
||||
});
|
||||
|
||||
it("skips geo job when geoCheckEnabled is false", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" }));
|
||||
expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })]));
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteJob ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("deleteJob", () => {
|
||||
it("removes monitor job", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.deleteJob(makeMonitor());
|
||||
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1");
|
||||
});
|
||||
|
||||
it("removes geo job if it exists", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
await queue.deleteJob(makeMonitor());
|
||||
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
|
||||
it("does not remove geo job if it does not exist", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.deleteJob(makeMonitor());
|
||||
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── pauseJob ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("pauseJob", () => {
|
||||
it("pauses monitor and geo job when geo exists", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
await queue.pauseJob(makeMonitor());
|
||||
|
||||
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
|
||||
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" }));
|
||||
});
|
||||
|
||||
it("does not pause geo job when it does not exist", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.pauseJob(makeMonitor());
|
||||
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
|
||||
expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
|
||||
it("throws when scheduler.pauseJob returns false for the main job", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
|
||||
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor");
|
||||
});
|
||||
|
||||
it("logs error but does not throw when geo pauseJob returns false", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
(scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
|
||||
|
||||
await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── resumeJob ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resumeJob", () => {
|
||||
it("resumes monitor and geo job when geo exists", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
await queue.resumeJob(makeMonitor());
|
||||
|
||||
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
|
||||
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" }));
|
||||
});
|
||||
|
||||
it("does not resume geo job when it does not exist", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.resumeJob(makeMonitor());
|
||||
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
|
||||
expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
|
||||
it("throws when scheduler.resumeJob returns false for the main job", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
|
||||
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor");
|
||||
});
|
||||
|
||||
it("logs error but does not throw when geo resumeJob returns false", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
(scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
|
||||
|
||||
await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateJob / syncGeoJob ─────────────────────────────────────────────────
|
||||
|
||||
describe("updateJob", () => {
|
||||
it("updates monitor job with new interval", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
const monitor = makeMonitor({ interval: 120000 });
|
||||
await queue.updateJob(monitor);
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor }));
|
||||
});
|
||||
|
||||
it("removes existing geo job when geoCheckEnabled is false", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" }));
|
||||
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
|
||||
it("removes existing geo job when type does not support geo checks", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
|
||||
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
|
||||
it("updates existing geo job when geoCheckEnabled", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
|
||||
await queue.updateJob(monitor);
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 }));
|
||||
});
|
||||
|
||||
it("creates new geo job when enabled but no existing job", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
|
||||
await queue.updateJob(monitor);
|
||||
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── shutdown ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("shutdown", () => {
|
||||
it("stops the scheduler", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.shutdown();
|
||||
expect(scheduler.stop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMetrics ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getMetrics", () => {
|
||||
it("returns empty metrics when no jobs", async () => {
|
||||
const { queue } = createQueue();
|
||||
const metrics = await queue.getMetrics();
|
||||
expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0, workers: [] });
|
||||
});
|
||||
|
||||
it("maps the store's aggregate counters through", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getStats as jest.Mock).mockResolvedValue({
|
||||
jobs: 2,
|
||||
activeJobs: 1,
|
||||
failingJobs: 1,
|
||||
totalRuns: 15,
|
||||
totalFailures: 2,
|
||||
jobsWithFailures: [],
|
||||
});
|
||||
const metrics = await queue.getMetrics();
|
||||
expect(metrics).toMatchObject({ jobs: 2, activeJobs: 1, failingJobs: 1, totalRuns: 15, totalFailures: 2 });
|
||||
});
|
||||
|
||||
it("derives monitor fields for failing monitor jobs from job data", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getStats as jest.Mock).mockResolvedValue({
|
||||
jobs: 1,
|
||||
activeJobs: 0,
|
||||
failingJobs: 1,
|
||||
totalRuns: 10,
|
||||
totalFailures: 2,
|
||||
jobsWithFailures: [{ id: "m1", data: { url: "http://a.com", type: "http" }, failedAt: 200, failCount: 2, failReason: "timeout" }],
|
||||
});
|
||||
const metrics = await queue.getMetrics();
|
||||
expect(metrics.jobsWithFailures).toEqual([
|
||||
{ monitorId: "m1", monitorUrl: "http://a.com", monitorType: "http", failedAt: 200, failCount: 2, failReason: "timeout" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves monitor fields null for non-monitor (cleanup) failures", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getStats as jest.Mock).mockResolvedValue({
|
||||
jobs: 1,
|
||||
activeJobs: 0,
|
||||
failingJobs: 1,
|
||||
totalRuns: 1,
|
||||
totalFailures: 1,
|
||||
jobsWithFailures: [{ id: "cleanup-orphaned", data: undefined, failedAt: 100, failCount: 1, failReason: "boom" }],
|
||||
});
|
||||
const metrics = await queue.getMetrics();
|
||||
expect(metrics.jobsWithFailures[0]).toEqual({
|
||||
monitorId: "cleanup-orphaned",
|
||||
monitorUrl: null,
|
||||
monitorType: null,
|
||||
failedAt: 100,
|
||||
failCount: 1,
|
||||
failReason: "boom",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMetrics: worker registry ──────────────────────────────────────────────
|
||||
|
||||
describe("getMetrics - worker registry", () => {
|
||||
it("returns the alive workers from the registry", async () => {
|
||||
const { queue, workersRepository } = createQueue();
|
||||
const alive = [
|
||||
{ workerId: "host:1:aaa", mode: "primary", lastSeenAt: 1000 },
|
||||
{ workerId: "host:2:bbb", mode: "worker", lastSeenAt: 2000 },
|
||||
];
|
||||
(workersRepository.findRecent as jest.Mock).mockResolvedValue(alive);
|
||||
|
||||
const metrics = await queue.getMetrics();
|
||||
|
||||
expect(workersRepository.findRecent).toHaveBeenCalledWith(30000);
|
||||
expect(metrics.workers).toEqual(alive);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getJobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getJobs", () => {
|
||||
it("maps scheduler jobs to summaries", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: "m1",
|
||||
template: "monitor-job",
|
||||
active: true,
|
||||
repeat: 60000,
|
||||
lockedAt: null,
|
||||
runCount: 5,
|
||||
failCount: 1,
|
||||
lastError: "timeout",
|
||||
lastStartedAt: 1000,
|
||||
lastFinishedAt: 1100,
|
||||
lastFailedAt: 900,
|
||||
data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true },
|
||||
},
|
||||
]);
|
||||
const { jobs } = await queue.getJobs({});
|
||||
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]).toEqual({
|
||||
monitorId: "m1",
|
||||
monitorUrl: "http://a.com",
|
||||
monitorType: "http",
|
||||
monitorInterval: 60000,
|
||||
monitorGeoInterval: 300000,
|
||||
monitorActive: true,
|
||||
active: true,
|
||||
repeat: 60000,
|
||||
lockedBy: null,
|
||||
lockedUntil: null,
|
||||
lockedAt: null,
|
||||
runCount: 5,
|
||||
failCount: 1,
|
||||
failReason: "timeout",
|
||||
lastRunAt: 1000,
|
||||
lastFinishedAt: 1100,
|
||||
lastRunTook: 100,
|
||||
lastFailedAt: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null lastRunTook when job is locked", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: "m1",
|
||||
template: "monitor-job",
|
||||
active: true,
|
||||
lockedAt: 999,
|
||||
runCount: 1,
|
||||
lastStartedAt: 1000,
|
||||
lastFinishedAt: 1100,
|
||||
data: { url: "a", type: "http", interval: 60000 },
|
||||
},
|
||||
]);
|
||||
const { jobs } = await queue.getJobs({});
|
||||
expect(jobs[0].lastRunTook).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves monitor fields null for non-monitor jobs", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, data: undefined }]);
|
||||
const { jobs } = await queue.getJobs({});
|
||||
expect(jobs[0].monitorUrl).toBeNull();
|
||||
expect(jobs[0].monitorType).toBeNull();
|
||||
expect(jobs[0].monitorInterval).toBeNull();
|
||||
expect(jobs[0].monitorGeoInterval).toBeNull();
|
||||
expect(jobs[0].monitorActive).toBeNull();
|
||||
});
|
||||
|
||||
it("pushes pagination to the scheduler and returns its total count", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", template: "monitor-job", active: true, data: { url: "a", type: "http", interval: 60000 } },
|
||||
]);
|
||||
(scheduler.countJobs as jest.Mock).mockResolvedValue(42);
|
||||
|
||||
const { jobs, count } = await queue.getJobs({ page: 2, rowsPerPage: 10 });
|
||||
|
||||
expect(scheduler.getJobs).toHaveBeenCalledWith({ skip: 20, limit: 10 });
|
||||
expect(count).toBe(42);
|
||||
expect(jobs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requests all jobs when rowsPerPage is omitted", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.countJobs as jest.Mock).mockResolvedValue(7);
|
||||
|
||||
const { count } = await queue.getJobs({});
|
||||
|
||||
expect(scheduler.getJobs).toHaveBeenCalledWith(undefined);
|
||||
expect(count).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flushQueues ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("flushQueues", () => {
|
||||
it("stops, flushes, and reinitializes", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
const result = await queue.flushQueues();
|
||||
expect(scheduler.stop).toHaveBeenCalled();
|
||||
expect(scheduler.flushJobs).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes before stopping, so the store is still connected for removeAll", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
const order: string[] = [];
|
||||
(scheduler.flushJobs as jest.Mock).mockImplementation(async () => {
|
||||
order.push("flush");
|
||||
return true;
|
||||
});
|
||||
(scheduler.stop as jest.Mock).mockImplementation(async () => {
|
||||
order.push("stop");
|
||||
return true;
|
||||
});
|
||||
await queue.flushQueues();
|
||||
expect(order).toEqual(["flush", "stop"]);
|
||||
});
|
||||
|
||||
it("refuses to flush on a non-primary worker without touching the scheduler", async () => {
|
||||
const { queue, scheduler } = createQueue({ queueMode: "worker" });
|
||||
const result = await queue.flushQueues();
|
||||
expect(result.success).toBe(false);
|
||||
expect(scheduler.flushJobs).not.toHaveBeenCalled();
|
||||
expect(scheduler.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when flush fails", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.flushJobs as jest.Mock).mockResolvedValue(false);
|
||||
const result = await queue.flushQueues();
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── static create ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("static create", () => {
|
||||
const envSettings = { dbConnectionString: "mongodb://localhost:27017/test_db" } as any;
|
||||
|
||||
it("creates a MongoStore + Scheduler and inits in primary mode", async () => {
|
||||
const logger = createMockLogger();
|
||||
const helper = createQueueHelper();
|
||||
const monitorsRepository = createMonitorsRepo();
|
||||
const workersRepository = createWorkersRepo();
|
||||
|
||||
const instance = await LessSimpleQueue.create(
|
||||
logger as any,
|
||||
helper as any,
|
||||
monitorsRepository as any,
|
||||
workersRepository as any,
|
||||
envSettings,
|
||||
"primary"
|
||||
);
|
||||
|
||||
expect(MockMongoStore).toHaveBeenCalledWith(expect.objectContaining({ url: "mongodb://localhost:27017/test_db" }));
|
||||
expect(MockScheduler).toHaveBeenCalled();
|
||||
expect(instance).toBeInstanceOf(LessSimpleQueue);
|
||||
expect(mockSchedulerInstance.start).toHaveBeenCalled();
|
||||
expect(monitorsRepository.findAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inits in worker mode without seeding monitors", async () => {
|
||||
const logger = createMockLogger();
|
||||
const helper = createQueueHelper();
|
||||
const monitorsRepository = createMonitorsRepo();
|
||||
const workersRepository = createWorkersRepo();
|
||||
|
||||
const instance = await LessSimpleQueue.create(
|
||||
logger as any,
|
||||
helper as any,
|
||||
monitorsRepository as any,
|
||||
workersRepository as any,
|
||||
envSettings,
|
||||
"worker"
|
||||
);
|
||||
|
||||
expect(instance).toBeInstanceOf(LessSimpleQueue);
|
||||
expect(monitorsRepository.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── worker heartbeat / deregistration ────────────────────────────────────────
|
||||
|
||||
describe("worker heartbeat", () => {
|
||||
it("writes a heartbeat on init with the workerId and mode (primary)", async () => {
|
||||
const { queue, workersRepository } = createQueue();
|
||||
await queue.init("primary");
|
||||
expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "primary");
|
||||
});
|
||||
|
||||
it("writes a heartbeat on init with the workerId and mode (worker)", async () => {
|
||||
const { queue, workersRepository } = createQueue();
|
||||
await queue.init("worker");
|
||||
expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker");
|
||||
});
|
||||
|
||||
it("beats again on each scheduler:heartbeat event", async () => {
|
||||
const { queue, scheduler, workersRepository } = createQueue();
|
||||
await queue.init("worker");
|
||||
(workersRepository.upsert as jest.Mock).mockClear();
|
||||
|
||||
scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd");
|
||||
scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd");
|
||||
|
||||
expect(workersRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker");
|
||||
});
|
||||
|
||||
it("stops beating after shutdown", async () => {
|
||||
const { queue, scheduler, workersRepository } = createQueue();
|
||||
await queue.init("worker");
|
||||
await queue.shutdown();
|
||||
(workersRepository.upsert as jest.Mock).mockClear();
|
||||
|
||||
scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd");
|
||||
|
||||
expect(workersRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deregisters the worker and stops the scheduler on shutdown", async () => {
|
||||
const { queue, scheduler, workersRepository } = createQueue();
|
||||
await queue.init("worker");
|
||||
await queue.shutdown();
|
||||
expect(workersRepository.deleteById).toHaveBeenCalledWith("host-1:1234:abcd");
|
||||
expect(scheduler.stop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
|
||||
import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +47,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
const telegramProvider = createProvider();
|
||||
const pushoverProvider = createProvider();
|
||||
const twilioProvider = createProvider();
|
||||
const ntfyProvider = createProvider();
|
||||
const settingsService = createSettingsService();
|
||||
const notificationMessageBuilder = createMessageBuilder();
|
||||
|
||||
@@ -64,6 +65,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
telegramProvider,
|
||||
pushoverProvider,
|
||||
twilioProvider,
|
||||
ntfyProvider,
|
||||
settingsService,
|
||||
notificationMessageBuilder,
|
||||
...overrides,
|
||||
@@ -82,6 +84,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
defaults.telegramProvider as any,
|
||||
defaults.pushoverProvider as any,
|
||||
defaults.twilioProvider as any,
|
||||
defaults.ntfyProvider as any,
|
||||
defaults.settingsService as any,
|
||||
defaults.logger as any,
|
||||
defaults.notificationMessageBuilder as any
|
||||
@@ -147,7 +150,7 @@ describe("NotificationsService", () => {
|
||||
});
|
||||
|
||||
it("routes to correct provider for each notification type", async () => {
|
||||
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const;
|
||||
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio", "ntfy"] as const;
|
||||
for (const type of types) {
|
||||
const deps = createService();
|
||||
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
|
||||
@@ -165,6 +168,7 @@ describe("NotificationsService", () => {
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
ntfy: deps.ntfyProvider,
|
||||
};
|
||||
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
@@ -238,30 +242,40 @@ describe("NotificationsService", () => {
|
||||
// ── sendTestNotification ─────────────────────────────────────────────────
|
||||
|
||||
describe("sendTestNotification", () => {
|
||||
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)(
|
||||
"routes %s to the correct provider",
|
||||
async (type) => {
|
||||
const deps = createService();
|
||||
const notification = makeNotification({ type: type as any });
|
||||
it.each([
|
||||
["email"],
|
||||
["slack"],
|
||||
["discord"],
|
||||
["pager_duty"],
|
||||
["matrix"],
|
||||
["webhook"],
|
||||
["teams"],
|
||||
["telegram"],
|
||||
["pushover"],
|
||||
["twilio"],
|
||||
["ntfy"],
|
||||
] as const)("routes %s to the correct provider", async (type) => {
|
||||
const deps = createService();
|
||||
const notification = makeNotification({ type: type as any });
|
||||
|
||||
const result = await deps.service.sendTestNotification(notification);
|
||||
const result = await deps.service.sendTestNotification(notification);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
|
||||
webhook: deps.webhookProvider,
|
||||
slack: deps.slackProvider,
|
||||
matrix: deps.matrixProvider,
|
||||
pager_duty: deps.pagerDutyProvider,
|
||||
discord: deps.discordProvider,
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
};
|
||||
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
|
||||
}
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
|
||||
webhook: deps.webhookProvider,
|
||||
slack: deps.slackProvider,
|
||||
matrix: deps.matrixProvider,
|
||||
pager_duty: deps.pagerDutyProvider,
|
||||
discord: deps.discordProvider,
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
ntfy: deps.ntfyProvider,
|
||||
};
|
||||
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("returns false for unknown notification type", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
@@ -35,7 +35,7 @@ jest.unstable_mockModule("super-simple-scheduler", () => ({
|
||||
default: MockScheduler,
|
||||
}));
|
||||
|
||||
const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts");
|
||||
const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/JobQueues/SuperSimpleQueue.ts");
|
||||
|
||||
const createQueueHelper = () => ({
|
||||
getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()),
|
||||
@@ -486,10 +486,25 @@ describe("SuperSimpleQueue", () => {
|
||||
// ── getMetrics ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("getMetrics", () => {
|
||||
it("returns empty metrics when no jobs", async () => {
|
||||
it("returns empty job metrics plus this process as the sole worker when no jobs", async () => {
|
||||
const { queue } = createQueue();
|
||||
const metrics = await queue.getMetrics();
|
||||
expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 });
|
||||
expect(metrics).toEqual({
|
||||
jobs: 0,
|
||||
activeJobs: 0,
|
||||
failingJobs: 0,
|
||||
jobsWithFailures: [],
|
||||
totalRuns: 0,
|
||||
totalFailures: 0,
|
||||
workers: [
|
||||
{
|
||||
// hostname:pid:uuid, matching less-simple-scheduler's workerId format
|
||||
workerId: expect.stringMatching(/^.+:\d+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/),
|
||||
mode: "primary",
|
||||
lastSeenAt: expect.any(Number),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates metrics from jobs", async () => {
|
||||
@@ -568,7 +583,7 @@ describe("SuperSimpleQueue", () => {
|
||||
data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true },
|
||||
},
|
||||
]);
|
||||
const jobs = await queue.getJobs();
|
||||
const { jobs } = await queue.getJobs({});
|
||||
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]).toEqual({
|
||||
@@ -580,6 +595,8 @@ describe("SuperSimpleQueue", () => {
|
||||
monitorActive: true,
|
||||
active: true,
|
||||
repeat: 60000,
|
||||
lockedBy: null,
|
||||
lockedUntil: null,
|
||||
lockedAt: null,
|
||||
runCount: 5,
|
||||
failCount: 1,
|
||||
@@ -596,14 +613,14 @@ describe("SuperSimpleQueue", () => {
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } },
|
||||
]);
|
||||
const jobs = await queue.getJobs();
|
||||
const { jobs } = await queue.getJobs({});
|
||||
expect(jobs[0].lastRunTook).toBeNull();
|
||||
});
|
||||
|
||||
it("handles jobs with missing data", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", active: true, data: null }]);
|
||||
const jobs = await queue.getJobs();
|
||||
const { jobs } = await queue.getJobs({});
|
||||
expect(jobs[0].monitorUrl).toBeNull();
|
||||
expect(jobs[0].monitorType).toBeNull();
|
||||
expect(jobs[0].monitorInterval).toBeNull();
|
||||
@@ -618,7 +635,7 @@ describe("SuperSimpleQueue", () => {
|
||||
{ id: "m1", active: true, data: { url: "a", type: "http", interval: 60000, isActive: true } },
|
||||
{ id: "m2", active: false, data: { url: "b", type: "http", interval: 60000, isActive: false } },
|
||||
]);
|
||||
const jobs = await queue.getJobs();
|
||||
const { jobs } = await queue.getJobs({});
|
||||
expect(jobs[0].monitorActive).toBe(true);
|
||||
expect(jobs[1].monitorActive).toBe(false);
|
||||
});
|
||||
@@ -628,10 +645,33 @@ describe("SuperSimpleQueue", () => {
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", active: true, repeat: 120000, data: { url: "a", type: "http", interval: 60000, geoCheckInterval: 600000 } },
|
||||
]);
|
||||
const jobs = await queue.getJobs();
|
||||
const { jobs } = await queue.getJobs({});
|
||||
expect(jobs[0].repeat).toBe(120000);
|
||||
expect(jobs[0].monitorGeoInterval).toBe(600000);
|
||||
});
|
||||
|
||||
it("paginates jobs and returns the total count", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue(
|
||||
Array.from({ length: 25 }, (_, i) => ({ id: `m${i}`, active: true, data: { url: `a${i}`, type: "http", interval: 60000 } }))
|
||||
);
|
||||
|
||||
const { jobs, count } = await queue.getJobs({ page: 1, rowsPerPage: 10 });
|
||||
expect(count).toBe(25);
|
||||
expect(jobs).toHaveLength(10);
|
||||
expect(jobs[0].monitorId).toBe("m10");
|
||||
});
|
||||
|
||||
it("returns all jobs when rowsPerPage is omitted", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue(
|
||||
Array.from({ length: 25 }, (_, i) => ({ id: `m${i}`, active: true, data: { url: `a${i}`, type: "http", interval: 60000 } }))
|
||||
);
|
||||
|
||||
const { jobs, count } = await queue.getJobs({});
|
||||
expect(count).toBe(25);
|
||||
expect(jobs).toHaveLength(25);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flushQueues ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import { QueueHelper } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts";
|
||||
import type { Monitor } from "../../../src/types/monitor.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
@@ -59,7 +59,7 @@ const createHelper = (overrides?: Record<string, unknown>) => {
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const helper = new SuperSimpleQueueHelper(
|
||||
const helper = new QueueHelper(
|
||||
defaults.logger as any,
|
||||
defaults.networkService as any,
|
||||
defaults.statusService as any,
|
||||
@@ -94,7 +94,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SuperSimpleQueueHelper", () => {
|
||||
describe("QueueHelper", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns JobQueueHelper", () => {
|
||||
const { helper } = createHelper();
|
||||
|
||||
Reference in new issue
Block a user