Compare commits

...
Author SHA1 Message Date
Alexander Holliday 73b74ffd80 Merge pull request #3705 from bluewave-labs/fix/migrations
bump version
2026-06-05 12:02:09 -07:00
Alex Holliday 2b393f9ebe bump version 2026-06-05 12:01:35 -07:00
Alexander Holliday 562a17707d Merge pull request #3704 from bluewave-labs/fix/migrations
fix/migrations
2026-06-05 12:00:51 -07:00
Alex Holliday 7d800b0f38 Merge branch 'develop' into fix/migrations 2026-06-05 11:58:11 -07:00
Alex Holliday a19479b578 fix migration race condition 2026-06-05 11:58:00 -07:00
Alexander Holliday 9b4a810e21 Merge pull request #3700 from bluewave-labs/chore/version-bump
bump version
2026-06-05 10:38:05 -07:00
Alex Holliday 1388d61b07 bump version 2026-06-05 10:37:43 -07:00
Alexander Holliday 8dbe42288b Merge pull request #3684 from jhcpeixoto/feat/ntfy-notifications
feat: add ntfy notification support
2026-06-05 09:35:04 -07:00
Alexander Holliday 45e31e7212 Merge pull request #3694 from bluewave-labs/feat/fe/progress-indicator-infra
Feat: Progress indicator infra & pagespeed
2026-06-04 00:25:19 -07:00
Br0wnHammer 7872b18108 refactor(client): inline isEditMode instead of shadowing it with isWizard 2026-06-04 11:09:09 +05:30
Alexander Holliday 15ac5fa84d Merge pull request #3697 from bluewave-labs/fix/jobs-pagination
fix: jobs pagination
2026-06-03 13:32:47 -07:00
Alex Holliday 3c85b36b1b add pagnation validation to queue query 2026-06-03 13:30:03 -07:00
Alex Holliday ee0bc5e500 add pagination components 2026-06-03 13:29:17 -07:00
Alex Holliday 4729863826 add tests 2026-06-03 13:29:03 -07:00
Alex Holliday 174fb048f7 implement pagination, update metrics to use scheduler's aggregation 2026-06-03 13:28:53 -07:00
Alex Holliday 63fd3f6afd bump scheduler 2026-06-03 13:28:25 -07:00
Alex Holliday c3944f2fe7 Add pagination to controller 2026-06-03 13:28:15 -07:00
Alexander Holliday ddd937d42f Merge pull request #3696 from bluewave-labs/fix/connection-reuse
fix: connection reuse
2026-06-03 11:54:33 -07:00
Alex Holliday 5e225f9173 update tests 2026-06-03 11:51:42 -07:00
Alex Holliday d2d110fcd5 reuse http agent, use firstByte instead of total 2026-06-03 11:48:59 -07:00
Alexander Holliday 724f30070d Merge pull request #3695 from ajhollid/fix/less-simple-queue-tweaks
fix: less simple queue tweaks
2026-06-03 11:07:24 -07:00
Alex Holliday b8a44f09fc update tests 2026-06-03 11:04:13 -07:00
Alex Holliday f85f1aa1d1 add processEVery 2026-06-03 11:02:56 -07:00
Alex Holliday 707b8f903b add bulk ops for inserting monitors, set primary concurrency to 0, handles scheduling only 2026-06-03 11:00:40 -07:00
Alex Holliday 9e4fd6d708 bump scheduler 2026-06-03 10:59:28 -07:00
Br0wnHammer 8572065ef7 fix(client): validate hardware alert thresholds on the wizard's second step 2026-06-03 20:16:10 +05:30
Br0wnHammer 01c3870b22 feat(client): enable step wizard on infrastructure and pagespeed create pages 2026-06-03 20:15:01 +05:30
Alexander Holliday 420c103f41 Merge pull request #3693 from bluewave-labs/feat/worker-stats
feat: worker stats
2026-06-02 11:33:44 -07:00
Alex Holliday b4970d321d update tests 2026-06-02 11:31:22 -07:00
Alex Holliday f5a4a03898 reorder stop 2026-06-02 11:31:11 -07:00
Alex Holliday dfae6c8abd type metrics for super simple queue 2026-06-02 11:26:10 -07:00
Alex Holliday 2b6734bb78 update tests 2026-06-02 11:25:21 -07:00
Alex Holliday b9fd29b906 add compliant worker ID to super simple queue 2026-06-02 11:11:24 -07:00
Alex Holliday caf8f22fec add worker table 2026-06-02 10:59:14 -07:00
Alex Holliday 599c599588 add tests 2026-06-02 09:47:13 -07:00
Alex Holliday cfed235d1f add worker stats 2026-06-02 09:47:00 -07:00
Alex Holliday c9eacff71f add queue worker type 2026-06-02 09:46:23 -07:00
Alex Holliday b2526a5be9 init queue worker repo 2026-06-02 09:45:56 -07:00
Alex Holliday cffe3f57fa add models 2026-06-02 09:45:37 -07:00
Alex Holliday 8b2506cc3d add queue worker repository 2026-06-02 09:44:49 -07:00
Alex Holliday 60356a95fe bump less-simple-scheduler 2026-06-02 09:44:23 -07:00
Alexander Holliday 1195567da8 Merge pull request #3692 from bluewave-labs/feat/fe/progress-indicator-status-page
Feat: Progress indicator status page
2026-06-02 09:19:32 -07:00
Br0wnHammer 7617929700 feat(status-page): add step-wizard progress indicator to create flow 2026-06-02 20:31:01 +05:30
Br0wnHammer 7df3ee6ecd feat(status-page): add wizard step metadata and helpers to validation schema 2026-06-02 20:27:12 +05:30
Alexander Holliday dc788dbf85 Merge pull request #3691 from bluewave-labs/feat/workers
feat: workers
2026-06-01 11:24:11 -07:00
Alex Holliday 89c4f4005e add lessSimpleQueue tests 2026-06-01 11:21:49 -07:00
Alex Holliday 53ed3b3465 remove unused imports 2026-06-01 11:17:50 -07:00
Alex Holliday e37fbf2a5c replace empty monitor check 2026-06-01 11:11:36 -07:00
Alex Holliday 4df08bfa79 add worker scripts 2026-06-01 10:45:20 -07:00
Alex Holliday f74231cf33 early return if worker mode 2026-06-01 10:45:09 -07:00
Alex Holliday e1558d35b0 use queue type on config 2026-06-01 10:44:53 -07:00
Alex Holliday 51672890df add queue type to less simple queue 2026-06-01 10:44:39 -07:00
Alex Holliday fc357fe3ae add queue mode to env validation 2026-06-01 10:44:18 -07:00
Alex Holliday 1a0e6d9404 add queue mode type 2026-06-01 10:44:05 -07:00
Alexander Holliday b9e8a6dbc8 Merge pull request #3689 from bluewave-labs/fix/listener-leak
fix: move listener registration to constructor
2026-05-29 11:51:27 -07:00
Alex Holliday c704f67e57 move listener registration to constructor 2026-05-29 11:45:17 -07:00
Alexander Holliday 4629a947a4 Merge pull request #3688 from bluewave-labs/feat/less-simple-scheduler
feat: Configurable Schedulers
2026-05-29 11:41:32 -07:00
Alex Holliday da92b9d067 update tests 2026-05-29 11:33:31 -07:00
Alex Holliday 458442f364 fix env sample, fix log queue, use dbType for enum 2026-05-29 11:31:11 -07:00
Alex Holliday 400b295dae Merge branch 'develop' into feat/less-simple-scheduler 2026-05-29 11:24:51 -07:00
Alex Holliday 6ddee6e85b fix imports 2026-05-29 11:23:31 -07:00
Alex Holliday 2db88ac192 add lastRunAt back 2026-05-29 11:20:19 -07:00
Alex Holliday 0342870bbd lastRunAt -> lastFinishedAT 2026-05-29 11:08:32 -07:00
Alex Holliday 4280a6c2c3 update env vars 2026-05-29 10:34:42 -07:00
Alex Holliday 0a3232419a update prettierrc 2026-05-29 10:33:15 -07:00
Alex Holliday 4dc3a756a8 add less-simple-scheduler, bump dependencies 2026-05-29 10:32:58 -07:00
Alex Holliday cd91e83817 Remove old queues 2026-05-29 10:28:59 -07:00
Alex Holliday f476710c29 add queue type to settings service 2026-05-29 10:28:49 -07:00
Alex Holliday 0d255c8962 update imports 2026-05-29 10:28:18 -07:00
Alex Holliday bf25a65d8e add configurable queue selection to service config, default to in memory 2026-05-29 10:28:01 -07:00
Alex Holliday f1e16b65ee Extract job queue interfaces, add less simple queue 2026-05-29 10:27:38 -07:00
Alex Holliday 201ad987ce Add validation for Queue type 2026-05-29 10:27:03 -07:00
Alex Holliday c12ce2fbcf add queue types 2026-05-29 10:26:46 -07:00
Alexander Holliday 6e77738ecf Merge pull request #3687 from bluewave-labs/fix/readme-translations
docs: add README translations for all supported locales
2026-05-29 09:17:42 -07:00
joaquimpeixoto 8a83bc99e9 feat: add ntfy notification support 2026-05-27 16:57:11 +01:00
65 changed files with 2930 additions and 532 deletions

