Compare commits

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

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

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ TOKEN_TTL="99d"
ORIGIN="localhost"
LOG_LEVEL="debug"
```
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
### Client `.env`
```env
+7 -5
View File
@@ -124,10 +124,12 @@ LOG_LEVEL="debug"
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
- `DB_CONNECTION_STRING`: MongoDB connection URL
- `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`)
- `ORIGIN`: Origin for CORS purposes (optional)
- `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
- `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
- `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
Start the backend server:
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
**Port already in use:**
- Check if another service is using ports 5173, 52345, 27017, or 6379
- Check if another service is using ports 5173, 52345, or 27017
- Stop the conflicting service or change the port in `.env` files
**MongoDB connection issues:**
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.10.0",
"version": "3.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.10.0",
"version": "3.9.2",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.10.0",
"version": "3.9.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -40,7 +40,6 @@ export const BaseChart = ({
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
@@ -25,11 +25,10 @@ export const InfraDetailsGauges = ({
<Stack
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"stretch"}
alignItems={"center"}
>
<DetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
@@ -38,7 +37,6 @@ export const InfraDetailsGauges = ({
/>
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
@@ -51,7 +49,6 @@ export const InfraDetailsGauges = ({
key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)}
+4 -4
View File
@@ -825,7 +825,7 @@
"netBytesRecv": "{{name}} - Empfangene Bytes",
"netBytesSent": "{{name}} - Gesendete Bytes",
"netNoTraffic": "Capture meldet null Datenverkehr auf allen Schnittstellen. Das ist normal, wenn Capture in einem Container läuft, der nur Loopback-Schnittstellen sieht — führe Capture direkt auf dem Host aus, um echten Netzwerkverkehr zu sehen.",
"temp": "CPU-Temperatur"
"temp": "Temp."
}
},
"gauges": {
@@ -841,13 +841,13 @@
},
"memory": {
"lowerLabel": "Frei",
"title": "Speicherauslastung",
"title": "Arbeitsspeicherauslastung",
"upperLabel": "Belegt"
}
},
"statBoxes": {
"avgCpuTemperature": "Durchschn. CPU-Temperatur",
"cpuFrequency": "CPU-Frequenz",
"avgCpuTemperature": "Durchschn. CPU Termperatur",
"cpuFrequency": "CPU Frequenz",
"cpuLogical": "CPU (logisch)",
"cpuPhysical": "CPU (physisch)",
"disk": "Festplatte",
+1 -1
View File
@@ -845,7 +845,7 @@
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.",
"temp": "CPU temperature"
"temp": "Temp"
}
},
"gauges": {
-1
View File
@@ -15,7 +15,6 @@
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
"clean": "rm -rf dist",
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates",
"typeCheck": "tsc --noEmit",
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
@@ -22,6 +22,8 @@ import {
} from "@/api/validation/userValidation.js";
import { IUserService } from "@/domain/users/user.service.js";
const SERVICE_NAME = "authController";
export interface IAuthController {
registerUser: RequestHandler;
createUser: RequestHandler;
@@ -40,12 +42,18 @@ export interface IAuthController {
}
class AuthController implements IAuthController {
static SERVICE_NAME = SERVICE_NAME;
private userService: IUserService;
constructor(userService: IUserService) {
this.userService = userService;
}
get serviceName() {
return AuthController.SERVICE_NAME;
}
registerUser = catchAsync(async (req: Request, res: Response) => {
const newUser = req.body.user;
const newUserToken = req.body.token;
@@ -11,7 +11,10 @@ import {
import { ICheckService } from "@/domain/checks/check.service.js";
import { requireTeamId } from "@/api/controllers/controllerUtils.js";
const SERVICE_NAME = "checkController";
export interface ICheckController {
serviceName: string;
getChecksByMonitor: RequestHandler;
getChecksByTeam: RequestHandler;
getChecksSummaryByTeamId: RequestHandler;
@@ -20,11 +23,17 @@ export interface ICheckController {
}
class CheckController implements ICheckController {
static SERVICE_NAME = SERVICE_NAME;
private checkService: ICheckService;
constructor(checkService: ICheckService) {
this.checkService = checkService;
}
get serviceName() {
return CheckController.SERVICE_NAME;
}
getChecksByMonitor = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
+19 -1
View File
@@ -1,5 +1,5 @@
import { AppError } from "@/utils/AppError.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker";
type SSLCheckerType = typeof sslChecker;
@@ -54,6 +54,24 @@ export const optionalNumber = (value: unknown, fieldName: string): number | unde
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
export const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
};
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") {
@@ -2,17 +2,25 @@ import { IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js";
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "diagnosticController";
export interface IDiagnosticController {
getSystemStats: RequestHandler;
}
class DiagnosticController implements IDiagnosticController {
static SERVICE_NAME = SERVICE_NAME;
private diagnosticService: IDiagnosticService;
constructor(diagnosticService: IDiagnosticService) {
this.diagnosticService = diagnosticService;
}
get serviceName() {
return DiagnosticController.SERVICE_NAME;
}
getSystemStats = catchAsync(async (req: Request, res: Response) => {
const diagnostics = await this.diagnosticService.getSystemStats();
return res.status(200).json({
@@ -4,15 +4,23 @@ import { getChecksParamValidation, getChecksQueryValidation } from "@/api/valida
import type { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { requireTeamId } from "./controllerUtils.js";
const SERVICE_NAME = "geoCheckController";
export interface IGeoCheckController {
getGeoChecksByMonitor: RequestHandler;
}
class GeoCheckController implements IGeoCheckController {
static SERVICE_NAME = SERVICE_NAME;
private geoChecksService: IGeoChecksService;
constructor(geoChecksService: IGeoChecksService) {
this.geoChecksService = geoChecksService;
}
get serviceName() {
return GeoCheckController.SERVICE_NAME;
}
getGeoChecksByMonitor = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
@@ -3,6 +3,7 @@ import { catchAsync } from "@/utils/catchAsync.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/api/controllers/controllerUtils.js";
import { IInviteService } from "@/domain/invites/invite.service.js";
const SERVICE_NAME = "inviteController";
export interface IInviteController {
getInviteToken: RequestHandler;
@@ -11,11 +12,16 @@ export interface IInviteController {
}
class InviteController implements IInviteController {
static SERVICE_NAME = SERVICE_NAME;
private inviteService: IInviteService;
constructor(inviteService: IInviteService) {
this.inviteService = inviteService;
}
get serviceName() {
return InviteController.SERVICE_NAME;
}
getInviteToken = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const userRoles = requireUserRoles(req.user?.role);
@@ -2,15 +2,21 @@ import { ILogger } from "@/utils/logger.js";
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "LogController";
export interface ILogController {
getLogs: RequestHandler;
}
class LogController {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
get serviceName() {
return LogController.SERVICE_NAME;
}
getLogs = catchAsync(async (req: Request, res: Response) => {
const logs = this.logger.getLogs();
@@ -23,6 +23,8 @@ import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/domain/monitors/monitor.service.js";
import { INotificationsService } from "@/domain/notifications/notification.service.js";
const SERVICE_NAME = "monitorController";
export interface IMonitorController {
getMonitorCertificate: RequestHandler;
getUptimeDetailsById: RequestHandler;
@@ -45,6 +47,8 @@ export interface IMonitorController {
updateNotifications: RequestHandler;
}
class MonitorController implements IMonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: IMonitorService;
private notificationsService: INotificationsService;
@@ -53,6 +57,10 @@ class MonitorController implements IMonitorController {
this.notificationsService = notificationsService;
}
get serviceName() {
return MonitorController.SERVICE_NAME;
}
getMonitorCertificate = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
@@ -3,6 +3,8 @@ import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
const SERVICE_NAME = "JobQueueController";
export interface IJobQueueController {
getMetrics: RequestHandler;
getJobs: RequestHandler;
@@ -11,8 +13,13 @@ export interface IJobQueueController {
}
class JobQueueController implements IJobQueueController {
static SERVICE_NAME = SERVICE_NAME;
constructor(private scheduler: IJobScheduler) {}
get serviceName() {
return JobQueueController.SERVICE_NAME;
}
getMetrics = catchAsync(async (req: Request, res: Response) => {
const metrics = await this.scheduler.getMetrics();
res.status(200).json({
@@ -7,13 +7,17 @@ import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"
import { IEmailService } from "@/service/emailService.js";
import { Settings } from "@/domain/app-settings/app-settings.type.js";
const SERVICE_NAME = "SettingsController";
export interface ISettingsController {
serviceName: string;
getAppSettings: RequestHandler;
updateAppSettings: RequestHandler;
sendTestEmail: RequestHandler;
}
class SettingsController implements ISettingsController {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: ISettingsService;
private emailService: IEmailService;
constructor(settingsService: ISettingsService, emailService: IEmailService) {
@@ -21,6 +25,10 @@ class SettingsController implements ISettingsController {
this.emailService = emailService;
}
get serviceName() {
return SettingsController.SERVICE_NAME;
}
buildAppSettings = (dbSettings: Settings) => {
const sanitizedSettings: Record<string, unknown> = { ...dbSettings };
delete sanitizedSettings.version;
@@ -13,7 +13,10 @@ import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.
import { IStatusPageService } from "@/domain/status-pages/status-page.service.js";
import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js";
const SERVICE_NAME = "statusPageController";
export interface IStatusPageController {
readonly serviceName: string;
createStatusPage: RequestHandler;
updateStatusPage: RequestHandler;
getStatusPageByUrl: RequestHandler;
@@ -23,11 +26,16 @@ export interface IStatusPageController {
}
class StatusPageController implements IStatusPageController {
static SERVICE_NAME = SERVICE_NAME;
private statusPageService: IStatusPageService;
constructor(statusPageService: IStatusPageService) {
this.statusPageService = statusPageService;
}
get serviceName() {
return StatusPageController.SERVICE_NAME;
}
createStatusPage = catchAsync(async (req: Request, res: Response) => {
createStatusPageBodyValidation.parse(req.body);
if (req.file) {
+8 -9
View File
@@ -1,8 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js";
import { MonitorTypes } from "@/domain/monitors/monitor.type.js";
import { CheckFilters, DateRanges, SortOrders } from "@/types/query.js";
import { MonitorTypes } from "@/domain/monitors/monitor.types.js";
//****************************************
// Check Validations
@@ -14,10 +13,10 @@ export const getChecksParamValidation = z.object({
export const getChecksQueryValidation = z.object({
type: z.enum(MonitorTypes).optional(),
sortOrder: z.enum(SortOrders),
sortOrder: z.enum(["asc", "desc"]),
limit: z.coerce.number().optional(),
dateRange: z.enum(DateRanges),
filter: z.enum(CheckFilters).optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
filter: z.enum(["all", "up", "down", "resolve"]).optional(),
ack: booleanCoercion.optional(),
page: z.coerce.number(),
rowsPerPage: z.coerce.number(),
@@ -26,10 +25,10 @@ export const getChecksQueryValidation = z.object({
});
export const getTeamChecksQueryValidation = z.object({
sortOrder: z.enum(SortOrders),
sortOrder: z.enum(["asc", "desc"]),
limit: z.coerce.number().optional(),
dateRange: z.enum(DateRanges),
filter: z.enum(CheckFilters).optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
filter: z.enum(["all", "up", "down", "resolve"]).optional(),
ack: booleanCoercion.optional(),
page: z.coerce.number(),
rowsPerPage: z.coerce.number(),
@@ -40,5 +39,5 @@ export const deleteChecksParamValidation = z.object({
});
export const getChecksSummaryByTeamIdQueryValidation = z.object({
dateRange: z.enum(DateRanges).optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
});
@@ -1,20 +1,18 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { DateRanges, SortOrders } from "@/types/query.js";
import { IncidentResolutionTypes } from "@/domain/incidents/incident.type.js";
//****************************************
// Incident Validations
//****************************************
export const getIncidentsByTeamQueryValidation = z.object({
sortOrder: z.enum(SortOrders),
dateRange: z.enum(DateRanges).default("all"),
sortOrder: z.enum(["asc", "desc"]),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).default("all"),
page: z.coerce.number().int().min(0),
rowsPerPage: z.coerce.number().int().min(1),
status: booleanCoercion.optional(),
monitorId: z.string().optional(),
resolutionType: z.enum(IncidentResolutionTypes).optional(),
resolutionType: z.enum(["manual", "automatic"]).optional(),
});
export const getIncidentSummaryQueryValidation = z.object({
@@ -1,7 +1,6 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { DurationUnits } from "@/domain/maintenance-windows/maintenance-window.type.js";
import { SortOrders } from "@/types/query.js";
const dateToString = z.coerce.date().transform((d) => d.toISOString());
@@ -50,7 +49,7 @@ export const getMaintenanceWindowsByTeamIdQueryValidation = z.object({
page: z.coerce.number().optional(),
rowsPerPage: z.coerce.number().optional(),
field: z.string().optional(),
order: z.enum(SortOrders).optional(),
order: z.enum(["asc", "desc"]).optional(),
});
export const getMaintenanceWindowsByMonitorIdParamValidation = z.object({
@@ -10,8 +10,7 @@ import {
MonitorStatuses,
MonitorTypes,
PageSpeedStrategies,
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
} from "@/domain/monitors/monitor.types.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -21,9 +20,9 @@ export const getMonitorByIdParamValidation = z.object({
export const getMonitorByIdQueryValidation = z.object({
status: booleanCoercion.optional(),
sortOrder: z.enum(SortOrders).optional(),
sortOrder: z.enum(["asc", "desc"]).optional(),
limit: z.coerce.number().optional(),
dateRange: z.enum(DateRanges).optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
numToDisplay: z.coerce.number().optional(),
normalize: booleanCoercion.optional(),
continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(),
@@ -43,7 +42,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({
rowsPerPage: z.coerce.number().int().min(1).max(100).optional(),
filter: z.union([z.string(), z.literal("")]).optional(),
field: z.string().optional(),
order: z.enum(SortOrders).optional(),
order: z.enum(["asc", "desc"]).optional(),
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
tags: z.union([z.string(), z.array(z.string())]).optional(),
explain: booleanCoercion.optional(),
@@ -196,7 +195,7 @@ export const getUptimeDetailsByIdParamValidation = z.object({
});
export const getUptimeDetailsByIdQueryValidation = z.object({
dateRange: z.enum(DateRanges),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
normalize: booleanCoercion.optional(),
});
@@ -207,7 +206,7 @@ const importedMonitorSchema = z
teamId: z.string().optional(),
name: z.string().min(1, "Name is required"),
description: z.union([z.string(), z.literal("")]).optional(),
status: z.enum(MonitorStatuses).default("initializing"),
status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
statusWindow: z.array(z.boolean()).default([]),
statusWindowSize: z.number().min(1).max(20).default(5),
statusWindowThreshold: z.number().min(1).max(100).default(60),
@@ -264,7 +263,7 @@ export const getHardwareDetailsByIdParamValidation = z.object({
});
export const getHardwareDetailsByIdQueryValidation = z.object({
dateRange: z.enum(DateRanges).optional(),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
});
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
@@ -3,7 +3,6 @@ import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { cssReferencesExternalResource } from "@/utils/customCss.js";
import { ImageMimeTypes } from "@/types/upload.js";
//****************************************
// Status Page Validations
@@ -68,7 +67,7 @@ export const imageValidation = z
fieldname: z.string().min(1, "Field name is required"),
originalname: z.string().min(1, "Original name is required"),
encoding: z.string().min(1, "Encoding is required"),
mimetype: z.enum(ImageMimeTypes, {
mimetype: z.enum(["image/jpeg", "image/png", "image/jpg"], {
message: "File must be a valid image (jpeg, jpg, or png)",
}),
size: z.number().max(3145728, "File size must be less than 3MB"),
+1 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { UserRoles } from "@/domain/users/user.type.js";
import { nameValidation, lowercaseEmailValidation, passwordValidation, booleanCoercion } from "@/api/validation/shared.js";
import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js";
//****************************************
// User Validations
@@ -46,7 +46,6 @@ export const editUserBodyValidation = z
profilePicture: z.string().optional(),
password: passwordValidation.optional(),
newPassword: passwordValidation.optional(),
deleteProfileImage: booleanCoercion.optional(),
})
// Changing a password requires both the current password and the new one.
// The service only runs the change-password flow when both are present, so
+15 -20
View File
@@ -21,7 +21,6 @@ import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js";
// Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
import { WebhookProvider } from "@/domain/notifications/providers/webhook.js";
import { SlackProvider } from "@/domain/notifications/providers/slack.js";
import { EmailProvider } from "@/domain/notifications/providers/email.js";
@@ -126,7 +125,7 @@ export const buildShared = async ({
const checksRepository = new MongoChecksRepository(logger);
const incidentsRepository = new MongoIncidentsRepository();
const usersRepository = new MongoUsersRepository();
const geoChecksRepository = new MongoGeoChecksRepository();
const geoChecksRepository = new MongoGeoChecksRepository(logger);
const notificationsRepository = new MongoNotificationsRepository();
const jobsRepository = new MongoJobsRepository(workerId);
const queueWorkersRepository = new MongoQueueWorkersRepository();
@@ -168,28 +167,24 @@ export const buildShared = async ({
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
const notificationProviders: NotificationProviderRegistry = {
webhook: webhookProvider,
email: emailProvider,
slack: slackProvider,
discord: discordProvider,
pager_duty: pagerDutyProvider,
matrix: matrixProvider,
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
const notificationsService = new NotificationsService({
const notificationsService = new NotificationsService(
notificationsRepository,
monitorsRepository,
providers: notificationProviders,
webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
ntfyProvider,
settingsService,
logger,
notificationMessageBuilder,
});
notificationMessageBuilder
);
const sharedServices: SharedServices = {
logger,
+8 -8
View File
@@ -131,9 +131,9 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
geoChecksRepository
);
const worker = await DBQueueWorker.create({
const worker = await DBQueueWorker.create(
logger,
isDbConnected: () => mongoose.connection.readyState === 1,
() => mongoose.connection.readyState === 1,
jobsRepository,
monitorsRepository,
checksRepository,
@@ -142,13 +142,13 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
checkProducer,
checkEvaluator,
geoCheckPipeline,
dispatcher: reactorDispatcher,
helper: workerHelper,
reactorDispatcher,
workerHelper,
queueWorkersRepository,
queueMode: envSettings.queueMode,
queuePrimaryProcesses: envSettings.queuePrimaryProcesses,
workerId,
});
envSettings.queueMode,
envSettings.queuePrimaryProcesses,
workerId
);
return { worker, networkService, bufferService, statusService };
};
+4
View File
@@ -21,6 +21,10 @@ class MongoDB implements IDb<Mongoose> {
this.db = null;
}
get serviceName() {
return MongoDB.SERVICE_NAME;
}
connect = async () => {
try {
const connectionString = this.envSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db";
@@ -20,6 +20,8 @@ export type EnvConfig = {
};
export interface ISettingsService {
readonly serviceName: string;
loadSettings(): EnvConfig;
getSettings(): EnvConfig;
areStatusPageThemesEnabled(): boolean;
getDBSettings(): Promise<Settings>;
@@ -55,6 +57,14 @@ export class SettingsService implements ISettingsService {
this.settingsRepository = settingsRepository;
}
get serviceName() {
return SettingsService.SERVICE_NAME;
}
loadSettings() {
return this.settings;
}
getSettings() {
return this.settings;
}
@@ -1,22 +1,9 @@
import { CheckModel } from "@/domain/checks/check.model.js";
import mongoose from "mongoose";
import type { HardwareDiskStats, HardwareNetStats } from "@/domain/checks/check.type.js";
type DateRange = { start: Date; end: Date };
type HardwareUpChecks = { totalChecks: number };
// $avg yields null when no values are present, so metrics are nullable until the repository applies fallbacks
type NullableValues<T> = { [K in keyof T]: T[K] | null };
export type HardwareStatsBucket = {
_id: string;
avgCpuUsage: number | null;
avgMemoryUsage: number | null;
avgTemperature: number[];
disks: NullableValues<HardwareDiskStats>[];
net: NullableValues<HardwareNetStats>[];
};
const NET_RATE_FIELDS = [
["bytesSentPerSecond", "bytes_sent"],
["deltaBytesRecv", "bytes_recv"],
@@ -83,13 +70,13 @@ export const getHardwareUpChecks = async (monitorId: string, dates: DateRange):
return { totalChecks: count };
};
export const getHardwareStats = async (monitorId: string, dates: DateRange, dateString: string): Promise<HardwareStatsBucket[]> => {
export const getHardwareStats = async (monitorId: string, dates: DateRange, dateString: string) => {
const netProjection = {
name: { $arrayElemAt: ["$sampleDoc.net.name", "$$nIdx"] },
...Object.fromEntries(NET_RATE_FIELDS.map(([outputKey, sourceField]) => [outputKey, netRatePerSecond(sourceField)])),
};
return await CheckModel.aggregate<HardwareStatsBucket>([
return await CheckModel.aggregate([
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+1 -1
View File
@@ -1,5 +1,5 @@
import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import type {
Check,
CheckAudits,
@@ -6,9 +6,8 @@ import type {
PageSpeedChecksResult,
UptimeChecksResult,
} from "@/domain/checks/check.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js";
import { CheckFilter, DateRange } from "@/types/query.js";
export interface IChecksRepository {
// create
@@ -20,27 +19,29 @@ export interface IChecksRepository {
findByMonitorId(
monitorId: string,
sortOrder: string,
dateRange: DateRange,
dateRange: string,
filter: string | undefined,
page: number,
rowsPerPage: number,
status: boolean | undefined,
filter?: CheckFilter
status: boolean | undefined
): Promise<ChecksQueryResult>;
findByTeamId(
sortOrder: string,
dateRange: DateRange,
dateRange: string,
filter: string | undefined,
page: number,
rowsPerPage: number,
teamId: string,
filter?: CheckFilter
teamId: string
): Promise<ChecksQueryResult>;
findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
findByDateRangeAndMonitorId(
monitorId: string,
dateRange: DateRange,
startDate: Date,
endDate: Date,
dateString: string,
options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
findSummaryByTeamId(teamId: string, dateRange: string): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
// update
//delete
@@ -11,19 +11,15 @@ import type {
CheckMetadata,
CheckNetworkInterfaceInfo,
GotTimings,
HardwareCheckStats,
} from "@/domain/checks/check.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import { CheckModel, type CheckDocument } from "@/domain/checks/check.model.js";
import mongoose from "mongoose";
import { getDateFormat, getDateForRange } from "@/utils/dataUtils.js";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@/domain/checks/check.hardware.aggregations.js";
import { CheckFilter, DateRange } from "@/types/query.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
const SERVICE_NAME = "StatusService";
@@ -184,12 +180,7 @@ class MongoChecksRepository implements IChecksRepository {
// Map id to _id for MongoDB storage
const { id, metadata, ...rest } = check;
if (!metadata || !metadata.monitorId || !metadata.teamId) {
throw new AppError({
message: `Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`,
status: 500,
service: SERVICE_NAME,
method: "toDocument",
});
throw new Error(`Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`);
}
return {
_id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(),
@@ -213,41 +204,50 @@ class MongoChecksRepository implements IChecksRepository {
return this.mapDocuments(inserted as unknown as CheckDocument[]);
};
private filterToMatch = (filter: CheckFilter | undefined): Record<string, unknown> => {
switch (filter) {
case "up":
return { status: true };
case "down":
return { status: false };
case "resolve":
return { status: false, statusCode: NETWORK_ERROR };
default:
this.logger.warn({
message: "invalid filter",
service: SERVICE_NAME,
method: "filterToMatch",
});
return {};
}
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: DateRange,
dateRange: string,
filter: string | undefined,
page: number,
rowsPerPage: number,
status: boolean | undefined,
filter?: CheckFilter
status: boolean | undefined
) => {
// Match
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(typeof status !== "undefined" && { status }),
createdAt: { $gte: getDateForRange(dateRange) },
...this.filterToMatch(filter),
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
};
if (filter !== undefined) {
switch (filter) {
case "all":
break;
case "up":
matchStage.status = true;
break;
case "down":
matchStage.status = false;
break;
case "resolve":
matchStage.status = false;
matchStage.statusCode = 5000;
break;
default:
this.logger.warn({
message: "invalid filter",
service: SERVICE_NAME,
method: "getChecks",
});
break;
}
}
//Sort
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
@@ -265,12 +265,39 @@ class MongoChecksRepository implements IChecksRepository {
return { checksCount, checks: this.mapDocuments(checks) };
};
findByTeamId = async (sortOrder: string, dateRange: DateRange, page: number, rowsPerPage: number, teamId: string, filter?: CheckFilter) => {
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
const matchStage: Record<string, unknown> = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
createdAt: { $gte: getDateForRange(dateRange) },
...this.filterToMatch(filter),
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
};
// Add filter to match stage
if (filter !== undefined) {
switch (filter) {
case "all":
break;
case "up":
matchStage.status = true;
break;
case "down":
matchStage.status = false;
break;
case "resolve":
matchStage.status = false;
matchStage.statusCode = 5000;
break;
default:
this.logger.warn({
message: "invalid filter",
service: SERVICE_NAME,
method: "getChecksByTeam",
});
break;
}
}
const parsedSortOrder = sortOrder === "asc" ? 1 : -1;
@@ -314,25 +341,25 @@ class MongoChecksRepository implements IChecksRepository {
return mapped;
};
findByDateRangeAndMonitorId = async (monitorId: string, dateRange: DateRange, options?: { type?: MonitorType }) => {
findByDateRangeAndMonitorId = async (monitorId: string, startDate: Date, endDate: Date, dateString: string, options?: { type?: MonitorType }) => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
const start = getDateForRange(dateRange);
const dateString = getDateFormat(dateRange);
const end = new Date();
if (options?.type === "hardware") {
return this.findHardwareDateRangeChecks(monitorObjectId, start, end, dateString);
return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
}
if (options?.type === "pagespeed") {
return this.findPageSpeedDateRangeChecks(monitorObjectId, start, end, dateString);
return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
}
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString);
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString);
};
findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => {
findSummaryByTeamId = async (teamId: string, dateRange: string) => {
const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
createdAt: { $gte: getDateForRange(dateRange) },
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
};
const [totalResult, downResult] = await Promise.all([
@@ -513,35 +540,33 @@ class MongoChecksRepository implements IChecksRepository {
totalChecks: upChecksDoc?.totalChecks ?? 0,
};
const checks = (hardwareMetrics ?? []).map(
(metric): HardwareCheckStats => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
})
);
const checks = (hardwareMetrics ?? []).map((metric) => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk: { [key: string]: number | string | undefined }) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface: { [key: string]: number | string | undefined }) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
}));
return {
monitorType: "hardware" as const,
+17 -14
View File
@@ -6,7 +6,6 @@ import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.j
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { CheckFilter, DateRange } from "@/types/query.js";
const SERVICE_NAME = "checkService";
@@ -19,21 +18,21 @@ export interface ICheckService {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: DateRange;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: CheckFilter;
filter?: string;
status?: boolean;
}): Promise<ChecksQueryResult>;
getChecksByTeam(params: {
teamId: string;
sortOrder: string;
dateRange: DateRange;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: CheckFilter;
filter?: string;
}): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: DateRange }): Promise<ChecksSummary>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
@@ -51,6 +50,10 @@ export class CheckService implements ICheckService {
this.checksRepository = checksRepository;
}
get serviceName() {
return CheckService.SERVICE_NAME;
}
createChecks = async (checks: Check[]) => {
return this.checksRepository.createChecks(checks);
};
@@ -165,11 +168,11 @@ export class CheckService implements ICheckService {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: DateRange;
dateRange: string;
page: number;
rowsPerPage: number;
status?: boolean;
filter: CheckFilter;
filter?: string;
}) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
@@ -184,7 +187,7 @@ export class CheckService implements ICheckService {
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, status, filter);
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, status);
return result;
};
@@ -193,25 +196,25 @@ export class CheckService implements ICheckService {
teamId,
sortOrder,
dateRange,
filter,
page,
rowsPerPage,
filter,
}: {
teamId: string;
sortOrder: string;
dateRange: DateRange;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: CheckFilter;
filter?: string;
}) => {
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, parsedPage, parsedRowsPerPage, teamId, filter);
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
return checkData;
};
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: DateRange }) => {
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => {
const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange);
return summary;
};
+38 -47
View File
@@ -1,4 +1,4 @@
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { Response } from "got";
export const CHECK_TTL_SENTINEL = 366;
@@ -126,6 +126,43 @@ export interface PageSpeedChecksResult {
groupedChecks: PageSpeedGroupedCheck[];
}
export interface HardwareChecksResult {
monitorType: "hardware";
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: Array<{
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: Array<{
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}>;
net: Array<{
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}>;
}>;
}
export interface GroupedCheck {
bucketDate: string;
avgResponseTime: number;
@@ -168,49 +205,3 @@ export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
};
export type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">;
export interface HardwareDiskStats {
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}
export interface HardwareNetStats {
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}
export interface HardwareCheckStats {
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: HardwareDiskStats[];
net: HardwareNetStats[];
}
export interface HardwareStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: HardwareCheckStats[];
}
export interface HardwareChecksResult extends HardwareStats {
monitorType: "hardware";
}
@@ -79,6 +79,10 @@ export class DiagnosticService implements IDiagnosticService {
obs.observe({ entryTypes: ["measure"] });
}
get serviceName() {
return DiagnosticService.SERVICE_NAME;
}
private getCPUUsagePercentage = async (): Promise<number> => {
const startUsage = process.cpuUsage();
const timingPeriod = 1000; // measured in ms
@@ -1,5 +1,5 @@
import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/domain/geo-checks/geo-check.type.js";
type GeoCheckMetadataDocument = Omit<GeoCheckMetadata, "monitorId" | "teamId"> & {
@@ -1,6 +1,5 @@
import type { GeoCheck, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import type { GeoContinent, FlatGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import { DateRange } from "@/types/query.js";
export interface GeoChecksQueryResult {
geoChecksCount: number;
@@ -17,12 +16,19 @@ export interface IGeoChecksRepository {
findByMonitorId(
monitorId: string,
sortOrder: string,
dateRange: DateRange,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult>;
findGroupedByMonitorIdAndDateRange(monitorId: string, DateRange: DateRange, continents?: GeoContinent[]): Promise<GroupedGeoCheck[]>;
findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]>;
findGroupedByMonitorIdAndDateRange(
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]>;
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
@@ -3,15 +3,20 @@ import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoCo
import type { FlatGeoChecksQueryResult } from "./geo-check.repository.interface.js";
import { GeoCheckMetadataDocument, GeoCheckModel, type GeoCheckDocument } from "@/domain/geo-checks/geo-check.model.js";
import mongoose, { PipelineStage } from "mongoose";
import { getDateForRange, getDateFormat } from "@/utils/dataUtils.js";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "GeoChecksRepository";
class MongoGeoChecksRepository implements IGeoChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
private toEntity = (doc: GeoCheckDocument): GeoCheck => {
const mapMetadata = (metadata: GeoCheckMetadataDocument): GeoCheckMetadata => ({
monitorId: toStringId(metadata.monitorId),
@@ -58,214 +63,304 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
};
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
const docs = await GeoCheckModel.insertMany(
geoChecks.map((geoCheck) => ({
metadata: {
monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
type: geoCheck.metadata.type,
},
results: geoCheck.results,
expiry: new Date(geoCheck.expiry),
}))
);
return docs.map((doc) => this.toEntity(doc));
try {
const docs = await GeoCheckModel.insertMany(
geoChecks.map((geoCheck) => ({
metadata: {
monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
type: geoCheck.metadata.type,
},
results: geoCheck.results,
expiry: new Date(geoCheck.expiry),
}))
);
return docs.map((doc) => this.toEntity(doc));
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Failed to createGeoChecks: ${error.message}` : "Failed to createGeoChecks",
service: SERVICE_NAME,
method: "createGeoChecks",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: DateRange,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => {
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: { $gte: getDateForRange(dateRange) },
};
try {
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
};
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
// Common pipeline stages for both paths
const pipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
// Common pipeline stages for both paths
const pipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
},
]
: []),
// Project to flat structure
{
$project: {
_id: 0,
monitorId: "$metadata.monitorId",
teamId: "$metadata.teamId",
type: "$metadata.type",
location: "$results.location",
status: "$results.status",
statusCode: "$results.statusCode",
timings: "$results.timings",
createdAt: 1,
updatedAt: 1,
]
: []),
// Project to flat structure
{
$project: {
_id: 0,
monitorId: "$metadata.monitorId",
teamId: "$metadata.teamId",
type: "$metadata.type",
location: "$results.location",
status: "$results.status",
statusCode: "$results.statusCode",
timings: "$results.timings",
createdAt: 1,
updatedAt: 1,
},
},
},
{ $sort: { createdAt: convertedSortOrder } },
{ $skip: skip },
{ $limit: rowsPerPage },
];
{ $sort: { createdAt: convertedSortOrder } },
{ $skip: skip },
{ $limit: rowsPerPage },
];
// Count pipeline
const countPipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
// Count pipeline
const countPipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
},
]
: []),
{ $count: "count" },
];
]
: []),
{ $count: "count" },
];
const [countResult, dataResults] = await Promise.all([GeoCheckModel.aggregate(countPipeline), GeoCheckModel.aggregate(pipeline)]);
const [countResult, dataResults] = await Promise.all([GeoCheckModel.aggregate(countPipeline), GeoCheckModel.aggregate(pipeline)]);
const geoChecksCount = countResult[0]?.count || 0;
const geoChecks: FlatGeoCheck[] = dataResults.map((doc) => ({
id: `${doc.monitorId.toString()}-${new Date(doc.createdAt).getTime()}-${doc.location.continent}-${doc.location.city}-${Math.random().toString(36).substring(2, 15)}`,
monitorId: doc.monitorId.toString(),
teamId: doc.teamId.toString(),
type: doc.type,
location: doc.location,
status: doc.status,
statusCode: doc.statusCode,
timings: doc.timings,
createdAt: new Date(doc.createdAt).toISOString(),
updatedAt: new Date(doc.updatedAt).toISOString(),
}));
const geoChecksCount = countResult[0]?.count || 0;
const geoChecks: FlatGeoCheck[] = dataResults.map((doc) => ({
id: `${doc.monitorId.toString()}-${new Date(doc.createdAt).getTime()}-${doc.location.continent}-${doc.location.city}-${Math.random().toString(36).substring(2, 15)}`,
monitorId: doc.monitorId.toString(),
teamId: doc.teamId.toString(),
type: doc.type,
location: doc.location,
status: doc.status,
statusCode: doc.statusCode,
timings: doc.timings,
createdAt: new Date(doc.createdAt).toISOString(),
updatedAt: new Date(doc.updatedAt).toISOString(),
}));
return { geoChecksCount, geoChecks };
return { geoChecksCount, geoChecks };
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error finding geo checks by monitor ID: ${error.message}` : "Error finding geo checks by monitor ID",
service: SERVICE_NAME,
method: "findByMonitorId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
findGroupedByMonitorIdAndDateRange = async (monitorId: string, dateRange: DateRange, continents?: GeoContinent[]): Promise<GroupedGeoCheck[]> => {
const start = getDateForRange(dateRange);
const dateString = getDateFormat(dateRange);
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
try {
const docs = await GeoCheckModel.find({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: startDate,
$lte: endDate,
},
}).sort({ createdAt: -1 });
return docs.map(this.toEntity);
} catch (error: unknown) {
this.logger.error({
message:
error instanceof Error
? `Error finding geo checks by monitor ID and date range: ${error.message}`
: "Error finding geo checks by monitor ID and date range",
service: SERVICE_NAME,
method: "findByMonitorIdAndDateRange",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
const end = new Date();
const pipeline: PipelineStage[] = [
// Match geo checks for this monitor in date range
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: start,
$lte: end,
},
},
},
// Unwind the results array to process each location separately
{
$unwind: "$results",
},
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
// Group by date bucket and continent
{
$group: {
_id: {
bucketDate: {
$dateToString: {
format: dateString,
date: "$createdAt",
},
},
continent: "$results.location.continent",
},
avgResponseTime: { $avg: "$results.timings.total" },
totalChecks: { $sum: 1 },
upChecks: {
$sum: {
$cond: ["$results.status", 1, 0],
findGroupedByMonitorIdAndDateRange = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
try {
const pipeline: PipelineStage[] = [
// Match geo checks for this monitor in date range
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: startDate,
$lte: endDate,
},
},
},
},
// Calculate uptime percentage
{
$project: {
_id: 0,
bucketDate: "$_id.bucketDate",
continent: "$_id.continent",
avgResponseTime: { $round: ["$avgResponseTime", 2] },
totalChecks: 1,
uptimePercentage: {
$round: [
// Unwind the results array to process each location separately
{
$unwind: "$results",
},
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$multiply: [
{
$divide: ["$upChecks", "$totalChecks"],
},
100,
],
$match: {
"results.location.continent": { $in: continents },
},
},
2,
],
]
: []),
// Group by date bucket and continent
{
$group: {
_id: {
bucketDate: {
$dateToString: {
format: dateFormat,
date: "$createdAt",
},
},
continent: "$results.location.continent",
},
avgResponseTime: { $avg: "$results.timings.total" },
totalChecks: { $sum: 1 },
upChecks: {
$sum: {
$cond: ["$results.status", 1, 0],
},
},
},
},
},
// Sort by date and continent
{
$sort: {
bucketDate: 1,
continent: 1,
// Calculate uptime percentage
{
$project: {
_id: 0,
bucketDate: "$_id.bucketDate",
continent: "$_id.continent",
avgResponseTime: { $round: ["$avgResponseTime", 2] },
totalChecks: 1,
uptimePercentage: {
$round: [
{
$multiply: [
{
$divide: ["$upChecks", "$totalChecks"],
},
100,
],
},
2,
],
},
},
},
},
];
// Sort by date and continent
{
$sort: {
bucketDate: 1,
continent: 1,
},
},
];
const results = await GeoCheckModel.aggregate(pipeline);
return results as GroupedGeoCheck[];
const results = await GeoCheckModel.aggregate(pipeline);
return results as GroupedGeoCheck[];
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error finding grouped geo checks: ${error.message}` : "Error finding grouped geo checks",
service: SERVICE_NAME,
method: "findGroupedByMonitorIdAndDateRange",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
const result = await GeoCheckModel.deleteMany({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
});
return result.deletedCount || 0;
try {
const result = await GeoCheckModel.deleteMany({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
});
return result.deletedCount || 0;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error deleting geo checks by monitor ID: ${error.message}` : "Error deleting geo checks by monitor ID",
service: SERVICE_NAME,
method: "deleteByMonitorId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
deleteByTeamId = async (teamId: string): Promise<number> => {
const result = await GeoCheckModel.deleteMany({
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
try {
const result = await GeoCheckModel.deleteMany({
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error deleting geo checks by team ID: ${error.message}` : "Error deleting geo checks by team ID",
service: SERVICE_NAME,
method: "deleteByTeamId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount || 0;
try {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount || 0;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? `Error deleting orphaned geo checks: ${error.message}` : "Error deleting orphaned geo checks",
service: SERVICE_NAME,
method: "deleteByMonitorIdsNotIn",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
}
@@ -1,5 +1,5 @@
import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { GeoCheckResult, GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import { Types } from "mongoose";
import type { FlatGeoChecksQueryResult, IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
@@ -7,18 +7,18 @@ import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.i
import type { IGlobalPingService } from "@/service/globalPingService.js";
import type { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "GeoChecksService";
export interface IGeoChecksService {
readonly serviceName: string;
buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null>;
createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>;
getGeoChecksByMonitor(args: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: DateRange;
dateRange: string;
page?: number;
rowsPerPage?: number;
continent?: GeoContinent | GeoContinent[];
@@ -50,6 +50,10 @@ export class GeoChecksService implements IGeoChecksService {
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return GeoChecksService.SERVICE_NAME;
}
async buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null> {
try {
if (!monitor.url) {
@@ -156,7 +160,7 @@ export class GeoChecksService implements IGeoChecksService {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: DateRange;
dateRange: string;
page?: number;
rowsPerPage?: number;
continent: GeoContinent | GeoContinent[];
@@ -1,4 +1,4 @@
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.types.js";
export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoContinent = (typeof GeoContinents)[number];
@@ -1,5 +1,5 @@
const SERVICE_NAME = "incidentService";
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { getDateForRange } from "@/utils/dataUtils.js";
@@ -11,7 +11,6 @@ import type { User } from "@/domain/users/user.type.js";
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import type { ILogger } from "@/utils/logger.js";
import { DateRange } from "@/types/query.js";
export interface IIncidentService {
handleIncident(
@@ -24,7 +23,7 @@ export interface IIncidentService {
getIncidentsByTeam(
teamId: string,
sortOrder: string,
dateRange: DateRange,
dateRange: string,
page: number,
rowsPerPage: number,
status: boolean | undefined,
@@ -58,6 +57,10 @@ export class IncidentService implements IIncidentService {
this.notificationMessageBuilder = notificationMessageBuilder;
}
get serviceName() {
return IncidentService.SERVICE_NAME;
}
handleIncident = async (
monitor: Monitor,
code: number,
@@ -175,7 +178,7 @@ export class IncidentService implements IIncidentService {
getIncidentsByTeam = async (
teamId: string,
sortOrder: string,
dateRange: DateRange,
dateRange: string,
page: number,
rowsPerPage: number,
status: boolean | undefined,
+2 -2
View File
@@ -1,7 +1,7 @@
// export type IncidentResolutionType = "automatic" | "manual" | null;
export const IncidentResolutionTypes = ["automatic", "manual"] as const;
export type IncidentResolutionType = (typeof IncidentResolutionTypes)[number] | null;
export const IncidentResolutionTypes = ["automatic", "manual", null] as const;
export type IncidentResolutionType = (typeof IncidentResolutionTypes)[number];
export interface Incident {
id: string;
@@ -35,6 +35,10 @@ export class InviteService implements IInviteService {
this.emailService = emailService;
}
get serviceName() {
return InviteService.SERVICE_NAME;
}
getInviteToken = async ({ invite, teamId, userRoles }: { invite: Partial<Invite>; teamId: string; userRoles: UserRole[] }) => {
invite.teamId = teamId;
@@ -62,6 +62,10 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
this.scheduler = scheduler;
}
get serviceName() {
return MaintenanceWindowService.SERVICE_NAME;
}
private flipMonitorsLeavingMaintenance = async (monitorIds: string[], teamId: string, excludeWindowId: string, now: Date): Promise<void> => {
if (monitorIds.length === 0) return;
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.types.js";
import type {
CheckAudits,
CheckCaptureInfo,
@@ -1,4 +1,4 @@
import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
export interface TeamQueryConfig {
limit?: number;
@@ -26,9 +26,9 @@ export interface IMonitorsRepository {
findByIdLean(monitorId: string): Promise<Monitor | null>;
// collection fetch
findAll(): Promise<Monitor[]>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[]>;
findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise<Monitor[]>;
findAll(): Promise<Monitor[] | null>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByIds(monitorIds: string[]): Promise<Monitor[]>;
findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise<Monitor[]>;
@@ -1,7 +1,7 @@
import { MonitorModel } from "@/domain/monitors/monitor.model.js";
import type { MonitorDocument, CheckSnapshotDocument } from "@/domain/monitors/monitor.model.js";
import type { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js";
import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.types.js";
import mongoose, { type FilterQuery, type PipelineStage } from "mongoose";
import { MongoBulkWriteError } from "mongodb";
import { AppError } from "@/utils/AppError.js";
@@ -91,26 +91,27 @@ class MongoMonitorsRepository implements IMonitorsRepository {
{ $project: { stats: 0 } },
];
private pageOptions = (config: TeamQueryConfig) => {
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
};
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[]> => {
const query = this.queryBuilder(config, teamId);
const { sort, skip, limit } = this.pageOptions(config);
const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(limit);
const sort = { [field]: order === "asc" ? 1 : -1 } as const;
const skip = Math.max(page, 0) * rowsPerPage;
const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(rowsPerPage);
return this.mapDocuments(documents);
};
findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[]> => {
const { sort, skip, limit } = this.pageOptions(config);
findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
const query = this.queryBuilder(config, teamId);
const sort = { [field]: order === "asc" ? 1 : -1 } as const;
const skip = Math.max(page, 0) * rowsPerPage;
const pipeline: PipelineStage[] = [
{ $match: query },
{ $sort: sort },
...(limit ? [{ $skip: skip }, { $limit: limit }] : []),
...(rowsPerPage ? [{ $skip: skip }, { $limit: rowsPerPage }] : []),
...this.uptimeStatsLookupStages,
];
+79 -34
View File
@@ -1,5 +1,5 @@
import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js";
import { type Monitor } from "@/domain/monitors/monitor.type.js";
import { type Monitor } from "@/domain/monitors/monitor.types.js";
import type {
MonitorType,
MonitorsWithChecksByTeamIdResult,
@@ -8,8 +8,8 @@ import type {
PageSpeedDetailsResult,
GamesMap,
GroupedGeoCheckResult,
} from "@/domain/monitors/monitor.type.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js";
} from "@/domain/monitors/monitor.types.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.types.js";
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.type.js";
import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
@@ -23,27 +23,29 @@ import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
supportsUptimeDetails(result.monitorType);
export interface IMonitorService {
readonly serviceName: string;
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<PageSpeedDetailsResult>;
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<PageSpeedDetailsResult>;
getGeoChecksByMonitorId(args: {
teamId: string;
monitorId: string;
dateRange: DateRange;
dateRange: string;
continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
@@ -133,6 +135,35 @@ export class MonitorService implements IMonitorService {
this.incidentsRepository = incidentsRepository;
}
get serviceName(): string {
return MonitorService.SERVICE_NAME;
}
private getDateRange = (dateRange: DateRangeKey) => {
const startDates = {
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: new Date(0),
};
return {
start: startDates[dateRange],
end: new Date(),
};
};
private getDateFormat = (dateRange: DateRangeKey): string => {
const formatLookup = {
recent: "%Y-%m-%dT%H:%M:00Z",
day: "%Y-%m-%dT%H:00:00Z",
week: "%Y-%m-%dT00:00:00Z",
month: "%Y-%m-%dT00:00:00Z",
all: "%Y-%m-%dT00:00:00Z",
};
return formatLookup[dateRange];
};
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
@@ -175,13 +206,15 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
dateRange: string;
}): Promise<UptimeDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
@@ -210,7 +243,7 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
dateRange: string;
}): Promise<HardwareDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
@@ -220,7 +253,9 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
@@ -250,7 +285,7 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
dateRange: string;
}): Promise<PageSpeedDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
@@ -260,7 +295,9 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
@@ -287,7 +324,7 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
monitorId: string;
dateRange: DateRange;
dateRange: string;
continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
@@ -299,7 +336,15 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] };
}
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(monitor.id, dateRange, continents);
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id,
start,
end,
this.getDateFormat(rangeKey),
continents
);
return { groupedGeoChecks };
};
@@ -318,7 +363,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[];
tags?: string | string[];
filter?: string;
}): Promise<Monitor[]> => {
}): Promise<Monitor[] | null> => {
return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter });
};
@@ -356,12 +401,13 @@ export class MonitorService implements IMonitorService {
order,
});
const monitorsList = (monitors ?? []) as Monitor[];
const snapshotTypes: MonitorType[] = ["hardware"];
const requestedTypes = Array.isArray(type) ? type : type ? [type] : [];
const snapshotOnlyRequest =
requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType));
const monitorsWithChecks = monitors.map((monitor: Monitor) => {
const monitorsWithChecks = monitorsList.map((monitor: Monitor) => {
const rawChecks = monitor.recentChecks ?? [];
const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type);
const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100);
@@ -450,7 +496,15 @@ export class MonitorService implements IMonitorService {
return { monitors, failedCount };
};
private deleteMonitorChildren = async (monitor: Monitor, teamId: string) => {
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`,
@@ -482,19 +536,6 @@ export class MonitorService implements IMonitorService {
});
});
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err instanceof Error ? err.stack : undefined,
});
});
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.deleteMonitorChildren(monitor, teamId);
await this.scheduler.deleteJob(monitor);
return monitor;
};
@@ -504,8 +545,12 @@ export class MonitorService implements IMonitorService {
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.deleteMonitorChildren(monitor, teamId);
await this.scheduler.deleteJob(monitor);
await this.checksRepository.deleteByMonitorId(monitor.id);
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id);
await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId);
} catch (error: unknown) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
@@ -522,7 +567,7 @@ export class MonitorService implements IMonitorService {
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<Monitor[]> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors.length === 0) {
if (!monitors || monitors.length === 0) {
throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 });
}
@@ -3,7 +3,6 @@ export type { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import http from "node:http";
import { HardwareStats } from "@/domain/checks/check.type.js";
export const HttpStatusCodes = [
...Object.keys(http.STATUS_CODES).map(Number),
@@ -150,6 +149,48 @@ export interface UptimeDetailsResult {
monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null;
}
export interface HardwareDiskStats {
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}
export interface HardwareNetStats {
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}
export interface HardwareCheckStats {
bucketDate: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: HardwareDiskStats[];
net: HardwareNetStats[];
}
export interface HardwareStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: HardwareCheckStats[];
}
export interface HardwareDetailsResult {
monitor: Monitor;
stats: HardwareStats;
@@ -1,4 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type {
@@ -1,4 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { Notification } from "@/domain/notifications/notification.type.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
@@ -9,9 +9,6 @@ import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import type { NotificationChannel } from "@/domain/notifications/notification.type.js";
export type NotificationProviderRegistry = Record<NotificationChannel, INotificationProvider>;
export interface INotificationsService {
createNotification: (notificationData: Partial<Notification>, userId: string, teamId: string) => Promise<Notification>;
@@ -32,29 +29,52 @@ export class NotificationsService implements INotificationsService {
private notificationsRepository: INotificationsRepository;
private monitorsRepository: IMonitorsRepository;
private providers: NotificationProviderRegistry;
private settingsService: ISettingsService;
private webhookProvider: INotificationProvider;
private emailProvider: INotificationProvider;
private slackProvider: INotificationProvider;
private discordProvider: INotificationProvider;
private pagerDutyProvider: INotificationProvider;
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private pushoverProvider: INotificationProvider;
private twilioProvider: INotificationProvider;
private ntfyProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
constructor({
notificationsRepository,
monitorsRepository,
providers,
settingsService,
logger,
notificationMessageBuilder,
}: {
notificationsRepository: INotificationsRepository;
monitorsRepository: IMonitorsRepository;
providers: NotificationProviderRegistry;
settingsService: ISettingsService;
logger: ILogger;
notificationMessageBuilder: INotificationMessageBuilder;
}) {
constructor(
notificationsRepository: INotificationsRepository,
monitorsRepository: IMonitorsRepository,
webhookProvider: INotificationProvider,
emailProvider: INotificationProvider,
slackProvider: INotificationProvider,
discordProvider: INotificationProvider,
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
pushoverProvider: INotificationProvider,
twilioProvider: INotificationProvider,
ntfyProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
) {
this.notificationsRepository = notificationsRepository;
this.monitorsRepository = monitorsRepository;
this.providers = providers;
this.webhookProvider = webhookProvider;
this.emailProvider = emailProvider;
this.slackProvider = slackProvider;
this.discordProvider = discordProvider;
this.pagerDutyProvider = pagerDutyProvider;
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.pushoverProvider = pushoverProvider;
this.twilioProvider = twilioProvider;
this.ntfyProvider = ntfyProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -77,16 +97,37 @@ export class NotificationsService implements INotificationsService {
}
// Route to provider based on notification type
const provider = this.providers[notification.type];
if (!provider) {
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME,
method: "send",
});
return false;
switch (notification.type) {
case "webhook":
return await this.webhookProvider.sendMessage!(notification, notificationMessage);
case "slack":
return await this.slackProvider.sendMessage!(notification, notificationMessage);
case "matrix":
return await this.matrixProvider.sendMessage!(notification, notificationMessage);
case "pager_duty":
return await this.pagerDutyProvider.sendMessage!(notification, notificationMessage);
case "discord":
return await this.discordProvider.sendMessage!(notification, notificationMessage);
case "email":
return await this.emailProvider.sendMessage!(notification, notificationMessage);
case "teams":
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
case "telegram":
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
case "pushover":
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
case "twilio":
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
case "ntfy":
return await this.ntfyProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME,
method: "send",
});
return false;
}
return await provider.sendMessage(notification, notificationMessage);
};
private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => {
@@ -124,26 +165,32 @@ export class NotificationsService implements INotificationsService {
};
sendTestNotification = async (notification: Partial<Notification>) => {
const type = notification.type;
if (!type) {
this.logger.warn({
message: "Notification type not provided",
service: SERVICE_NAME,
method: "sendTestNotification",
});
return false;
switch (notification.type) {
case "email":
return await this.emailProvider.sendTestAlert(notification);
case "slack":
return await this.slackProvider.sendTestAlert(notification);
case "discord":
return await this.discordProvider.sendTestAlert(notification);
case "pager_duty":
return await this.pagerDutyProvider.sendTestAlert(notification);
case "matrix":
return await this.matrixProvider.sendTestAlert(notification);
case "webhook":
return await this.webhookProvider.sendTestAlert(notification);
case "teams":
return await this.teamsProvider.sendTestAlert(notification);
case "telegram":
return await this.telegramProvider.sendTestAlert(notification);
case "pushover":
return await this.pushoverProvider.sendTestAlert(notification);
case "twilio":
return await this.twilioProvider.sendTestAlert(notification);
case "ntfy":
return await this.ntfyProvider.sendTestAlert(notification);
default:
return false;
}
const provider = this.providers[type];
if (!provider) {
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME,
method: "sendTestNotification",
});
return false;
}
return await provider.sendTestAlert(notification);
};
testAllNotifications = async (notificationIds: string[]) => {
@@ -4,8 +4,7 @@ import { QueueWorkerDocument } from "@/domain/queue-workers/queue-worker.type.js
// Single source of truth for worker liveness: the TTL that GCs stale records and
// the window readers use to count a worker as alive. Heartbeat cadence must stay well below it.
const WORKER_TTL_SECONDS = 180;
export const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000;
export const WORKER_TTL_SECONDS = 180;
const QueueWorkerSchema = new Schema<QueueWorkerDocument>(
{
@@ -9,7 +9,7 @@ import {
} from "@/domain/status-pages/status-page.type.js";
import { AppError } from "@/utils/AppError.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { NormalizeData } from "@/utils/dataUtils.js";
export interface IStatusPageService {
@@ -1,4 +1,4 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { Monitor } from "@/domain/monitors/monitor.types.js";
import type { NormalizedCheck, CheckSnapshot } from "@/domain/checks/check.type.js";
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
@@ -2,6 +2,7 @@ import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { UserModel, type UserDocument } from "@/domain/users/user.model.js";
import type { User, UserProfileImage } from "@/domain/users/user.type.js";
import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
const SERVICE_NAME = "MongoUsersRepository";
@@ -71,7 +72,7 @@ class MongoUsersRepository implements IUsersRepository {
findById = async (id: string) => {
const user = await UserModel.findById(id).select("-password").select("-profileImage");
if (!user) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
throw new Error("User not found");
}
return this.toEntity(user);
@@ -86,8 +87,9 @@ class MongoUsersRepository implements IUsersRepository {
const candidateUser = { ...patch };
let unsetFields: Record<string, 1> | undefined;
if (candidateUser.deleteProfileImage === true) {
if (ParseBoolean(candidateUser.deleteProfileImage) === true) {
unsetFields = { profileImage: 1, avatarImage: 1 };
delete candidateUser.deleteProfileImage;
} else if (file) {
// 1. Save the full size image
candidateUser.profileImage = {
+6 -2
View File
@@ -24,7 +24,7 @@ export interface IUserService {
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null): Promise<User>;
loginUser(email: string, password: string): Promise<{ user: User; token: string }>;
editUser(
updates: Partial<User & { newPassword?: string; deleteProfileImage?: boolean }>,
updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
@@ -103,6 +103,10 @@ export class UserService implements IUserService {
this.teamsRepository = teamsRepository;
}
get serviceName() {
return UserService.SERVICE_NAME;
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
};
@@ -238,7 +242,7 @@ export class UserService implements IUserService {
};
editUser = async (
updates: Partial<User & { newPassword?: string; deleteProfileImage?: boolean }>,
updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
+1 -1
View File
@@ -27,7 +27,7 @@ const startApp = async () => {
const env = validateEnv(logger);
logger.setLogLevel(env.LOG_LEVEL);
const settingsService = new SettingsService(env);
const envSettings = settingsService.getSettings();
const envSettings = settingsService.loadSettings();
const { queueMode, queuePrimaryProcesses } = envSettings;
logger.info({
+4
View File
@@ -51,6 +51,10 @@ export class BufferService implements IBufferService {
});
}
get serviceName() {
return BufferService.SERVICE_NAME;
}
addToBuffer(check: Check) {
try {
this.buffer.push(check);
+5
View File
@@ -56,6 +56,10 @@ export class EmailService implements IEmailService {
this.init();
}
get serviceName() {
return EmailService.SERVICE_NAME;
}
init = () => {
const loadTemplate = (templateName: string) => {
try {
@@ -75,6 +79,7 @@ export class EmailService implements IEmailService {
this.templateLookup = {
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
+7 -2
View File
@@ -1,6 +1,6 @@
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/domain/geo-checks/geo-check.type.js";
import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor.type.js";
import { MonitorType } from "@/domain/monitors/monitor.type.js";
import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor.types.js";
import { MonitorType } from "@/domain/monitors/monitor.types.js";
import type { ILogger } from "@/utils/logger.js";
import got from "got";
import { isStatusUp } from "@/service/network/utils.js";
@@ -61,6 +61,7 @@ interface GlobalPingProbeResult {
}
export interface IGlobalPingService {
readonly serviceName: string;
createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>;
pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise<GeoCheckResult[]>;
}
@@ -74,6 +75,10 @@ export class GlobalPingService implements IGlobalPingService {
this.logger = logger;
}
get serviceName() {
return GlobalPingService.SERVICE_NAME;
}
async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null> {
try {
if (!supportsGeoCheck(monitorType)) {
@@ -1,4 +1,4 @@
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import jmespath from "jmespath";
import RE2 from "re2";
type JmesPath = typeof jmespath;
+2 -3
View File
@@ -1,10 +1,9 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
const SERVICE_NAME = "DNSProvider";
+3 -3
View File
@@ -1,11 +1,11 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
type DockerodeType = typeof Dockerode;
const SERVICE_NAME = "DockerProvider";
+2 -2
View File
@@ -1,9 +1,9 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { GameDig } from "gamedig";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { NETWORK_ERROR } from "@/service/network/utils.js";
import { ILogger } from "@/utils/logger.js";
type GameDigType = typeof GameDig;
+1 -1
View File
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js";
import * as grpc from "@grpc/grpc-js";
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AppError } from "@/utils/AppError.js";
+2 -3
View File
@@ -5,9 +5,8 @@ import { HttpStatusPayload } from "@/types/network.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { Agent as HttpsAgent } from "https";
import { Agent as HttpAgent } from "http";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { isStatusUp } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { NETWORK_ERROR, isStatusUp } from "@/service/network/utils.js";
import CacheableLookup from "cacheable-lookup";
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
@@ -1,4 +1,4 @@
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { MonitorStatusResponse } from "@/types/network.js";
export interface IStatusProvider<T> {
@@ -1,6 +1,6 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.types.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import { PingStatusPayload } from "@/types/network.js";
import { IStatusProvider } from "./IStatusProvider.js";
import { MonitorType, Monitor } from "@/domain/monitors/monitor.type.js";
import { MonitorType, Monitor } from "@/domain/monitors/monitor.types.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import ping from "ping";
+2 -3
View File
@@ -1,9 +1,8 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import * as net from "net";
type NetType = typeof net;
@@ -1,9 +1,8 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import { AppError } from "@/utils/AppError.js";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js";
import type WebSocket from "ws";
type WebSocketConstructor = typeof WebSocket;
+4 -1
View File
@@ -1,4 +1,4 @@
import type { HttpStatusCode } from "@/domain/monitors/monitor.type.js";
import type { HttpStatusCode } from "@/domain/monitors/monitor.types.js";
export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => {
const start = process.hrtime.bigint();
@@ -12,6 +12,9 @@ export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ res
}
};
export const NETWORK_ERROR = 5000;
export const PING_ERROR = 5001;
export const isStatusUp = (statusCode: number | undefined, customUpCodes: HttpStatusCode[] = []): boolean => {
if (statusCode === undefined) return false;
return (statusCode >= 200 && statusCode < 300) || customUpCodes.includes(statusCode);
+7 -2
View File
@@ -1,13 +1,14 @@
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js";
import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { AxiosStatic } from "axios";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { NETWORK_ERROR } from "@/service/network/utils.js";
import { ILogger } from "@/utils/logger.js";
import { IStatusProvider } from "./network/IStatusProvider.js";
const SERVICE_NAME = "NetworkService";
export interface INetworkService {
readonly serviceName: string;
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
requestWebhook(
type: string,
@@ -38,6 +39,10 @@ export class NetworkService implements INetworkService {
this.logger = logger;
}
get serviceName(): string {
return NetworkService.SERVICE_NAME;
}
// Main entry point
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
const provider = this.providers.find((p) => p.supports(monitor.type));
+6 -2
View File
@@ -1,7 +1,7 @@
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js";
import type { Monitor, MonitorStatus } from "@/domain/monitors/monitor.type.js";
import type { Monitor, MonitorStatus } from "@/domain/monitors/monitor.types.js";
import type {
DockerStatusPayload,
GameStatusPayload,
@@ -17,7 +17,7 @@ import type {
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import type { HardwareStatusMetrics } from "@/types/network.js";
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js";
import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.types.js";
const SERVICE_NAME = "StatusService";
const HARDWARE_ALERT_COUNTER_START = 5;
@@ -57,6 +57,10 @@ export class StatusService implements IStatusService {
this.monitorStatsRepository = monitorStatsRepository;
}
get serviceName() {
return StatusService.SERVICE_NAME;
}
async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) {
try {
await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
+58
View File
@@ -0,0 +1,58 @@
<!-- name -->
<mjml>
<mj-head>
<mj-font
name="Roboto"
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text
font-weight="300"
font-size="16px"
color="#616161"
line-height="24px"
></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text
align="left"
font-size="10px"
>
Message from Checkmate Service
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{ name }}!</p>
<p>
We hope you’re finding Checkmate helpful in monitoring your infrastructure. Your support means a lot to us, and we
<b>truly appreciate</b> having you as part of our community.
</p>
<p>
If you’re happy with Checkmate, we’d love to hear about your experience! Leaving a review on G2 helps others discover Checkmate and
supports our ongoing improvements.
</p>
G2 Link: TBD
<p>Thank you for taking the time to share your thoughts - we greatly appreciate it!</p>
Checkmate Team
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider
border-width="1px"
border-color="#E0E0E0"
></mj-divider>
<mj-text font-size="12px">
<p>This email was sent by Checkmate.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
+66
View File
@@ -0,0 +1,66 @@
<mjml>
<mj-head>
<mj-font
name="Roboto"
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text
font-weight="300"
font-size="16px"
color="#616161"
line-height="24px"
></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text
align="left"
font-size="10px"
>Message from Checkmate Service</mj-text
>
</mj-column>
<mj-column
width="45%"
padding-top="20px"
>
<mj-text
align="center"
font-weight="500"
padding="0px"
font-size="18px"
color="green"
>No incidents this week!</mj-text
>
<mj-divider
border-width="2px"
border-color="#616161"
></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{ name }}!</p>
<p>There were no incidents this week. Good job!</p>
<p><b>Current monitors:</b></p>
<p><b>Google:</b> 100% availability</p>
<p><b>Canada.ca:</b>100% availability</p>
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider
border-width="1px"
border-color="#E0E0E0"
></mj-divider>
<mj-text font-size="12px">
<p>This email was sent by Checkmate.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
+1 -3
View File
@@ -9,12 +9,10 @@ import type {
GotTimings,
ILighthouseAudit,
} from "@/domain/checks/check.type.js";
import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.types.js";
import type { QueryResult } from "gamedig";
export const NETWORK_ERROR = 5000;
export interface MonitorStatusResponse<
T =
| HttpStatusPayload
-8
View File
@@ -1,8 +0,0 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
export const SortOrders = ["asc", "desc"] as const;
export type SortOrder = (typeof SortOrders)[number];
export const CheckFilters = ["all", "up", "down", "resolve"] as const;
export type CheckFilter = (typeof CheckFilters)[number];
+57 -43
View File
@@ -1,7 +1,6 @@
import type { GroupedCheck, HasResponseTime, NormalizedCheck, NormalizedUptimeCheck } from "@/domain/checks/check.type.js";
import { type DateRange } from "@/types/query.js";
export const getDateForRange = (dateRange: DateRange): Date => {
export const getDateForRange = (dateRange: string): Date | undefined => {
const now = Date.now();
switch (dateRange) {
case "recent":
@@ -15,63 +14,78 @@ export const getDateForRange = (dateRange: DateRange): Date => {
case "month":
return new Date(now - 30 * 24 * 60 * 60 * 1000); // 30 days
case "all":
return new Date(0);
return undefined;
default:
return new Date(0);
return undefined;
}
};
export const getDateFormat = (dateRange: DateRange): string => {
const formatLookup = {
hour: "%Y-%m-%dT%H:%M:00Z",
recent: "%Y-%m-%dT%H:%M:00Z",
day: "%Y-%m-%dT%H:00:00Z",
week: "%Y-%m-%dT00:00:00Z",
month: "%Y-%m-%dT00:00:00Z",
all: "%Y-%m-%dT00:00:00Z",
};
return formatLookup[dateRange];
};
const percentileBy = <T>(arr: T[], percentile: number, value: (item: T) => number): number => {
const sorted = arr.slice().sort((a, b) => value(a) - value(b));
const calculatePercentile = <T extends HasResponseTime>(arr: T[], percentile: number): number => {
const sorted = arr.slice().sort((a, b) => a.responseTime - b.responseTime);
const index = (percentile / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= sorted.length) return value(sorted[lower]!);
return value(sorted[lower]!) * (1 - weight) + value(sorted[upper]!) * weight;
if (upper >= sorted.length) return sorted[lower]!.responseTime;
return sorted[lower]!.responseTime * (1 - weight) + sorted[upper]!.responseTime * weight;
};
const rescale = (raw: number, min: number, max: number, rangeMin: number, rangeMax: number): number => {
const scaled = rangeMin + ((raw - min) * (rangeMax - rangeMin)) / (max - min);
return Math.max(rangeMin, Math.min(rangeMax, scaled));
const calculatePercentileUptimeDetails = (arr: GroupedCheck[], percentile: number): number => {
const sorted = arr.slice().sort((a, b) => a.avgResponseTime - b.avgResponseTime);
const index = (percentile / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= sorted.length) return sorted[lower]!.avgResponseTime;
return sorted[lower]!.avgResponseTime * (1 - weight) + sorted[upper]!.avgResponseTime * weight;
};
export const NormalizeData = <T extends HasResponseTime>(checks: T[], rangeMin: number, rangeMax: number): NormalizedCheck<T>[] => {
const original = (check: T) => (typeof check.responseTime === "number" ? check.responseTime : 0);
if (checks.length <= 1) {
return checks.map((check) => ({ ...check, originalResponseTime: original(check) }));
if (checks.length > 1) {
const min = calculatePercentile(checks, 0);
const max = calculatePercentile(checks, 95);
const normalizedChecks = checks.map((check) => {
const originalResponseTime = typeof check.responseTime === "number" ? check.responseTime : 0;
let normalizedResponseTime = rangeMin + ((check.responseTime - min) * (rangeMax - rangeMin)) / (max - min);
normalizedResponseTime = Math.max(rangeMin, Math.min(rangeMax, normalizedResponseTime));
return {
...check,
responseTime: normalizedResponseTime,
originalResponseTime: originalResponseTime,
};
});
return normalizedChecks;
} else {
return checks.map((check) => {
const originalResponseTime = typeof check.responseTime === "number" ? check.responseTime : 0;
return { ...check, originalResponseTime: originalResponseTime };
});
}
const min = percentileBy(checks, 0, (c) => c.responseTime);
const max = percentileBy(checks, 95, (c) => c.responseTime);
return checks.map((check) => ({
...check,
responseTime: rescale(check.responseTime, min, max, rangeMin, rangeMax),
originalResponseTime: original(check),
}));
};
export const NormalizeDataUptimeDetails = <T extends GroupedCheck>(checks: T[], rangeMin: number, rangeMax: number): NormalizedUptimeCheck<T>[] => {
const original = (check: T) => (typeof check.avgResponseTime === "number" ? check.avgResponseTime : 0);
if (checks.length <= 1) {
return checks.map((check) => ({ ...check, originalAvgResponseTime: original(check) }));
if (checks.length > 1) {
const min = calculatePercentileUptimeDetails(checks, 0);
const max = calculatePercentileUptimeDetails(checks, 95);
const normalizedChecks = checks.map((check) => {
const originalResponseTime = check.avgResponseTime;
let normalizedResponseTime = rangeMin + ((check.avgResponseTime - min) * (rangeMax - rangeMin)) / (max - min);
normalizedResponseTime = Math.max(rangeMin, Math.min(rangeMax, normalizedResponseTime));
return {
...check,
avgResponseTime: normalizedResponseTime,
originalAvgResponseTime: originalResponseTime,
};
});
return normalizedChecks;
} else {
return checks.map((check) => {
return { ...check, originalAvgResponseTime: check.avgResponseTime };
});
}
const min = percentileBy(checks, 0, (c) => c.avgResponseTime);
const max = percentileBy(checks, 95, (c) => c.avgResponseTime);
return checks.map((check) => ({
...check,
avgResponseTime: rescale(check.avgResponseTime, min, max, rangeMin, rangeMax),
originalAvgResponseTime: original(check),
}));
};
+5
View File
@@ -19,6 +19,7 @@ interface LogEntry extends LogConfig {
}
export interface ILogger {
readonly serviceName: string;
info(config: LogConfig): void;
warn(config: LogConfig): void;
error(config: LogConfig): void;
@@ -96,6 +97,10 @@ class Logger implements ILogger {
});
}
get serviceName() {
return Logger.SERVICE_NAME;
}
info(config: LogConfig) {
const logEntry = this.buildLogEntry("info", config);
this.cacheLog(logEntry);
+17
View File
@@ -0,0 +1,17 @@
export const ParseBoolean = (value: boolean | string | null | undefined) => {
if (value === true || value === "true") {
return true;
} else if (value === false || value === "false" || value === null || value === undefined) {
return false;
}
};
export const getTokenFromHeaders = (headers: Record<string, string>) => {
const authorizationHeader = headers.authorization;
if (!authorizationHeader) throw new Error("No auth headers");
const parts = authorizationHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") throw new Error("Invalid auth headers");
return parts[1];
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { MonitorStatusResponse } from "@/types/network.js";
import { MonitorEvaluation } from "@/worker/worker.interface.js";
import { Check } from "@/domain/checks/check.type.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { IMonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { IStatusService } from "@/service/statusService.js";
+2 -2
View File
@@ -1,11 +1,11 @@
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { MonitorEvaluation } from "@/worker/worker.interface.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import { isWindowActive } from "@/utils/maintenanceWindow.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "@/service/bufferService.js";
import { AppError } from "@/utils/AppError.js";
import { supportsGeoCheck } from "@/domain/monitors/monitor.type.js";
import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
const SERVICE_NAME = "CheckPipeline";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { Check } from "@/domain/checks/check.type.js";
import { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
+63 -52
View File
@@ -11,13 +11,14 @@ import { ICheckPipeline } from "@/worker/worker.check-pipeline.js";
import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { IWorkerHelper } from "@/worker/worker.helper.js";
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
import { WORKER_STALE_MS } from "@/domain/queue-workers/queue-worker.model.js";
import { WORKER_TTL_SECONDS } from "@/domain/queue-workers/queue-worker.model.js";
import { QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { JobScheduler } from "@/worker/worker.job-scheduler.js";
import { IBufferService } from "@/service/bufferService.js";
import { POLL_MS } from "@/worker/worker.job-scheduler.js";
const SERVICE_NAME = "JobQueue";
const POLL_MS = 250; // base poll interval while a loop is actively claiming work
const POLL_MAX_MS = 5000; // back off poll interval to 5s if no jobs
const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000; // a worker counts as alive if seen within this window
const LOCK_RENEW_MS = LOCK_MS / 3; // renew an in-flight job's lock at 1/3 the lease, so it survives two missed renewals
const DRAIN_TIMEOUT_MS = 25_000;
const DRAIN_POLL_MS = 200;
@@ -30,25 +31,6 @@ const CONCURRENCY: Record<JobType, number> = {
"cleanup-retention": 1,
};
export interface DBQueueWorkerDependencies {
logger: ILogger; // forwarded to super
isDbConnected: () => boolean;
jobsRepository: IJobsRepository; // forwarded
monitorsRepository: IMonitorsRepository; // forwarded
checksRepository: IChecksRepository;
checkService: ICheckService;
bufferService: IBufferService;
checkProducer: ICheckProducer;
checkEvaluator: ICheckEvaluator;
geoCheckPipeline: ICheckPipeline;
dispatcher: IReactorDispatcher;
helper: IWorkerHelper;
queueWorkersRepository: IQueueWorkersRepository; // forwarded
queueMode: QueueMode; // forwarded
queuePrimaryProcesses: boolean; // consumed immediately
workerId: string;
}
export class DBQueueWorker extends JobScheduler implements IQueueWorker {
static SERVICE_NAME = SERVICE_NAME;
@@ -63,39 +45,68 @@ export class DBQueueWorker extends JobScheduler implements IQueueWorker {
"cleanup-retention": 0,
};
private checksRepository: IChecksRepository;
private checkProducer: ICheckProducer;
private checkService: ICheckService;
private checkEvaluator: ICheckEvaluator;
private dispatcher: IReactorDispatcher;
private geoCheckPipeline: ICheckPipeline;
private helper: IWorkerHelper;
private bufferService: IBufferService;
private isDbConnected: () => boolean;
constructor(dependencies: DBQueueWorkerDependencies) {
super(
dependencies.jobsRepository,
dependencies.queueWorkersRepository,
dependencies.monitorsRepository,
dependencies.queueMode,
dependencies.logger,
dependencies.workerId
);
this.processesJobs = dependencies.queuePrimaryProcesses === true || dependencies.queueMode === "worker";
this.checksRepository = dependencies.checksRepository;
this.checkProducer = dependencies.checkProducer;
this.checkService = dependencies.checkService;
this.checkEvaluator = dependencies.checkEvaluator;
this.dispatcher = dependencies.dispatcher;
this.geoCheckPipeline = dependencies.geoCheckPipeline;
this.helper = dependencies.helper;
this.bufferService = dependencies.bufferService;
this.isDbConnected = dependencies.isDbConnected;
constructor(
logger: ILogger, // forwarded to super
private isDbConnected: () => boolean,
jobsRepository: IJobsRepository, // forwarded
monitorsRepository: IMonitorsRepository, // forwarded
private checksRepository: IChecksRepository,
private checkService: ICheckService,
private bufferService: IBufferService,
private checkProducer: ICheckProducer,
private checkEvaluator: ICheckEvaluator,
private geoCheckPipeline: ICheckPipeline,
private dispatcher: IReactorDispatcher,
private helper: IWorkerHelper,
queueWorkersRepository: IQueueWorkersRepository, // forwarded
queueMode: QueueMode, // forwarded
queuePrimaryProcesses: boolean, // consumed immediately
workerId: string
) {
super(jobsRepository, queueWorkersRepository, monitorsRepository, queueMode, logger, workerId);
this.processesJobs = queuePrimaryProcesses === true || queueMode === "worker";
}
static async create(dependencies: DBQueueWorkerDependencies): Promise<DBQueueWorker> {
const instance = new DBQueueWorker(dependencies);
get serviceName() {
return DBQueueWorker.SERVICE_NAME;
}
static async create(
logger: ILogger,
isDbConnected: () => boolean,
jobsRepository: IJobsRepository,
monitorsRepository: IMonitorsRepository,
checksRepository: IChecksRepository,
checkService: ICheckService,
bufferService: IBufferService,
checkProducer: ICheckProducer,
checkEvaluator: ICheckEvaluator,
geoCheckPipeline: ICheckPipeline,
dispatcher: IReactorDispatcher,
helper: IWorkerHelper,
queueWorkersRepository: IQueueWorkersRepository,
queueMode: QueueMode,
queuePrimaryProcesses: boolean,
workerId: string
): Promise<DBQueueWorker> {
const instance = new DBQueueWorker(
logger,
isDbConnected,
jobsRepository,
monitorsRepository,
checksRepository,
checkService,
bufferService,
checkProducer,
checkEvaluator,
geoCheckPipeline,
dispatcher,
helper,
queueWorkersRepository,
queueMode,
queuePrimaryProcesses,
workerId
);
await instance.init();
return instance;
}
+5
View File
@@ -12,6 +12,7 @@ import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { ILogger } from "@/utils/logger.js";
export interface IWorkerHelper {
readonly serviceName: string;
getCleanupOrphanedJob(): () => Promise<void>;
getCleanupRetentionJob(): () => Promise<void>;
}
@@ -68,6 +69,10 @@ export class WorkerHelper implements IWorkerHelper {
this.geoChecksRepository = geoChecksRepository;
}
get serviceName() {
return WorkerHelper.SERVICE_NAME;
}
getCleanupOrphanedJob = () => {
return async () => {
try {
+3 -1
View File
@@ -1,4 +1,4 @@
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.types.js";
import { Check } from "@/domain/checks/check.type.js";
import { JobType } from "@/domain/jobs/job.type.js";
import { MonitorPayloadMap, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js";
@@ -67,6 +67,7 @@ export type WorkerHealth = {
inFlight: number; // total in-flight jobs across types
};
export interface IJobScheduler {
readonly serviceName: string;
addJob(monitorId: string, monitor: Monitor): Promise<void>;
deleteJob(monitor: Monitor): Promise<void>;
pauseJob(monitor: Monitor): Promise<void>;
@@ -82,6 +83,7 @@ export interface IJobScheduler {
}
export interface IQueueWorker extends IJobScheduler {
readonly serviceName: string;
getHealth(): WorkerHealth;
countDueBacklog(): Promise<number>;
countAliveWorkers(): Promise<number>;
+8 -4
View File
@@ -1,16 +1,17 @@
import { IJobScheduler } from "@/worker/worker.interface.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.type.js";
import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { type Job, type JobSeed, type JobType, jobId } from "@/domain/jobs/job.type.js";
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
import { WorkerJobsPagination, WorkerJobSummary, WorkerMetrics } from "@/worker/worker.interface.js";
import { WORKER_STALE_MS } from "@/domain/queue-workers/queue-worker.model.js";
import { WORKER_TTL_SECONDS } from "@/domain/queue-workers/queue-worker.model.js";
import { QueueMode } from "@/domain/app-settings/app-settings.type.js";
import { ILogger } from "@/utils/logger.js";
export const POLL_MS = 250; // base poll interval while a loop is actively claiming work
const POLL_MS = 250; // base poll interval while a loop is actively claiming work
const SERVICE_NAME = "JobScheduler";
const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000; // a worker counts as alive if seen within this window
const HEARTBEAT_MS = WORKER_STALE_MS / 3; // Worker can miss two beats without being considered stale
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -25,6 +26,9 @@ export class JobScheduler implements IJobScheduler {
protected readonly workerId: string // same id MongoJobsRepository stamps into lockedBy
) {}
get serviceName() {
return JobScheduler.SERVICE_NAME;
}
private heartbeatInFlight: Promise<void> = Promise.resolve();
protected processesJobs = false; // scheduler-only by default; DBQueueWorker flips this on when it runs job loops
@@ -187,7 +191,7 @@ export class JobScheduler implements IJobScheduler {
// Seed queue
protected reconcile = async () => {
const now = Date.now();
const monitors = await this.monitorsRepository.findAll();
const monitors = (await this.monitorsRepository.findAll()) ?? [];
for (const monitor of monitors) {
await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now));
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) {
@@ -1,6 +1,6 @@
import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/domain/monitors/monitor.repository.interface.ts";
import type { CheckSnapshot } from "../../src/domain/checks/check.type.ts";
import type { Monitor, MonitorsSummary } from "../../src/domain/monitors/monitor.type.ts";
import type { Monitor, MonitorsSummary } from "../../src/domain/monitors/monitor.types.ts";
export class InMemoryMonitorsRepository implements IMonitorsRepository {
private monitors: Monitor[] = [];
+1 -1
View File
@@ -10,7 +10,7 @@ import { IncidentService } from "../../src/domain/incidents/incident.service.ts"
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
import { createMockLogger } from "./createMockLogger.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.types.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { Check } from "../../src/domain/checks/check.type.ts";
import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@jest/globals";
import type { IStatusProvider } from "../../src/service/network/IStatusProvider.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.types.ts";
/**
* Shared contract tests for all IStatusProvider implementations.
@@ -5,7 +5,7 @@ import { createMockLogger } from "../helpers/createMockLogger.ts";
import type { IMonitorsRepository } from "../../src/domain/monitors/monitor.repository.interface.ts";
import type { IUsersRepository } from "../../src/domain/users/user.repository.interface.ts";
import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.type.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.types.ts";
import type { MonitorActionDecision } from "../../src/worker/worker.helper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
import { describe, expect, it, jest } from "@jest/globals";
import { AdvancedMatcher } from "../../../../src/service/network/AdvancedMatcher.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts";
import { DNSProvider } from "../../../../src/service/network/DNSProvider.ts";
// ── Mocks ────────────────────────────────────────────────────────────────────
@@ -2,8 +2,8 @@ import { describe, expect, it, jest } from "@jest/globals";
import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -2,8 +2,8 @@ import { describe, expect, it, jest } from "@jest/globals";
import { GameProvider } from "../../../../src/service/network/GameProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -1,7 +1,7 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GrpcProvider } from "../../../../src/service/network/GrpcProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more