No files matched your search

+1
View File
@@ -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:
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.7.1",
"version": "3.8.1",
"type": "module",
"scripts": {
"dev": "vite",
+8
View File
@@ -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",
+7 -7
View File
@@ -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"
+53 -3
View File
@@ -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)}>
+59 -14
View File
@@ -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}
/>
);
};
+1 -1
View File
@@ -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")}
+361 -302
View File
@@ -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}
+2
View File
@@ -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;
}
+18 -5
View File
@@ -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;
}
+8 -4
View File
@@ -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()),
});
+7
View File
@@ -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>;
+45 -9
View File
@@ -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;
+16
View File
@@ -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": {
+1
View File
@@ -11,6 +11,7 @@ LOG_LEVEL=debug
# Database
# --------
DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
QUEUE_TYPE=superSimpleQueue
# JWT Authentication
# ------------------
+10 -1
View File
@@ -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
View File
@@ -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"
},
+4
View File
@@ -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) => {
+131 -4
View File
@@ -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
View File
@@ -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",
+34 -9
View File
@@ -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,
+10 -7
View File
@@ -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;
}
+14 -1
View File
@@ -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,
+28
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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;
+3 -3
View File
@@ -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;
+5 -2
View File
@@ -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),
};
};
}
@@ -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 = () => {
@@ -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;
}
+5 -1
View File
@@ -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 -2
View File
@@ -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" });
+2
View File
@@ -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;
}
+12
View File
@@ -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;
+4 -1
View File
@@ -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;
+6
View File
@@ -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(),
});
+2 -2
View File
@@ -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();