Merge remote-tracking branch 'origin/develop' into feat/pagespeed-mobile-strat

This commit is contained in:
Alex committed 2026-05-11 11:11:13 -07:00
commit 78fdc548bb
395 files changed
+60597 -29063

No files matched your search

+6
View File
@@ -24,3 +24,9 @@ CLIENT_HOST=http://localhost:5173
# Optional Configuration
# ----------------------
ORIGIN=localhost
# Feature Flags
# -------------
# Set to "false" to disable the selectable theme feature on status pages.
# When disabled, all status pages render with the default "refined" theme.
STATUS_PAGE_THEMES_ENABLED=true
+1 -1
View File
@@ -8,7 +8,7 @@ Checkmate's backend is designed to be lightweight and scalable, ensuring reliabl
## Installation & documentation
For setup instructions, configuration details, and deployment guidelines, please visit our official documentation at [docs.checkmate.so](https://docs.checkmate.so).
For setup instructions, configuration details, and deployment guidelines, please visit our official documentation at [docs.checkmate.so](https://checkmate.so/docs).
## Related repositories
+2 -2
View File
@@ -9,11 +9,11 @@ const config: Config = {
},
moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
"^(\\.{1,2}/.*)\\.js$": "$1",
},
testMatch: ["<rootDir>/test/**/*.test.ts"],
setupFilesAfterEnv: [],
+10406 -4215
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
import { z } from "zod";
export const successEnvelope = <T extends z.ZodTypeAny>(data: T) =>
z.object({
success: z.literal(true),
msg: z.string(),
data,
});
export const successEnvelopeNoData = z.object({
success: z.literal(true),
msg: z.string(),
});
export const errorEnvelope = z.object({
success: z.literal(false),
msg: z.string(),
data: z.unknown().optional(),
});
export const bearer = [{ bearerAuth: [] }];
export const json = <T extends z.ZodTypeAny>(schema: T, example?: unknown) => ({
"application/json": example === undefined ? { schema } : { schema, example },
});
export const standardErrors = {
"401": { description: "Unauthorized", content: json(errorEnvelope) },
"403": { description: "Forbidden", content: json(errorEnvelope) },
"500": { description: "Internal server error", content: json(errorEnvelope) },
};
export const okJson = <T extends z.ZodTypeAny>(data: T, description = "OK", example?: unknown) => ({
description,
content: json(successEnvelope(data), example === undefined ? undefined : { success: true, msg: "OK", data: example }),
});
export const okJsonNoData = (description = "OK") => ({
description,
content: json(successEnvelopeNoData, { success: true, msg: "OK" }),
});
export const okUnknown = okJson(z.unknown());
export const multipart = (fields: Record<string, z.ZodTypeAny>, fileField?: string) => {
const shape: Record<string, z.ZodTypeAny> = { ...fields };
if (fileField) {
shape[fileField] = z.string().openapi({ type: "string", format: "binary" });
}
return {
"multipart/form-data": { schema: z.object(shape) },
};
};
+56
View File
@@ -0,0 +1,56 @@
import "./registry.js";
import "./routes/auth.js";
import "./routes/check.js";
import "./routes/diagnostic.js";
import "./routes/geoCheck.js";
import "./routes/incident.js";
import "./routes/invite.js";
import "./routes/log.js";
import "./routes/maintenanceWindow.js";
import "./routes/monitor.js";
import "./routes/notification.js";
import "./routes/queue.js";
import "./routes/settings.js";
import "./routes/statusPage.js";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import type { JsonObject } from "swagger-ui-express";
import { registry } from "./registry.js";
let cached: JsonObject | null = null;
export function getOpenApiSpec(): JsonObject {
if (cached) return cached;
const generator = new OpenApiGeneratorV3(registry.definitions);
const document = generator.generateDocument({
openapi: "3.0.0",
info: {
title: "Checkmate API",
version: "1.0.0",
description: "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts",
},
servers: [{ url: "/api/v1", description: "Current server" }],
tags: [
{ name: "auth", description: "Sign up, log in, password reset, and the authenticated user's profile." },
{ name: "monitors", description: "Configure and inspect monitors (HTTP, port, ping, hardware, docker, game, gRPC) for the caller's team." },
{ name: "checks", description: "Individual monitoring check results, including history, summaries, and bulk deletes." },
{ name: "geo-checks", description: "Geographic check results for monitors with multi-region probing enabled." },
{ name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." },
{
name: "notifications",
description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.",
},
{ name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." },
{ name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." },
{ name: "settings", description: "Global application settings (admin/superadmin)." },
{ name: "invite", description: "Team invitations and accepting them." },
{ name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." },
{ name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." },
{ name: "logs", description: "Application logs for the running server (admin/superadmin)." },
],
});
cached = document as unknown as JsonObject;
return cached;
}
+12
View File
@@ -0,0 +1,12 @@
import { OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export const registry = new OpenAPIRegistry();
registry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
+207
View File
@@ -0,0 +1,207 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, json, multipart, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import {
loginValidation,
registrationBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
} from "@/validation/authValidation.js";
import {
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
createUserBodyValidation,
editUserBodyValidation,
userResponseSchema,
} from "@/validation/userValidation.js";
const tags = ["auth"];
const userExample = {
_id: "65f1c2a4d8b9e0123456789a",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.com",
role: ["admin"],
teamId: "65f1c2a4d8b9e01234567890",
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-15T14:30:00.000Z",
};
const userObject = userResponseSchema.openapi("User", { example: userExample });
const authPayload = z
.object({
user: userObject,
token: z.string().optional(),
})
.openapi("AuthPayload", {
example: {
user: userExample,
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
},
});
registry.registerPath({
method: "post",
path: "/auth/register",
tags,
summary: "Register a new user (first user becomes superadmin)",
request: { body: { content: multipart(registrationBodyValidation.shape, "profileImage") } },
responses: { "200": okJson(authPayload), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/auth/login",
tags,
summary: "Log in",
request: {
body: {
content: json(loginValidation, { email: "ada@example.com", password: "S3cure!Passw0rd" }),
},
},
responses: {
"200": okJson(authPayload, "OK", {
user: {
_id: "65f1c2a4d8b9e0123456789a",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.com",
role: ["admin"],
teamId: "65f1c2a4d8b9e01234567890",
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-15T14:30:00.000Z",
},
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
}),
"401": { description: "Invalid credentials", content: json(z.object({ success: z.literal(false), msg: z.string() })) },
"500": standardErrors["500"],
},
});
registry.registerPath({
method: "post",
path: "/auth/recovery/request",
tags,
summary: "Request a password recovery email",
request: { body: { content: json(recoveryValidation, { email: "ada@example.com" }) } },
responses: { "200": okJsonNoData(), "500": standardErrors["500"] },
});
registry.registerPath({
method: "post",
path: "/auth/recovery/validate",
tags,
summary: "Validate a password recovery token",
request: { body: { content: json(recoveryTokenBodyValidation) } },
responses: { "200": okJsonNoData(), "500": standardErrors["500"] },
});
registry.registerPath({
method: "post",
path: "/auth/recovery/reset",
tags,
summary: "Reset password using a recovery token",
request: { body: { content: json(newPasswordValidation) } },
responses: { "200": okJsonNoData(), "500": standardErrors["500"] },
});
registry.registerPath({
method: "get",
path: "/auth/users/superadmin",
tags,
summary: "Check whether a superadmin user exists",
responses: { "200": okJson(z.object({ superAdminExists: z.boolean() })), "500": standardErrors["500"] },
});
registry.registerPath({
method: "get",
path: "/auth/users",
tags,
summary: "List all users (admin/superadmin)",
security: bearer,
responses: { "200": okJson(z.array(userObject)), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/auth/users",
tags,
summary: "Create a new user (superadmin)",
security: bearer,
request: { body: { content: multipart(createUserBodyValidation.shape, "profileImage") } },
responses: { "201": okJson(userObject, "User created"), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/auth/users/{userId}",
tags,
summary: "Get a user by id",
security: bearer,
request: { params: getUserByIdParamValidation },
responses: {
"200": okJson(userObject),
"404": { description: "User not found", content: json(z.object({ success: z.literal(false), msg: z.string() })) },
...standardErrors,
},
});
registry.registerPath({
method: "patch",
path: "/auth/users/{userId}",
tags,
summary: "Edit a user (superadmin)",
security: bearer,
request: {
params: editUserByIdParamValidation,
body: { content: json(editUserByIdBodyValidation) },
},
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/auth/users/{userId}/password",
tags,
summary: "Change a user's password (superadmin)",
security: bearer,
request: {
params: editUserByIdParamValidation,
body: { content: json(editUserPasswordByIdBodyValidation) },
},
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/auth/users/{userId}",
tags,
summary: "Delete a user (admin/superadmin)",
security: bearer,
request: { params: getUserByIdParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/auth/user",
tags,
summary: "Edit the currently authenticated user",
security: bearer,
request: { body: { content: multipart(editUserBodyValidation.shape, "profileImage") } },
responses: { "200": okJson(userObject), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/auth/user",
tags,
summary: "Delete the currently authenticated user",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
+61
View File
@@ -0,0 +1,61 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
getChecksParamValidation,
getChecksQueryValidation,
getTeamChecksQueryValidation,
deleteChecksParamValidation,
getChecksSummaryByTeamIdQueryValidation,
} from "@/validation/checkValidation.js";
const tags = ["checks"];
registry.registerPath({
method: "get",
path: "/checks/team/summary",
tags,
summary: "Aggregate check summary for the caller's team",
security: bearer,
request: { query: getChecksSummaryByTeamIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/checks/team",
tags,
summary: "List checks across the team",
security: bearer,
request: { query: getTeamChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/checks/team",
tags,
summary: "Delete all checks for the team (admin/superadmin)",
security: bearer,
responses: { "200": okJson(z.object({ deletedCount: z.number() })), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/checks/{monitorId}",
tags,
summary: "List checks for a monitor",
security: bearer,
request: { params: getChecksParamValidation, query: getChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/checks/{monitorId}",
tags,
summary: "Delete checks for a monitor",
security: bearer,
request: { params: deleteChecksParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
+11
View File
@@ -0,0 +1,11 @@
import { registry } from "../registry.js";
import { bearer, okUnknown, standardErrors } from "../helpers.js";
registry.registerPath({
method: "get",
path: "/diagnostic/system",
tags: ["diagnostic"],
summary: "Get system diagnostics (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
+13
View File
@@ -0,0 +1,13 @@
import { registry } from "../registry.js";
import { bearer, okUnknown, standardErrors } from "../helpers.js";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
registry.registerPath({
method: "get",
path: "/geo-checks/{monitorId}",
tags: ["geo-checks"],
summary: "Get geo check results for a monitor",
security: bearer,
request: { params: getChecksParamValidation, query: getChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
+104
View File
@@ -0,0 +1,104 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
const tags = ["incidents"];
const incidentIdParam = z.object({ incidentId: z.string() });
const incidentObject = z
.object({
id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }),
monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }),
teamId: z.string().openapi({ example: "65f1c2a4d8b9e01234567890" }),
startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }),
status: z.boolean().openapi({ example: true, description: "true = resolved, false = ongoing" }),
message: z.string().nullable().optional().openapi({ example: "HTTP 503 from origin" }),
statusCode: z.number().nullable().optional().openapi({ example: 503 }),
resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }),
resolvedBy: z.string().nullable().optional(),
resolvedByEmail: z.string().nullable().optional(),
comment: z.string().nullable().optional(),
createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
updatedAt: z.string().openapi({ example: "2026-04-15T03:34:00.000Z" }),
})
.passthrough()
.openapi("Incident");
const incidentSummaryItem = z
.object({
id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }),
monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }),
monitorName: z.string().nullable().openapi({ example: "Marketing site" }),
status: z.boolean().openapi({ example: true }),
startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }),
resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }),
message: z.string().nullable().openapi({ example: "HTTP 503 from origin" }),
statusCode: z.number().nullable().openapi({ example: 503 }),
createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
})
.openapi("IncidentSummaryItem");
const incidentSummary = z
.object({
total: z.number().openapi({ example: 12 }),
totalActive: z.number().openapi({ example: 1 }),
totalManualResolutions: z.number().openapi({ example: 2 }),
totalAutomaticResolutions: z.number().openapi({ example: 9 }),
avgResolutionTimeHours: z.number().openapi({ example: 0.5 }),
topMonitor: z
.object({
monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }),
monitorName: z.string().nullable().openapi({ example: "Marketing site" }),
incidentCount: z.number().openapi({ example: 4 }),
})
.nullable(),
latestIncidents: z.array(incidentSummaryItem),
})
.openapi("IncidentSummary");
registry.registerPath({
method: "get",
path: "/incidents/team",
tags,
summary: "List incidents for the caller's team",
security: bearer,
request: { query: getIncidentsByTeamQueryValidation },
responses: { "200": okJson(z.array(incidentObject)), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/incidents/team/summary",
tags,
summary: "Incident summary for the caller's team",
security: bearer,
request: { query: getIncidentSummaryQueryValidation },
responses: { "200": okJson(incidentSummary), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/incidents/{incidentId}",
tags,
summary: "Get an incident by id",
security: bearer,
request: { params: incidentIdParam },
responses: {
"200": okJson(z.object({ incident: incidentObject, monitor: z.unknown(), user: z.unknown().nullable() })),
...standardErrors,
},
});
registry.registerPath({
method: "put",
path: "/incidents/{incidentId}/resolve",
tags,
summary: "Manually resolve an incident (admin/superadmin)",
security: bearer,
request: { params: incidentIdParam },
responses: { "200": okJsonNoData(), ...standardErrors },
});
+35
View File
@@ -0,0 +1,35 @@
import { registry } from "../registry.js";
import { bearer, json, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import { z } from "zod";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
const tags = ["invite"];
registry.registerPath({
method: "post",
path: "/invite/send",
tags,
summary: "Send an invite email (admin/superadmin)",
security: bearer,
request: { body: { content: json(inviteBodyValidation) } },
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/invite/verify",
tags,
summary: "Verify an invite token",
request: { body: { content: json(inviteVerificationBodyValidation) } },
responses: { "200": okJson(z.object({ email: z.string(), role: z.array(z.string()).optional() }).passthrough()), "500": standardErrors["500"] },
});
registry.registerPath({
method: "post",
path: "/invite",
tags,
summary: "Create an invite token (admin/superadmin)",
security: bearer,
request: { body: { content: json(inviteBodyValidation) } },
responses: { "200": okJson(z.object({ token: z.string() }).passthrough()), ...standardErrors },
});
+11
View File
@@ -0,0 +1,11 @@
import { registry } from "../registry.js";
import { bearer, okUnknown, standardErrors } from "../helpers.js";
registry.registerPath({
method: "get",
path: "/logs",
tags: ["logs"],
summary: "Get application logs (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
@@ -0,0 +1,73 @@
import { registry } from "../registry.js";
import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
createMaintenanceWindowBodyValidation,
getMaintenanceWindowByIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
getMaintenanceWindowsByMonitorIdParamValidation,
deleteMaintenanceWindowByIdParamValidation,
editMaintenanceWindowByIdParamValidation,
editMaintenanceByIdWindowBodyValidation,
} from "@/validation/maintenanceWindowValidation.js";
const tags = ["maintenance-window"];
registry.registerPath({
method: "post",
path: "/maintenance-window",
tags,
summary: "Create one or more maintenance windows",
security: bearer,
request: { body: { content: json(createMaintenanceWindowBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/maintenance-window/team",
tags,
summary: "List maintenance windows for the caller's team",
security: bearer,
request: { query: getMaintenanceWindowsByTeamIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/maintenance-window/monitor/{monitorId}",
tags,
summary: "List maintenance windows for a monitor",
security: bearer,
request: { params: getMaintenanceWindowsByMonitorIdParamValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/maintenance-window/{id}",
tags,
summary: "Get a maintenance window by id",
security: bearer,
request: { params: getMaintenanceWindowByIdParamValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/maintenance-window/{id}",
tags,
summary: "Edit a maintenance window",
security: bearer,
request: { params: editMaintenanceWindowByIdParamValidation, body: { content: json(editMaintenanceByIdWindowBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/maintenance-window/{id}",
tags,
summary: "Delete a maintenance window",
security: bearer,
request: { params: deleteMaintenanceWindowByIdParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
+238
View File
@@ -0,0 +1,238 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
getCertificateParamValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
monitorResponseSchema,
} from "@/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/validation/notificationValidation.js";
const tags = ["monitors"];
const monitorObject = monitorResponseSchema.openapi("Monitor", {
example: {
_id: "65f1c2a4d8b9e0123456789a",
name: "Marketing site",
description: "Production marketing site monitored from the EU region",
type: "http",
url: "https://www.example.com",
port: 443,
isActive: true,
interval: 60000,
status: "up",
statusWindowSize: 5,
statusWindowThreshold: 3,
ignoreTlsErrors: false,
useAdvancedMatching: false,
notifications: ["65f1c2a4d8b9e0123456789b"],
cpuAlertThreshold: 90,
memoryAlertThreshold: 90,
diskAlertThreshold: 90,
tempAlertThreshold: 80,
selectedDisks: [],
geoCheckEnabled: false,
geoCheckLocations: [],
geoCheckInterval: 300000,
teamId: "65f1c2a4d8b9e01234567890",
userId: "65f1c2a4d8b9e01234567891",
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-15T14:30:00.000Z",
},
});
registry.registerPath({
method: "get",
path: "/monitors/team",
tags,
summary: "List monitors for the caller's team",
security: bearer,
request: { query: getMonitorsByTeamIdQueryValidation },
responses: { "200": okJson(z.array(monitorObject)), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/team/with-checks",
tags,
summary: "List team monitors with their most recent checks (paginated)",
security: bearer,
request: { query: getMonitorsWithChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/team/groups",
tags,
summary: "List monitor groups for the caller's team",
security: bearer,
responses: { "200": okJson(z.array(z.string())), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/uptime/details/{monitorId}",
tags,
summary: "Get uptime details for a monitor",
security: bearer,
request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/hardware/details/{monitorId}",
tags,
summary: "Get hardware metrics detail for a monitor",
security: bearer,
request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/pagespeed/details/{monitorId}",
tags,
summary: "Get PageSpeed detail for a monitor",
security: bearer,
request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/{monitorId}/geo-checks",
tags,
summary: "Get geo check results for a monitor",
security: bearer,
request: { params: getMonitorByIdParamValidation, query: getMonitorByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors/pause/{monitorId}",
tags,
summary: "Toggle pause state for a monitor",
security: bearer,
request: { params: pauseMonitorParamValidation },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/certificate/{monitorId}",
tags,
summary: "Get SSL certificate info for a monitor",
security: bearer,
request: { params: getCertificateParamValidation },
responses: { "200": okJson(z.object({ certificateDate: z.string() })), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/monitors/notifications",
tags,
summary: "Bulk update notifications across monitors",
security: bearer,
request: { body: { content: json(updateNotificationsValidation) } },
responses: { "200": okJson(z.object({ modifiedCount: z.number() })), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors",
tags,
summary: "Create a new monitor",
security: bearer,
request: { body: { content: json(createMonitorBodyValidation) } },
responses: { "200": okJson(monitorObject, "Monitor created"), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/monitors",
tags,
summary: "Delete every monitor (superadmin only)",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors/demo",
tags,
summary: "Insert preconfigured demo monitors",
security: bearer,
responses: { "200": okJson(z.number()), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/export/json",
tags,
summary: "Export all team monitors as JSON",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors/import/json",
tags,
summary: "Import monitors from JSON",
security: bearer,
request: { body: { content: json(importMonitorsBodyValidation) } },
responses: { "200": okJson(z.object({ imported: z.number() }).passthrough()), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/games",
tags,
summary: "List supported game-server types",
security: bearer,
responses: { "200": okJson(z.array(z.string())), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/{monitorId}",
tags,
summary: "Get a monitor by id",
security: bearer,
request: { params: getMonitorByIdParamValidation },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/monitors/{monitorId}",
tags,
summary: "Edit a monitor",
security: bearer,
request: { params: getMonitorByIdParamValidation, body: { content: json(editMonitorBodyValidation) } },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/monitors/{monitorId}",
tags,
summary: "Delete a monitor",
security: bearer,
request: { params: getMonitorByIdParamValidation },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
+155
View File
@@ -0,0 +1,155 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
createNotificationBodyValidation,
deleteNotificationParamValidation,
getNotificationByIdParamValidation,
editNotificationParamValidation,
testAllNotificationsBodyValidation,
} from "@/validation/notificationValidation.js";
const tags = ["notifications"];
// OpenAPI metadata for each notification variant. Keyed by the discriminator
// value in createNotificationBodyValidation; build fails loudly if a variant
// in the validator has no entry here, which doubles as drift detection.
const notificationVariantMeta: Record<string, { component: string; example: Record<string, unknown> }> = {
email: {
component: "EmailNotification",
example: { notificationName: "Ops on-call email", type: "email", address: "alerts@example.com" },
},
webhook: {
component: "WebhookNotification",
example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" },
},
slack: {
component: "SlackNotification",
example: { notificationName: "#alerts", type: "slack", address: "https://hooks.slack.com/services/T000/B000/XXXX" },
},
discord: {
component: "DiscordNotification",
example: { notificationName: "#status", type: "discord", address: "https://discord.com/api/webhooks/123/abc" },
},
pager_duty: {
component: "PagerDutyNotification",
example: { notificationName: "PagerDuty primary", type: "pager_duty", address: "R01XXXXXXXXXXXXXXXXXXXXXXX" },
},
matrix: {
component: "MatrixNotification",
example: {
notificationName: "Matrix room",
type: "matrix",
homeserverUrl: "https://matrix.example.com",
roomId: "!abc123:example.com",
accessToken: "syt_xxx",
},
},
teams: {
component: "TeamsNotification",
example: { notificationName: "Teams ops channel", type: "teams", address: "https://outlook.office.com/webhook/..." },
},
telegram: {
component: "TelegramNotification",
example: { notificationName: "Telegram bot", type: "telegram", address: "-1001234567890", accessToken: "123456:ABC-DEF" },
},
pushover: {
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
twilio: {
component: "TwilioNotification",
example: {
notificationName: "Twilio SMS",
type: "twilio",
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
accessToken: "your-auth-token",
phone: "+15551234567",
twilioPhoneNumber: "+15557654321",
},
},
};
const decoratedVariants = createNotificationBodyValidation.options.map((variant) => {
const typeField = variant.shape.type as z.ZodLiteral<string>;
const type = typeField.value;
const meta = notificationVariantMeta[type];
if (!meta) {
throw new Error(`Missing OpenAPI metadata for notification variant "${type}". Add an entry in notificationVariantMeta.`);
}
return variant.openapi(meta.component, { example: meta.example });
});
const notificationBody = z
.discriminatedUnion("type", decoratedVariants as typeof createNotificationBodyValidation.options)
.openapi("NotificationChannelBody");
registry.registerPath({
method: "post",
path: "/notifications",
tags,
summary: "Create a notification channel",
description: "Create a notification channel of any supported type. The `type` field discriminates the body shape.",
security: bearer,
request: { body: { content: json(notificationBody) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/notifications/test/all",
tags,
summary: "Send a test alert through every notification channel for the team",
security: bearer,
request: { body: { content: json(testAllNotificationsBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/notifications/test",
tags,
summary: "Send a test alert through a single notification channel",
security: bearer,
request: { body: { content: json(notificationBody) } },
responses: { "200": okJson(z.object({ success: z.boolean() }).passthrough()), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/notifications/team",
tags,
summary: "List notification channels for the caller's team",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/notifications/{id}",
tags,
summary: "Get a notification channel by id",
security: bearer,
request: { params: getNotificationByIdParamValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/notifications/{id}",
tags,
summary: "Delete a notification channel",
security: bearer,
request: { params: deleteNotificationParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/notifications/{id}",
tags,
summary: "Edit a notification channel",
security: bearer,
request: { params: editNotificationParamValidation, body: { content: json(notificationBody) } },
responses: { "200": okUnknown, ...standardErrors },
});
+40
View File
@@ -0,0 +1,40 @@
import { registry } from "../registry.js";
import { bearer, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
const tags = ["queue"];
registry.registerPath({
method: "get",
path: "/queue/jobs",
tags,
summary: "List queued monitor jobs (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/queue/metrics",
tags,
summary: "Get queue runtime metrics (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/queue/all-metrics",
tags,
summary: "Get queue metrics across all monitors (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/queue/flush",
tags,
summary: "Flush the monitor job queue (admin/superadmin)",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
+35
View File
@@ -0,0 +1,35 @@
import { registry } from "../registry.js";
import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
const tags = ["settings"];
registry.registerPath({
method: "get",
path: "/settings",
tags,
summary: "Get application settings",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/settings",
tags,
summary: "Update application settings (admin/superadmin)",
security: bearer,
request: { body: { content: json(updateAppSettingsBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/settings/test-email",
tags,
summary: "Send a test email using current SMTP settings (admin/superadmin)",
security: bearer,
request: { body: { content: json(sendTestEmailBodyValidation) } },
responses: { "200": okJsonNoData(), ...standardErrors },
});
+52
View File
@@ -0,0 +1,52 @@
import { registry } from "../registry.js";
import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/validation/statusPageValidation.js";
const tags = ["status-page"];
registry.registerPath({
method: "get",
path: "/status-page/team",
tags,
summary: "List status pages for the caller's team",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/status-page",
tags,
summary: "Create a status page (logo upload optional)",
security: bearer,
request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "put",
path: "/status-page/{id}",
tags,
summary: "Update a status page (logo upload optional)",
security: bearer,
request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/status-page/{url}",
tags,
summary: "Get a public status page by its URL slug",
request: { params: getStatusPageParamValidation, query: getStatusPageQueryValidation },
responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] },
});
registry.registerPath({
method: "delete",
path: "/status-page/{id}",
tags,
summary: "Delete a status page",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
+162 -535
View File
File diff suppressed because it is too large. Load diff
+13 -11
View File
@@ -5,10 +5,15 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
"clean": "rm -rf dist",
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates",
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
"format": "prettier --write .",
@@ -27,9 +32,8 @@
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.7.2",
"axios": "^1.15.2",
"bcryptjs": "3.0.2",
"bullmq": "5.41.2",
"cacheable-lookup": "7.0.0",
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
@@ -37,7 +41,7 @@
"dockerode": "4.0.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"express-rate-limit": "8.3.2",
"gamedig": "5.2.0",
"got": "14.4.7",
"handlebars": "^4.7.8",
@@ -50,19 +54,19 @@
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.1",
"pg": "8.20.0",
"multer": "^1.4.5-lts.2",
"nodemailer": "8.0.5",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@eslint/js": "^9.17.0",
"@types/compression": "1.8.1",
"@types/cookie-parser": "1.4.10",
@@ -78,13 +82,11 @@
"@types/multer": "^2.0.0",
"@types/nodemailer": "7.0.1",
"@types/papaparse": "^5.5.2",
"@types/pg": "8.20.0",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
+10
View File
@@ -0,0 +1,10 @@
import { writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getOpenApiSpec } from "../openapi/index.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outPath = path.resolve(__dirname, "..", "openapi.json");
writeFileSync(outPath, JSON.stringify(getOpenApiSpec(), null, 2) + "\n");
console.log(`OpenAPI spec written to ${outPath}`);
+12 -35
View File
@@ -29,6 +29,8 @@ import {
MatrixProvider,
TeamsProvider,
TelegramProvider,
PushoverProvider,
TwilioProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -62,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js";
// Third-party
import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got";
import ping from "ping";
@@ -98,19 +102,6 @@ import {
MongoTeamsRepository,
MongoMaintenanceWindowsRepository,
MongoSettingsRepository,
TimescaleMonitorsRepository,
TimescaleChecksRepository,
TimescaleGeoChecksRepository,
TimescaleMonitorStatsRepository,
TimescaleStatusPagesRepository,
TimescaleUsersRepository,
TimescaleInvitesRepository,
TimescaleRecoveryTokensRepository,
TimescaleNotificationsRepository,
TimescaleIncidentsRepository,
TimescaleTeamsRepository,
TimescaleMaintenanceWindowsRepository,
TimescaleSettingsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
@@ -126,7 +117,6 @@ import {
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import TimescaleDB from "@/db/TimescaleDB.js";
import { AppError } from "@/utils/AppError.js";
export type InitializedServices = {
@@ -183,8 +173,6 @@ export const initializeServices = async ({
if (dbType === "mongodb") {
db = new MongoDB(logger, envSettings);
} else if (dbType === "timescaledb") {
db = new TimescaleDB(logger, envSettings);
}
if (!db) {
@@ -224,23 +212,7 @@ export const initializeServices = async ({
teamsRepository = new MongoTeamsRepository();
maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
} else {
const pool = db.getPool();
if (!pool) {
throw new Error("Failed to get database pool");
}
monitorsRepository = new TimescaleMonitorsRepository(pool);
checksRepository = new TimescaleChecksRepository(pool);
geoChecksRepository = new TimescaleGeoChecksRepository(pool);
monitorStatsRepository = new TimescaleMonitorStatsRepository(pool);
statusPagesRepository = new TimescaleStatusPagesRepository(pool);
usersRepository = new TimescaleUsersRepository(pool);
invitesRepository = new TimescaleInvitesRepository(pool);
recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool);
settingsRepository = new TimescaleSettingsRepository(pool);
notificationsRepository = new TimescaleNotificationsRepository(pool);
incidentsRepository = new TimescaleIncidentsRepository(pool);
teamsRepository = new TimescaleTeamsRepository(pool);
maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool);
throw new AppError({ message: "Unsupported database type", status: 500 });
}
// Inject settings repository into settings service (now that DB is connected)
@@ -256,6 +228,7 @@ export const initializeServices = async ({
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [
pingProvider,
@@ -267,6 +240,7 @@ export const initializeServices = async ({
gameProvider,
grpcProvider,
webSocketProvider,
dnsProvider,
]);
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
@@ -298,6 +272,8 @@ export const initializeServices = async ({
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -310,6 +286,8 @@ export const initializeServices = async ({
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
settingsService,
logger,
notificationMessageBuilder
@@ -364,7 +342,6 @@ export const initializeServices = async ({
});
const monitorService = new MonitorService({
jobQueue: superSimpleQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -375,7 +352,7 @@ export const initializeServices = async ({
incidentsRepository,
});
const statusPageService = new StatusPageService(statusPagesRepository);
const statusPageService = new StatusPageService(statusPagesRepository, settingsService);
const services = {
settingsService,
@@ -29,6 +29,14 @@ export const optionalString = (value: unknown, fieldName: string): string | unde
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
export const extractString = (value: unknown): string | undefined => {
const candidate = Array.isArray(value) ? value[0] : value;
if (typeof candidate !== "string" || candidate.length === 0) {
return undefined;
}
return candidate;
};
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
+4 -3
View File
@@ -1,6 +1,6 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js";
import { requireTeamId, requireUserId, requireUserEmail, extractString } from "./controllerUtils.js";
import { IIncidentService } from "@/service/index.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
@@ -85,12 +85,13 @@ class IncidentController implements IIncidentController {
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const userEmail = requireUserEmail(req.user?.email);
const incidentId = req.params?.incidentId;
const incidentId = extractString(req.params?.incidentId);
if (!incidentId) {
throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 });
}
const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, req?.body?.comment, userEmail);
const comment = extractString(req.body?.comment);
const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, comment, userEmail);
return res.status(200).json({
success: true,
+29 -1
View File
@@ -14,12 +14,12 @@ import {
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
bulkPauseMonitorBodyValidation,
} from "@/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService, INotificationsService } from "@/service/index.js";
import { GeoContinent } from "@/types/geoCheck.js";
const SERVICE_NAME = "monitorController";
@@ -36,6 +36,7 @@ export interface IMonitorController {
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
@@ -307,6 +308,33 @@ class MonitorController implements IMonitorController {
}
};
bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body);
const { monitorIds, pause } = validatedBody;
const teamId = requireTeamId(req.user?.teamId);
const { monitors, failedCount } = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause });
const action = pause ? "paused" : "resumed";
const monitorStr = monitors.length === 1 ? "monitor" : "monitors";
let msg = `${monitors.length} ${monitorStr} ${action} successfully`;
if (failedCount > 0) {
msg = `${monitors.length} ${monitorStr} ${action} in database, but ${failedCount} failed to sync with the job queue. Please check logs.`;
}
return res.status(200).json({
success: true,
msg,
data: monitors,
});
} catch (error) {
next(error);
}
};
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = requireUserId(req.user?.id);
@@ -37,13 +37,9 @@ class NotificationController implements INotificationController {
const notification = testNotificationBodyValidation.parse(req.body);
const success = await this.notificationsService.sendTestNotification(notification);
if (!success) {
throw new AppError({ message: "Sending notification failed", status: 500 });
}
return res.status(200).json({
success: true,
msg: "Notification sent successfully",
success,
msg: success ? "Notification sent successfully" : "Notification could not be sent — check the destination details.",
details: { service: SERVICE_NAME },
});
} catch (error) {
@@ -89,6 +89,7 @@ class SettingsController implements ISettingsController {
systemEmailHost,
systemEmailPort,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailUser,
systemEmailConnectionHost,
@@ -112,6 +113,7 @@ class SettingsController implements ISettingsController {
systemEmailPort,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailSecure,
@@ -66,6 +66,7 @@ class StatusPageController implements IStatusPageController {
if (req.file) {
imageValidation.parse(req.file);
}
const teamId = requireTeamId(req?.user?.teamId);
const statusPageId = req.params.id as string;
if (!statusPageId) {
@@ -127,7 +128,10 @@ class StatusPageController implements IStatusPageController {
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data: { statusPage, monitors: normalizedMonitors },
data: {
statusPage,
monitors: normalizedMonitors,
},
});
} catch (error) {
next(error);
@@ -0,0 +1,33 @@
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
/**
* When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming
* theme fields before persisting so an admin client can't accidentally store
* a value the public page won't honour. Whatever a caller sends here for
* `theme` / `themeMode` is silently ignored — the rest of the body is
* preserved verbatim.
*
* The strip is silent (not a 400) because the kill switch is an ops-side
* concern: API clients should not have to know whether the operator has
* disabled themes today, only that their saved value won't take effect.
* If we later decide we'd rather fail loudly, change this to throw a
* 400 AppError; downstream consumers already handle it.
*/
export const withoutThemeFields = (body: Record<string, unknown>): Record<string, unknown> => {
const rest = { ...body };
delete rest.theme;
delete rest.themeMode;
return rest;
};
/**
* When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page
* should see the canonical default theme / mode regardless of what's stored
* in Mongo. Whatever the user previously chose stays in the DB so flipping
* the flag back on resurfaces their selection.
*/
export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
...statusPage,
theme: DEFAULT_STATUS_PAGE_THEME,
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
});
-3
View File
@@ -1,7 +1,4 @@
import { Pool } from "pg";
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
getPool(): Pool | null;
}
+6 -7
View File
@@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
const SERVICE_NAME = "MongoDB";
import { IDb } from "@/db/IDb.js";
import { Pool } from "pg";
class MongoDB implements IDb {
static SERVICE_NAME = SERVICE_NAME;
@@ -35,6 +34,12 @@ class MongoDB implements IDb {
setDefaultsOnInsert: true,
}
);
// Run migrations BEFORE syncIndexes. The 0006 dedupe migration must run before
// the unique index on MonitorStats.monitorId is enforced, otherwise databases
// with stray duplicates (caused by the pre-fix race in updateRunningStats)
// will crash at startup when the unique index fails to build.
await runMigrations();
// Sync indexes
const models = mongoose.modelNames();
for (const modelName of models) {
@@ -47,8 +52,6 @@ class MongoDB implements IDb {
service: SERVICE_NAME,
method: "connect",
});
await runMigrations();
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -75,10 +78,6 @@ class MongoDB implements IDb {
});
}
};
getPool = () => {
return null;
};
}
export default MongoDB;
-105
View File
@@ -1,105 +0,0 @@
import { IDb } from "@/db/IDb.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import { Client, Pool } from "pg";
import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js";
const SERVICE_NAME = "TimescaleDB";
class TimescaleDB implements IDb {
private pool: Pool | null = null;
constructor(
private logger: ILogger,
private envSettings: EnvConfig
) {}
private ensureDatabaseExists = async (connectionString: string) => {
const url = new URL(connectionString);
const dbName = url.pathname.slice(1); // Remove leading "/"
// Connect to the default "postgres" database
url.pathname = "/postgres";
const client = new Client({ connectionString: url.toString() });
await client.connect();
const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
if (result.rowCount === 0) {
await client.query(`CREATE DATABASE "${dbName}"`);
this.logger.info({
message: `Created database "${dbName}"`,
service: SERVICE_NAME,
method: "ensureDatabaseExists",
});
}
await client.end();
};
connect = async () => {
try {
const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate";
await this.ensureDatabaseExists(connectionString);
this.pool = new Pool({ connectionString });
const client = await this.pool.connect();
client.release();
// Ensure TimescaleDB extension is enabled
await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;");
this.logger.info({
message: "Connected to TimescaleDB",
service: SERVICE_NAME,
method: "connect",
});
await runTimescaleDBMigrations(this.pool, this.logger);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "connect",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
disconnect = async () => {
try {
if (this.pool === null) {
return;
}
this.logger.info({
message: "Disconnecting from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
await this.pool.end();
this.pool = null;
this.logger.info({
message: "Disconnected from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "disconnect",
stack: error instanceof Error ? error.stack : undefined,
});
}
};
getPool = (): Pool => {
if (this.pool === null) {
throw new Error("TimescaleDB is not connected. Call connect() first.");
}
return this.pool;
};
}
export default TimescaleDB;
@@ -0,0 +1,78 @@
import { MonitorStatsModel } from "../models/MonitorStats.js";
import { logger } from "@/utils/logger.js";
/**
* Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced.
*
* 0003 ran the same dedupe, but the pre-fix race in StatusService.updateRunningStats
* could create new duplicates after 0003 completed. This migration re-runs the dedupe so
* the unique index added in the same PR can be built without failing.
*
* Keeps the document with the highest totalChecks for each monitorId (most data) and
* deletes the rest. Falls back to most recent updatedAt when totalChecks tie.
*/
export async function cleanupDuplicateMonitorStatsForUniqueIndex(): Promise<void> {
const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex";
try {
logger.info({ service: SERVICE_NAME, message: "Starting pre-unique-index dedupe of MonitorStats" });
const duplicates = await MonitorStatsModel.aggregate([
{
$group: {
_id: "$monitorId",
docs: { $push: { id: "$_id", totalChecks: "$totalChecks", updatedAt: "$updatedAt" } },
count: { $sum: 1 },
},
},
{ $match: { count: { $gt: 1 } } },
]);
if (duplicates.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Found ${duplicates.length} monitors with duplicate stats`,
});
let totalDeleted = 0;
type DocInfo = { id: string; totalChecks: number; updatedAt: Date };
for (const duplicate of duplicates) {
const monitorId = duplicate._id;
const docs: DocInfo[] = duplicate.docs;
// Sort by totalChecks desc, then updatedAt desc — keep the winner, delete the rest.
docs.sort((a, b) => {
const checksDiff = (b.totalChecks ?? 0) - (a.totalChecks ?? 0);
if (checksDiff !== 0) return checksDiff;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
const toDelete = docs.slice(1).map((doc) => doc.id);
if (toDelete.length > 0) {
const result = await MonitorStatsModel.deleteMany({ _id: { $in: toDelete } });
totalDeleted += result.deletedCount ?? 0;
logger.debug({
service: SERVICE_NAME,
message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`,
});
}
}
logger.info({
service: SERVICE_NAME,
message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats dedupe: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -4,6 +4,7 @@ import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStat
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -17,6 +18,7 @@ const migrations: MigrationEntry[] = [
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
];
const runMigrations = async (logger?: ILogger) => {
@@ -1,50 +0,0 @@
import type { Pool } from "pg";
export const createEnums = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
};
export const dropEnums = async (pool: Pool) => {
await pool.query(`
DROP TYPE IF EXISTS match_method;
DROP TYPE IF EXISTS status_page_type;
DROP TYPE IF EXISTS duration_unit;
DROP TYPE IF EXISTS incident_resolution_type;
DROP TYPE IF EXISTS user_role;
DROP TYPE IF EXISTS notification_type;
DROP TYPE IF EXISTS monitor_status;
DROP TYPE IF EXISTS monitor_type;
`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createTeams = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email);
`);
};
export const dropTeams = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS teams;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createUsers = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
avatar_image TEXT,
profile_image BYTEA,
profile_image_content_type TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
check_ttl INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
`);
};
export const dropUsers = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS users;`);
};
@@ -1,69 +0,0 @@
import type { Pool } from "pg";
export const createMonitors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
description TEXT,
type monitor_type NOT NULL,
status monitor_status NOT NULL DEFAULT 'initializing',
-- HTTP / Website monitoring
url TEXT,
port INTEGER,
ignore_tls_errors BOOLEAN DEFAULT FALSE,
use_advanced_matching BOOLEAN DEFAULT FALSE,
json_path TEXT,
expected_value TEXT,
match_method match_method,
secret TEXT,
-- Scheduling
interval_ms INTEGER NOT NULL DEFAULT 60000,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Uptime tracking
status_window BOOLEAN[],
status_window_size INTEGER DEFAULT 5,
status_window_threshold INTEGER DEFAULT 60,
uptime_percentage DOUBLE PRECISION,
-- Infrastructure alert thresholds
cpu_alert_threshold INTEGER,
cpu_alert_counter INTEGER,
memory_alert_threshold INTEGER,
memory_alert_counter INTEGER,
disk_alert_threshold INTEGER,
disk_alert_counter INTEGER,
temp_alert_threshold INTEGER,
temp_alert_counter INTEGER,
selected_disks TEXT[],
-- Game monitoring
game_id TEXT,
grpc_service_name TEXT,
-- Grouping
monitor_group TEXT,
-- Geo checks
geo_check_enabled BOOLEAN DEFAULT FALSE,
geo_check_locations TEXT[],
geo_check_interval_ms INTEGER DEFAULT 300000,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type);
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id);
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status);
`);
};
export const dropMonitors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitors;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
type notification_type NOT NULL,
notification_name TEXT,
address TEXT,
phone TEXT,
-- Matrix-specific fields
homeserver_url TEXT,
room_id TEXT,
access_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id);
`);
};
export const dropNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS notifications;`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createMonitorNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_notifications (
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
PRIMARY KEY (monitor_id, notification_id)
);
CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification
ON monitor_notifications (notification_id);
`);
};
export const dropMonitorNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createIncidents = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
status BOOLEAN,
message TEXT,
status_code INTEGER,
resolution_type incident_resolution_type,
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
resolved_by_email TEXT,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status);
CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status);
CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC);
`);
};
export const dropIncidents = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS incidents;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createMonitorStats = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE,
avg_response_time DOUBLE PRECISION DEFAULT 0,
max_response_time DOUBLE PRECISION DEFAULT 0,
total_checks BIGINT DEFAULT 0,
total_up_checks BIGINT DEFAULT 0,
total_down_checks BIGINT DEFAULT 0,
uptime_percentage DOUBLE PRECISION DEFAULT 0,
last_check_timestamp TIMESTAMPTZ,
last_response_time DOUBLE PRECISION DEFAULT 0,
time_of_last_failure TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
export const dropMonitorStats = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_stats;`);
};
@@ -1,105 +0,0 @@
import type { Pool } from "pg";
export const createChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
status BOOLEAN,
response_time INTEGER,
status_code INTEGER,
message TEXT,
-- HTTP request timings
timing_start DOUBLE PRECISION,
timing_socket DOUBLE PRECISION,
timing_lookup DOUBLE PRECISION,
timing_connect DOUBLE PRECISION,
timing_secure_connect DOUBLE PRECISION,
timing_upload DOUBLE PRECISION,
timing_response DOUBLE PRECISION,
timing_end DOUBLE PRECISION,
phase_wait DOUBLE PRECISION,
phase_dns DOUBLE PRECISION,
phase_tcp DOUBLE PRECISION,
phase_tls DOUBLE PRECISION,
phase_request DOUBLE PRECISION,
phase_first_byte DOUBLE PRECISION,
phase_download DOUBLE PRECISION,
phase_total DOUBLE PRECISION,
-- CPU metrics
cpu_physical_core INTEGER,
cpu_logical_core INTEGER,
cpu_frequency DOUBLE PRECISION,
cpu_current_frequency DOUBLE PRECISION,
cpu_temperature DOUBLE PRECISION[],
cpu_free_percent DOUBLE PRECISION,
cpu_usage_percent DOUBLE PRECISION,
-- Memory metrics
mem_total_bytes BIGINT,
mem_available_bytes BIGINT,
mem_used_bytes BIGINT,
mem_usage_percent DOUBLE PRECISION,
-- Host info
host_os TEXT,
host_platform TEXT,
host_kernel_version TEXT,
host_pretty_name TEXT,
-- Capture agent info
capture_version TEXT,
capture_mode TEXT,
-- Lighthouse / PageSpeed scores
lighthouse_performance DOUBLE PRECISION,
lighthouse_accessibility DOUBLE PRECISION,
lighthouse_best_practices DOUBLE PRECISION,
lighthouse_seo DOUBLE PRECISION,
-- Lighthouse audit details
audit_cls_score DOUBLE PRECISION,
audit_cls_value DOUBLE PRECISION,
audit_cls_display TEXT,
audit_si_score DOUBLE PRECISION,
audit_si_value DOUBLE PRECISION,
audit_si_display TEXT,
audit_fcp_score DOUBLE PRECISION,
audit_fcp_value DOUBLE PRECISION,
audit_fcp_display TEXT,
audit_lcp_score DOUBLE PRECISION,
audit_lcp_value DOUBLE PRECISION,
audit_lcp_display TEXT,
audit_tbt_score DOUBLE PRECISION,
audit_tbt_value DOUBLE PRECISION,
audit_tbt_display TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
ON checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_time
ON checks (team_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time
ON checks (monitor_id, monitor_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_status_time
ON checks (team_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_status_code
ON checks (status_code, created_at DESC);
`);
};
export const dropChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS checks;`);
};
@@ -1,37 +0,0 @@
import type { Pool } from "pg";
export const createCheckDisks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_disks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
device TEXT,
mountpoint TEXT,
total_bytes BIGINT,
free_bytes BIGINT,
used_bytes BIGINT,
usage_percent DOUBLE PRECISION,
total_inodes BIGINT,
free_inodes BIGINT,
used_inodes BIGINT,
inodes_usage_percent DOUBLE PRECISION,
read_bytes BIGINT,
write_bytes BIGINT,
read_time BIGINT,
write_time BIGINT
);
SELECT create_hypertable('check_disks', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_disks_check
ON check_disks (check_id, check_created_at);
`);
};
export const dropCheckDisks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_disks;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_network_interfaces (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
name TEXT,
bytes_sent BIGINT,
bytes_recv BIGINT,
packets_sent BIGINT,
packets_recv BIGINT,
err_in BIGINT,
err_out BIGINT,
drop_in BIGINT,
drop_out BIGINT,
fifo_in BIGINT,
fifo_out BIGINT
);
SELECT create_hypertable('check_network_interfaces', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_net_check
ON check_network_interfaces (check_id, check_created_at);
`);
};
export const dropCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createCheckErrors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_errors (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
metrics TEXT[],
error TEXT
);
SELECT create_hypertable('check_errors', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_errors_check
ON check_errors (check_id, check_created_at);
`);
};
export const dropCheckErrors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_errors;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createGeoChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('geo_checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time
ON geo_checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time
ON geo_checks (team_id, created_at DESC);
`);
};
export const dropGeoChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_checks;`);
};
@@ -1,46 +0,0 @@
import type { Pool } from "pg";
export const createGeoCheckResults = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_check_results (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geo_check_id UUID NOT NULL,
geo_check_created_at TIMESTAMPTZ NOT NULL,
-- Status
status BOOLEAN,
status_code INTEGER,
-- Location
location_continent TEXT,
location_region TEXT,
location_country TEXT,
location_state TEXT,
location_city TEXT,
location_longitude DOUBLE PRECISION,
location_latitude DOUBLE PRECISION,
-- Timings
timing_total DOUBLE PRECISION,
timing_dns DOUBLE PRECISION,
timing_tcp DOUBLE PRECISION,
timing_tls DOUBLE PRECISION,
timing_first_byte DOUBLE PRECISION,
timing_download DOUBLE PRECISION
);
SELECT create_hypertable('geo_check_results', 'geo_check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_results_check
ON geo_check_results (geo_check_id, geo_check_created_at);
CREATE INDEX IF NOT EXISTS idx_geo_results_country
ON geo_check_results (location_country, geo_check_created_at DESC);
`);
};
export const dropGeoCheckResults = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_check_results;`);
};
@@ -1,22 +0,0 @@
import type { Pool } from "pg";
export const createInvites = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry);
`);
};
export const dropInvites = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS invites;`);
};
@@ -1,20 +0,0 @@
import type { Pool } from "pg";
export const createRecoveryTokens = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS recovery_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry);
`);
};
export const dropRecoveryTokens = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createMaintenanceWindows = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
active BOOLEAN NOT NULL DEFAULT TRUE,
name TEXT,
duration INTEGER,
duration_unit duration_unit,
repeat INTEGER,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id);
CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id);
CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry);
`);
};
export const dropMaintenanceWindows = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`);
};
@@ -1,51 +0,0 @@
import type { Pool } from "pg";
export const createStatusPages = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS status_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[],
company_name TEXT,
url TEXT NOT NULL UNIQUE,
timezone TEXT,
color TEXT DEFAULT '#4169E1',
logo_data BYTEA,
logo_content_type TEXT,
is_published BOOLEAN DEFAULT FALSE,
show_charts BOOLEAN DEFAULT FALSE,
show_uptime_percentage BOOLEAN DEFAULT FALSE,
show_admin_login_link BOOLEAN DEFAULT FALSE,
show_infrastructure BOOLEAN DEFAULT FALSE,
custom_css TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id);
CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url);
CREATE TABLE IF NOT EXISTS status_page_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
CREATE TABLE IF NOT EXISTS status_page_sub_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
`);
};
export const dropStatusPages = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS status_page_sub_monitors;
DROP TABLE IF EXISTS status_page_monitors;
DROP TABLE IF EXISTS status_pages;
`);
};
@@ -1,62 +0,0 @@
import type { Pool } from "pg";
export const createAppSettings = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS app_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
check_ttl INTEGER DEFAULT 30,
language TEXT,
jwt_secret TEXT,
pagespeed_api_key TEXT,
-- SMTP / Email settings
system_email_host TEXT,
system_email_port INTEGER,
system_email_address TEXT,
system_email_password TEXT,
system_email_user TEXT,
system_email_connection_host TEXT,
system_email_tls_servername TEXT,
system_email_secure BOOLEAN,
system_email_pool BOOLEAN,
system_email_ignore_tls BOOLEAN,
system_email_require_tls BOOLEAN,
system_email_reject_unauthorized BOOLEAN,
show_url BOOLEAN,
version TEXT,
-- Global infrastructure thresholds
threshold_cpu_usage INTEGER,
threshold_memory_usage INTEGER,
threshold_disk_usage INTEGER,
threshold_temperature INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enforce singleton: only one row allowed
CREATE OR REPLACE FUNCTION enforce_singleton_app_settings()
RETURNS TRIGGER AS $$
BEGIN
IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN
RAISE EXCEPTION 'app_settings allows only one row';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings;
CREATE TRIGGER trg_singleton_app_settings
BEFORE INSERT ON app_settings
FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings();
`);
};
export const dropAppSettings = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS app_settings;
DROP FUNCTION IF EXISTS enforce_singleton_app_settings();
`);
};
@@ -1,127 +0,0 @@
import type { Pool } from "pg";
export const createContinuousAggregates = async (pool: Pool) => {
// Uptime & Response Time (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time,
MIN(response_time) AS min_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`);
// Uptime & Response Time (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`);
// Infrastructure Metrics (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
AVG(cpu_usage_percent) AS avg_cpu,
MAX(cpu_usage_percent) AS max_cpu,
AVG(mem_usage_percent) AS avg_memory,
MAX(mem_usage_percent) AS max_memory,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'hardware'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('hardware_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`);
// PageSpeed Scores (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
AVG(lighthouse_performance) AS avg_performance,
AVG(lighthouse_accessibility) AS avg_accessibility,
AVG(lighthouse_best_practices) AS avg_best_practices,
AVG(lighthouse_seo) AS avg_seo,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'pagespeed'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('pagespeed_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`);
};
export const dropContinuousAggregates = async (pool: Pool) => {
await pool.query(`
DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE;
`);
};
@@ -1,135 +0,0 @@
import type { Pool } from "pg";
export const createRetentionCompression = async (pool: Pool) => {
// -- Compression policies --
// Checks
await pool.query(`
ALTER TABLE checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Check child tables
await pool.query(`
ALTER TABLE check_disks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_network_interfaces SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_errors SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Geo checks
await pool.query(`
ALTER TABLE geo_checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE geo_check_results SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'geo_check_id',
timescaledb.compress_orderby = 'geo_check_created_at DESC'
);
SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE);
`);
// -- Retention policies (default 30 days) --
await pool.query(`
SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE);
`);
// -- Dynamic retention helper function --
await pool.query(`
CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER)
RETURNS VOID AS $$
DECLARE
ttl_interval INTERVAL;
BEGIN
ttl_interval := (new_ttl_days || ' days')::INTERVAL;
PERFORM remove_retention_policy('checks', if_exists => true);
PERFORM remove_retention_policy('check_disks', if_exists => true);
PERFORM remove_retention_policy('check_network_interfaces', if_exists => true);
PERFORM remove_retention_policy('check_errors', if_exists => true);
PERFORM remove_retention_policy('geo_checks', if_exists => true);
PERFORM remove_retention_policy('geo_check_results', if_exists => true);
PERFORM add_retention_policy('checks', ttl_interval);
PERFORM add_retention_policy('check_disks', ttl_interval);
PERFORM add_retention_policy('check_network_interfaces', ttl_interval);
PERFORM add_retention_policy('check_errors', ttl_interval);
PERFORM add_retention_policy('geo_checks', ttl_interval);
PERFORM add_retention_policy('geo_check_results', ttl_interval);
END;
$$ LANGUAGE plpgsql;
`);
};
export const dropRetentionCompression = async (pool: Pool) => {
// Remove retention policies
await pool.query(`
SELECT remove_retention_policy('checks', if_exists => true);
SELECT remove_retention_policy('check_disks', if_exists => true);
SELECT remove_retention_policy('check_network_interfaces', if_exists => true);
SELECT remove_retention_policy('check_errors', if_exists => true);
SELECT remove_retention_policy('geo_checks', if_exists => true);
SELECT remove_retention_policy('geo_check_results', if_exists => true);
`);
// Remove compression policies
await pool.query(`
SELECT remove_compression_policy('checks', if_exists => true);
SELECT remove_compression_policy('check_disks', if_exists => true);
SELECT remove_compression_policy('check_network_interfaces', if_exists => true);
SELECT remove_compression_policy('check_errors', if_exists => true);
SELECT remove_compression_policy('geo_checks', if_exists => true);
SELECT remove_compression_policy('geo_check_results', if_exists => true);
`);
// Disable compression on tables
await pool.query(`
ALTER TABLE checks SET (timescaledb.compress = false);
ALTER TABLE check_disks SET (timescaledb.compress = false);
ALTER TABLE check_network_interfaces SET (timescaledb.compress = false);
ALTER TABLE check_errors SET (timescaledb.compress = false);
ALTER TABLE geo_checks SET (timescaledb.compress = false);
ALTER TABLE geo_check_results SET (timescaledb.compress = false);
`);
// Drop dynamic retention function
await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`);
};
@@ -1,150 +0,0 @@
import type { Pool } from "pg";
import type { ILogger } from "@/utils/logger.js";
import { createEnums, dropEnums } from "./0001_create_enums.js";
import { createTeams, dropTeams } from "./0002_create_teams.js";
import { createUsers, dropUsers } from "./0003_create_users.js";
import { createMonitors, dropMonitors } from "./0004_create_monitors.js";
import { createNotifications, dropNotifications } from "./0005_create_notifications.js";
import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js";
import { createIncidents, dropIncidents } from "./0007_create_incidents.js";
import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js";
import { createChecks, dropChecks } from "./0009_create_checks.js";
import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js";
import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js";
import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js";
import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js";
import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js";
import { createInvites, dropInvites } from "./0015_create_invites.js";
import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js";
import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js";
import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js";
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
import { addStrategyToMonitors, dropStrategyFromMonitors } from "./0022_add_strategy_to_monitors.js";
const SERVICE_NAME = "TimescaleDB Migrations";
type MigrationEntry = {
name: string;
up: (pool: Pool) => Promise<void>;
down: (pool: Pool) => Promise<void>;
};
const migrations: MigrationEntry[] = [
{ name: "0001_create_enums", up: createEnums, down: dropEnums },
{ name: "0002_create_teams", up: createTeams, down: dropTeams },
{ name: "0003_create_users", up: createUsers, down: dropUsers },
{ name: "0004_create_monitors", up: createMonitors, down: dropMonitors },
{ name: "0005_create_notifications", up: createNotifications, down: dropNotifications },
{ name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications },
{ name: "0007_create_incidents", up: createIncidents, down: dropIncidents },
{ name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats },
{ name: "0009_create_checks", up: createChecks, down: dropChecks },
{ name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks },
{ name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces },
{ name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors },
{ name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks },
{ name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults },
{ name: "0015_create_invites", up: createInvites, down: dropInvites },
{ name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens },
{ name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows },
{ name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages },
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_strategy_to_monitors", up: addStrategyToMonitors, down: dropStrategyFromMonitors },
];
const ensureMigrationsTable = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE migration_status AS ENUM ('completed', 'failed');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS migrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
status migration_status NOT NULL,
completed_at TIMESTAMPTZ,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => {
try {
logger?.info({ message: "Running migrations", service: SERVICE_NAME });
await ensureMigrationsTable(pool);
for (const migration of migrations) {
const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]);
if (result.rowCount !== null && result.rowCount > 0) {
logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME });
continue;
}
try {
await migration.up(pool);
await pool.query(
`INSERT INTO migrations (name, status, completed_at)
VALUES ($1, 'completed', NOW())
ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`,
[migration.name]
);
logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
await pool.query(
`INSERT INTO migrations (name, status, error)
VALUES ($1, 'failed', $2)
ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`,
[migration.name, err?.message]
);
throw error;
}
}
logger?.info({ message: "Migrations completed", service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => {
try {
await ensureMigrationsTable(pool);
// Find the last completed migration
const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1");
if (result.rowCount === 0) {
logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME });
return;
}
const lastMigrationName = result.rows[0].name;
const migration = migrations.find((m) => m.name === lastMigrationName);
if (!migration) {
throw new Error(`Migration "${lastMigrationName}" not found in migration list`);
}
logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME });
await migration.down(pool);
await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]);
logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
export { runTimescaleDBMigrations, rollbackTimescaleDBMigration };
+1
View File
@@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailHost: { type: String },
systemEmailPort: { type: Number },
systemEmailAddress: { type: String },
systemEmailDisplayName: { type: String },
systemEmailPassword: { type: String },
systemEmailUser: { type: String },
systemEmailConnectionHost: { type: String, default: "localhost" },
+1
View File
@@ -271,6 +271,7 @@ const CheckSchema = new Schema<CheckDocument>(
{
timestamps: true,
strict: false,
suppressReservedKeysWarning: true,
timeseries: {
timeField: "createdAt",
metaField: "metadata",
+2 -2
View File
@@ -1,5 +1,5 @@
import { Schema, model, type Types } from "mongoose";
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js";
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
@@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
},
durationUnit: {
type: String,
enum: ["seconds", "minutes", "hours", "days"],
enum: DurationUnits,
},
repeat: {
type: Number,
+8 -1
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
import type {
CheckAudits,
CheckCaptureInfo,
@@ -355,6 +355,13 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Number,
default: 300000,
},
dnsServer: {
type: String,
},
dnsRecordType: {
type: String,
enum: DnsRecordTypes,
},
recentChecks: {
type: [checkSnapshotSchema],
default: [],
+1 -1
View File
@@ -16,7 +16,7 @@ const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
index: true,
unique: true,
required: true,
},
avgResponseTime: {
+3 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
required: true,
},
notificationName: {
@@ -37,6 +37,8 @@ const NotificationSchema = new Schema<NotificationDocument>(
homeserverUrl: { type: String },
roomId: { type: String },
accessToken: { type: String },
accountSid: { type: String },
twilioPhoneNumber: { type: String },
},
{
timestamps: true,
+17 -1
View File
@@ -1,6 +1,12 @@
import { Schema, model, type Types } from "mongoose";
import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
import { StatusPageTypes } from "@/types/statusPage.js";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
StatusPageThemeModes,
StatusPageThemes,
StatusPageTypes,
} from "@/types/statusPage.js";
type StatusPageDocumentBase = Omit<
StatusPage,
@@ -108,6 +114,16 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: String,
default: "",
},
theme: {
type: String,
enum: StatusPageThemes,
default: DEFAULT_STATUS_PAGE_THEME,
},
themeMode: {
type: String,
enum: StatusPageThemeModes,
default: DEFAULT_STATUS_PAGE_THEME_MODE,
},
},
{ timestamps: true }
);
File diff suppressed because it is too large. Load diff
@@ -1,274 +0,0 @@
import type { Pool } from "pg";
import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js";
import { getDateForRange } from "@/utils/dataUtils.js";
const dateStringToBucket = (dateString: string): string => {
if (dateString.includes("%M")) return "1 minute";
if (dateString.includes("%H")) return "1 hour";
return "1 day";
};
export class TimescaleGeoChecksRepository implements IGeoChecksRepository {
constructor(private pool: Pool) {}
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
const created: GeoCheck[] = [];
for (const geoCheck of geoChecks) {
const geoResult = await this.pool.query(
`INSERT INTO geo_checks (monitor_id, team_id, monitor_type, expiry)
VALUES ($1, $2, $3, $4)
RETURNING id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at`,
[geoCheck.metadata.monitorId, geoCheck.metadata.teamId, geoCheck.metadata.type, geoCheck.expiry ? new Date(geoCheck.expiry) : null]
);
const row = geoResult.rows[0];
if (!row) continue;
for (const result of geoCheck.results) {
await this.pool.query(
`INSERT INTO geo_check_results (
geo_check_id, geo_check_created_at,
status, status_code,
location_continent, location_region, location_country, location_state, location_city,
location_longitude, location_latitude,
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
[
row.id,
row.created_at,
result.status,
result.statusCode,
result.location.continent,
result.location.region,
result.location.country,
result.location.state,
result.location.city,
result.location.longitude,
result.location.latitude,
result.timings.total,
result.timings.dns,
result.timings.tcp,
result.timings.tls,
result.timings.firstByte,
result.timings.download,
]
);
}
created.push(this.toGeoCheck(row, geoCheck.results));
}
return created;
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => {
const conditions: string[] = ["gc.monitor_id = $1"];
const values: unknown[] = [monitorId];
let paramIndex = 2;
const rangeDate = getDateForRange(dateRange);
if (rangeDate) {
conditions.push(`gc.created_at >= $${paramIndex++}`);
values.push(rangeDate);
}
if (continents && continents.length > 0) {
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
values.push(continents);
}
const where = conditions.join(" AND ");
const direction = sortOrder === "asc" ? "ASC" : "DESC";
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
const countValues = [...values];
const dataValues = [...values, rowsPerPage, offset];
const [countResult, dataResult] = await Promise.all([
this.pool.query(
`SELECT COUNT(*)::int AS count
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${where}`,
countValues
),
this.pool.query(
`SELECT gc.id, gc.monitor_id, gc.team_id, gc.monitor_type,
r.location_continent, r.location_region, r.location_country, r.location_state, r.location_city,
r.location_longitude, r.location_latitude,
r.status, r.status_code,
r.timing_total, r.timing_dns, r.timing_tcp, r.timing_tls, r.timing_first_byte, r.timing_download,
gc.created_at, gc.updated_at
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${where}
ORDER BY gc.created_at ${direction}
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataValues
),
]);
const geoChecks: FlatGeoCheck[] = dataResult.rows.map((row) => ({
id: `${row.monitor_id}-${new Date(row.created_at).getTime()}-${row.location_continent}-${row.location_city}-${row.id.substring(0, 8)}`,
monitorId: row.monitor_id,
teamId: row.team_id,
type: row.monitor_type,
location: {
continent: row.location_continent,
region: row.location_region,
country: row.location_country,
state: row.location_state,
city: row.location_city,
longitude: row.location_longitude,
latitude: row.location_latitude,
},
status: row.status,
statusCode: row.status_code,
timings: {
total: row.timing_total ?? 0,
dns: row.timing_dns ?? 0,
tcp: row.timing_tcp ?? 0,
tls: row.timing_tls ?? 0,
firstByte: row.timing_first_byte ?? 0,
download: row.timing_download ?? 0,
},
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
}));
return { geoChecksCount: countResult.rows[0].count, geoChecks };
};
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
const geoRows = await this.pool.query(
`SELECT id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at
FROM geo_checks
WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3
ORDER BY created_at DESC`,
[monitorId, startDate, endDate]
);
const geoChecks: GeoCheck[] = [];
for (const row of geoRows.rows) {
const resultsRows = await this.pool.query(
`SELECT status, status_code,
location_continent, location_region, location_country, location_state, location_city,
location_longitude, location_latitude,
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
FROM geo_check_results
WHERE geo_check_id = $1`,
[row.id]
);
const results = resultsRows.rows.map(this.toGeoCheckResult);
geoChecks.push(this.toGeoCheck(row, results));
}
return geoChecks;
};
findGroupedByMonitorIdAndDateRange = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
const bucket = dateStringToBucket(dateFormat);
const conditions: string[] = ["gc.monitor_id = $2", "gc.created_at >= $3", "gc.created_at <= $4"];
const values: unknown[] = [bucket, monitorId, startDate, endDate];
let paramIndex = 5;
if (continents && continents.length > 0) {
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
values.push(continents);
}
const result = await this.pool.query(
`SELECT
time_bucket($1::interval, gc.created_at) AS bucket_date,
r.location_continent AS continent,
ROUND(AVG(r.timing_total)::numeric, 2) AS "avgResponseTime",
COUNT(*)::int AS "totalChecks",
ROUND((100.0 * COUNT(*) FILTER (WHERE r.status = TRUE) / COUNT(*))::numeric, 2) AS "uptimePercentage"
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${conditions.join(" AND ")}
GROUP BY bucket_date, r.location_continent
ORDER BY bucket_date, r.location_continent`,
values
);
return result.rows.map((row) => ({
bucketDate: (row.bucket_date as Date).toISOString(),
continent: row.continent as GeoContinent,
avgResponseTime: Number(row.avgResponseTime ?? 0),
totalChecks: Number(row.totalChecks ?? 0),
uptimePercentage: Number(row.uptimePercentage ?? 0),
}));
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]);
return result.rowCount ?? 0;
};
deleteByTeamId = async (teamId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM geo_checks`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private toGeoCheckResult = (row: Record<string, unknown>): GeoCheckResult => ({
location: {
continent: (row.location_continent as GeoContinent) ?? "",
region: (row.location_region as string) ?? "",
country: (row.location_country as string) ?? "",
state: (row.location_state as string) ?? "",
city: (row.location_city as string) ?? "",
longitude: (row.location_longitude as number) ?? 0,
latitude: (row.location_latitude as number) ?? 0,
},
status: (row.status as boolean) ?? false,
statusCode: (row.status_code as number) ?? 0,
timings: {
total: (row.timing_total as number) ?? 0,
dns: (row.timing_dns as number) ?? 0,
tcp: (row.timing_tcp as number) ?? 0,
tls: (row.timing_tls as number) ?? 0,
firstByte: (row.timing_first_byte as number) ?? 0,
download: (row.timing_download as number) ?? 0,
},
});
private toGeoCheck = (row: Record<string, unknown>, results: GeoCheckResult[]): GeoCheck => ({
id: row.id as string,
metadata: {
monitorId: row.monitor_id as string,
teamId: row.team_id as string,
type: row.monitor_type as MonitorType,
},
results,
expiry: row.expiry ? (row.expiry as Date).toISOString() : new Date(0).toISOString(),
__v: 0,
createdAt: (row.created_at as Date).toISOString(),
updatedAt: (row.updated_at as Date).toISOString(),
});
}
@@ -1,294 +0,0 @@
import type { Pool } from "pg";
import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js";
import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js";
import { AppError } from "@/utils/AppError.js";
interface IncidentRow {
id: string;
monitor_id: string;
team_id: string;
start_time: Date;
end_time: Date | null;
status: boolean;
message: string | null;
status_code: number | null;
resolution_type: IncidentResolutionType;
resolved_by: string | null;
resolved_by_email: string | null;
comment: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code,
resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`;
export class TimescaleIncidentsRepository implements IIncidentsRepository {
constructor(private pool: Pool) {}
create = async (incident: Partial<Incident>): Promise<Incident> => {
const result = await this.pool.query<IncidentRow>(
`INSERT INTO incidents (monitor_id, team_id, start_time, end_time, status, message, status_code, resolution_type, resolved_by, resolved_by_email, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING ${COLUMNS}`,
[
incident.monitorId,
incident.teamId,
incident.startTime ? new Date(Number(incident.startTime) || incident.startTime) : new Date(),
incident.endTime ? new Date(Number(incident.endTime) || incident.endTime) : null,
incident.status ?? true,
incident.message ?? null,
incident.statusCode ?? null,
incident.resolutionType ?? null,
incident.resolvedBy ?? null,
incident.resolvedByEmail ?? null,
incident.comment ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create incident", status: 500 });
}
return this.toEntity(row);
};
findById = async (incidentId: string, teamId: string): Promise<Incident> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2`, [incidentId, teamId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 });
}
return this.toEntity(row);
};
findActiveByIncidentId = async (incidentId: string, teamId: string): Promise<Incident | null> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2 AND status = TRUE`, [
incidentId,
teamId,
]);
const row = result.rows[0];
return row ? this.toEntity(row) : null;
};
findActiveByMonitorId = async (monitorId: string, teamId: string): Promise<Incident | null> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE monitor_id = $1 AND team_id = $2 AND status = TRUE`, [
monitorId,
teamId,
]);
const row = result.rows[0];
return row ? this.toEntity(row) : null;
};
findByTeamId = async (
teamId: string,
startDate: Date | undefined,
page: number,
rowsPerPage: number,
sortOrder?: string,
status?: boolean,
monitorId?: string,
resolutionType?: string
): Promise<Incident[]> => {
const { conditions, values, paramIndex } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
const direction = sortOrder === "asc" ? "ASC" : "DESC";
const offset = page * rowsPerPage;
const result = await this.pool.query<IncidentRow>(
`SELECT ${COLUMNS} FROM incidents
WHERE ${conditions.join(" AND ")}
ORDER BY created_at ${direction}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, rowsPerPage, offset]
);
return result.rows.map(this.toEntity);
};
countByTeamId = async (
teamId: string,
startDate: Date | undefined,
status?: boolean,
monitorId?: string,
resolutionType?: string
): Promise<number> => {
const { conditions, values } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM incidents WHERE ${conditions.join(" AND ")}`, values);
return result.rows[0].count;
};
findSummaryByTeamId = async (teamId: string, limit?: number): Promise<IncidentSummary> => {
// Counts by status and resolution type
const countsResult = await this.pool.query(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status = TRUE)::int AS active,
COUNT(*) FILTER (WHERE resolution_type = 'manual')::int AS manual,
COUNT(*) FILTER (WHERE resolution_type = 'automatic')::int AS automatic
FROM incidents WHERE team_id = $1`,
[teamId]
);
const counts = countsResult.rows[0] ?? { total: 0, active: 0, manual: 0, automatic: 0 };
// Average resolution time
const resTimeResult = await this.pool.query(
`SELECT AVG(EXTRACT(EPOCH FROM (end_time - start_time))) AS avg_seconds
FROM incidents
WHERE team_id = $1 AND status = FALSE AND end_time IS NOT NULL`,
[teamId]
);
const avgSeconds = resTimeResult.rows[0]?.avg_seconds ?? 0;
const avgResolutionTimeHours = Math.round((avgSeconds / 3600) * 100) / 100;
// Top monitor by incident count
const topMonitorResult = await this.pool.query(
`SELECT i.monitor_id, m.name AS monitor_name, COUNT(*)::int AS count
FROM incidents i
LEFT JOIN monitors m ON m.id = i.monitor_id
WHERE i.team_id = $1
GROUP BY i.monitor_id, m.name
ORDER BY count DESC
LIMIT 1`,
[teamId]
);
// Latest incidents
const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10);
const latestResult = await this.pool.query(
`SELECT i.id, i.monitor_id, m.name AS monitor_name, i.status, i.start_time, i.end_time,
i.resolution_type, i.message, i.status_code, i.created_at
FROM incidents i
LEFT JOIN monitors m ON m.id = i.monitor_id
WHERE i.team_id = $1
ORDER BY i.created_at DESC
LIMIT $2`,
[teamId, latestLimit]
);
const topRow = topMonitorResult.rows[0];
return {
total: counts.total,
totalActive: counts.active,
totalManualResolutions: counts.manual,
totalAutomaticResolutions: counts.automatic,
avgResolutionTimeHours,
topMonitor: topRow
? {
monitorId: topRow.monitor_id,
monitorName: topRow.monitor_name ?? null,
incidentCount: topRow.count,
}
: null,
latestIncidents: latestResult.rows.map((row) => ({
id: row.id,
monitorId: row.monitor_id,
monitorName: row.monitor_name ?? null,
status: row.status,
startTime: row.start_time.toISOString(),
endTime: row.end_time ? row.end_time.toISOString() : null,
resolutionType: row.resolution_type ?? null,
message: row.message ?? null,
statusCode: row.status_code ?? null,
createdAt: row.created_at.toISOString(),
})),
};
};
updateById = async (incidentId: string, teamId: string, patch: Partial<Incident>): Promise<Incident> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof Incident, string][] = [
["status", "status"],
["message", "message"],
["statusCode", "status_code"],
["endTime", "end_time"],
["resolutionType", "resolution_type"],
["resolvedBy", "resolved_by"],
["resolvedByEmail", "resolved_by_email"],
["comment", "comment"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
const value = key === "endTime" && patch[key] ? new Date(Number(patch[key]) || (patch[key] as string)) : patch[key];
sets.push(`${column} = $${paramIndex++}`);
values.push(value);
}
}
if (sets.length === 0) {
return this.findById(incidentId, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(incidentId, teamId);
const result = await this.pool.query<IncidentRow>(
`UPDATE incidents SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: `Failed to update incident with id ${incidentId}`, status: 500 });
}
return this.toEntity(row);
};
deleteByMonitorId = async (monitorId: string, teamId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id = $1 AND team_id = $2`, [monitorId, teamId]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM incidents`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private buildWhere = (teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string) => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
let paramIndex = 2;
if (startDate) {
conditions.push(`created_at >= $${paramIndex++}`);
values.push(startDate);
}
if (status !== undefined) {
conditions.push(`status = $${paramIndex++}`);
values.push(status);
}
if (monitorId) {
conditions.push(`monitor_id = $${paramIndex++}`);
values.push(monitorId);
}
if (resolutionType) {
conditions.push(`resolution_type = $${paramIndex++}`);
values.push(resolutionType);
}
return { conditions, values, paramIndex };
};
private toEntity = (row: IncidentRow): Incident => ({
id: row.id,
monitorId: row.monitor_id,
teamId: row.team_id,
startTime: row.start_time.toISOString(),
endTime: row.end_time ? row.end_time.toISOString() : null,
status: row.status,
message: row.message ?? null,
statusCode: row.status_code ?? null,
resolutionType: row.resolution_type ?? null,
resolvedBy: row.resolved_by ?? null,
resolvedByEmail: row.resolved_by_email ?? null,
comment: row.comment ?? null,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
-13
View File
@@ -1,54 +1,41 @@
export * from "@/repositories/monitors/IMonitorsRepository.js";
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js";
export * from "@/repositories/checks/IChecksRepository.js";
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js";
export { TimescaleChecksRepository } from "@/repositories/checks/TimescaleChecksRepository.js";
export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js";
export { TimescaleMonitorStatsRepository } from "@/repositories/monitor-stats/TimescaleMonitorStatsRepository.js";
export * from "@/repositories/status-pages/IStatusPagesRepository.js";
export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js";
export { TimescaleStatusPagesRepository } from "@/repositories/status-pages/TimescaleStatusPagesRepository.js";
export * from "@/repositories/users/IUsersRepository.js";
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js";
export * from "@/repositories/invites/IInvitesRepository.js";
export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js";
export { TimescaleInvitesRepository } from "@/repositories/invites/TimescaleInvitesRepository.js";
export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js";
export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js";
export { TimescaleRecoveryTokensRepository } from "@/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.js";
export * from "@/repositories/settings/ISettingsRepository.js";
export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js";
export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js";
export * from "@/repositories/notifications/INotificationsRepository.js";
export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js";
export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js";
export * from "@/repositories/incidents/IIncidentsRepository.js";
export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js";
export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js";
export * from "@/repositories/teams/ITeamsRepository.js";
export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js";
export { TimescaleTeamsRepository } from "@/repositories/teams/TimescaleTeamsRepository.js";
export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js";
export { TimescaleMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.js";
export * from "@/repositories/geo-checks/IGeoChecksRepository.js";
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
export * from "@/repositories/dlq/IDLQRepository.js";
export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js";
export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js";
@@ -1,77 +0,0 @@
import type { Pool } from "pg";
import crypto from "crypto";
import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js";
import type { Invite } from "@/types/invite.js";
import type { UserRole } from "@/types/user.js";
import { AppError } from "@/utils/AppError.js";
const parsePostgresArray = (value: unknown): UserRole[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as UserRole[];
};
interface InviteRow {
id: string;
email: string;
team_id: string;
roles: UserRole[];
token: string;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`;
export class TimescaleInvitesRepository implements IInvitesRepository {
constructor(private pool: Pool) {}
create = async (invite: Partial<Invite>): Promise<Invite> => {
// Delete existing invites for this email
await this.pool.query(`DELETE FROM invites WHERE email = $1`, [invite.email]);
const token = crypto.randomBytes(32).toString("hex");
const result = await this.pool.query<InviteRow>(
`INSERT INTO invites (email, team_id, roles, token, expiry)
VALUES ($1, $2, $3, $4, $5)
RETURNING ${COLUMNS}`,
[invite.email, invite.teamId, invite.role ?? ["user"], token, invite.expiry ? new Date(invite.expiry) : null]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create invite", status: 500 });
}
return this.toEntity(row);
};
findByToken = async (token: string): Promise<Invite> => {
const result = await this.pool.query<InviteRow>(`SELECT ${COLUMNS} FROM invites WHERE token = $1`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Invite not found", status: 404 });
}
return this.toEntity(row);
};
findByTokenAndDelete = async (token: string): Promise<Invite> => {
const result = await this.pool.query<InviteRow>(`DELETE FROM invites WHERE token = $1 RETURNING ${COLUMNS}`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Invite not found", status: 404 });
}
return this.toEntity(row);
};
private toEntity = (row: InviteRow): Invite => ({
id: row.id,
email: row.email,
teamId: row.team_id,
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
token: row.token,
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,197 +0,0 @@
import type { Pool } from "pg";
import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js";
import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
import { AppError } from "@/utils/AppError.js";
interface MaintenanceWindowRow {
id: string;
monitor_id: string;
team_id: string;
active: boolean;
name: string;
duration: number;
duration_unit: DurationUnit;
repeat: number;
start_time: Date;
end_time: Date;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat,
start_time, end_time, expiry, created_at, updated_at`;
export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository {
constructor(private pool: Pool) {}
create = async (data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
const startTime = data.start ? new Date(data.start) : null;
const endTime = data.end ? new Date(data.end) : null;
// One-time windows expire at end time
const expiry = data.repeat === 0 ? endTime : null;
const result = await this.pool.query<MaintenanceWindowRow>(
`INSERT INTO maintenance_windows (monitor_id, team_id, active, name, duration, duration_unit, repeat, start_time, end_time, expiry)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING ${COLUMNS}`,
[
data.monitorId,
data.teamId,
data.active ?? true,
data.name ?? null,
data.duration ?? null,
data.durationUnit ?? null,
data.repeat ?? 0,
startTime,
endTime,
expiry,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create maintenance window", status: 500 });
}
return this.toEntity(row);
};
findById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE id = $1 AND team_id = $2`, [
id,
teamId,
]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance Window not found", status: 404 });
}
return this.toEntity(row);
};
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE monitor_id = $1 AND team_id = $2`, [
monitorId,
teamId,
]);
return result.rows.map(this.toEntity);
};
findByTeamId = async (
teamId: string,
page: number,
rowsPerPage: number,
field?: string,
order?: string,
active?: boolean
): Promise<MaintenanceWindow[]> => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
let paramIndex = 2;
if (active !== undefined) {
conditions.push(`active = $${paramIndex++}`);
values.push(active);
}
const fieldMap: Record<string, string> = {
createdAt: "created_at",
name: "name",
start: "start_time",
end: "end_time",
active: "active",
};
const sortColumn = field ? (fieldMap[field] ?? "created_at") : "created_at";
const sortDirection = order === "asc" ? "ASC" : "DESC";
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
const result = await this.pool.query<MaintenanceWindowRow>(
`SELECT ${COLUMNS} FROM maintenance_windows
WHERE ${conditions.join(" AND ")}
ORDER BY ${sortColumn} ${sortDirection}
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
[...values, rowsPerPage, offset]
);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, teamId: string, patch: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof MaintenanceWindow, string, boolean][] = [
["active", "active", false],
["name", "name", false],
["duration", "duration", false],
["durationUnit", "duration_unit", false],
["repeat", "repeat", false],
["start", "start_time", true],
["end", "end_time", true],
];
for (const [key, column, isDate] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(isDate ? new Date(patch[key] as string) : patch[key]);
}
}
if (sets.length === 0) {
return this.findById(id, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<MaintenanceWindowRow>(
`UPDATE maintenance_windows SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
const result = await this.pool.query<MaintenanceWindowRow>(
`DELETE FROM maintenance_windows WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`,
[id, teamId]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 });
}
return this.toEntity(row);
};
countByTeamId = async (teamId: string, active?: boolean): Promise<number> => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
if (active !== undefined) {
conditions.push(`active = $2`);
values.push(active);
}
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM maintenance_windows WHERE ${conditions.join(" AND ")}`, values);
return result.rows[0].count;
};
private toEntity = (row: MaintenanceWindowRow): MaintenanceWindow => ({
id: row.id,
monitorId: row.monitor_id,
teamId: row.team_id,
active: row.active,
name: row.name,
duration: row.duration,
durationUnit: row.duration_unit,
repeat: row.repeat,
start: row.start_time.toISOString(),
end: row.end_time.toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,11 +1,12 @@
import type { MonitorStats } from "@/types/index.js";
import type { MonitorStats, CheckResultInput } from "@/types/index.js";
export interface IMonitorStatsRepository {
// create
create(data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// single fetch
findByMonitorId(monitorId: string): Promise<MonitorStats>;
// update
updateByMonitorId(monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// Upsert must be atomic to prevent race conditions
updateByMonitorId(monitorId: string, result: CheckResultInput): Promise<MonitorStats>;
// delete
deleteByMonitorId(monitorId: string): Promise<MonitorStats>;
deleteByMonitorIds(monitorIds: string[]): Promise<number>;
@@ -1,5 +1,5 @@
import { type MonitorStatsDocument, MonitorStatsModel } from "@/db/models/index.js";
import type { MonitorStats } from "@/types/index.js";
import type { MonitorStats, CheckResultInput } from "@/types/index.js";
import { IMonitorStatsRepository } from "@/repositories/index.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
@@ -46,10 +46,48 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository {
return this.toEntity(monitorStats);
};
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: new mongoose.Types.ObjectId(monitorId) }, { $set: data }, { new: true });
updateByMonitorId = async (monitorId: string, result: CheckResultInput): Promise<MonitorStats> => {
const { status, responseTime, now } = result;
const objectId = new mongoose.Types.ObjectId(monitorId);
const upDelta = status ? 1 : 0;
const downDelta = status ? 0 : 1;
const pipeline = [
{
$set: {
monitorId: objectId,
totalChecks: { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] },
totalUpChecks: { $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] },
totalDownChecks: { $add: [{ $ifNull: ["$totalDownChecks", 0] }, downDelta] },
avgResponseTime: {
$divide: [
{
$add: [{ $multiply: [{ $ifNull: ["$avgResponseTime", 0] }, { $ifNull: ["$totalChecks", 0] }] }, responseTime],
},
{ $add: [{ $ifNull: ["$totalChecks", 0] }, 1] },
],
},
uptimePercentage: {
$divide: [{ $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] }, { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] }],
},
maxResponseTime: { $max: [{ $ifNull: ["$maxResponseTime", 0] }, responseTime] },
lastResponseTime: responseTime,
lastCheckTimestamp: now,
timeOfLastFailure: status
? {
$cond: [{ $eq: [{ $ifNull: ["$timeOfLastFailure", 0] }, 0] }, now, "$timeOfLastFailure"],
}
: 0,
},
},
];
const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: objectId }, pipeline, {
upsert: true,
new: true,
setDefaultsOnInsert: true,
});
if (!updated) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
throw new AppError({ message: "Failed to apply check result to monitor stats", status: 500 });
}
return this.toEntity(updated);
};
@@ -1,140 +0,0 @@
import type { Pool } from "pg";
import type { IMonitorStatsRepository } from "./IMonitorStatsRepository.js";
import type { MonitorStats } from "@/types/monitorStats.js";
import { AppError } from "@/utils/AppError.js";
interface MonitorStatsRow {
id: string;
monitor_id: string;
avg_response_time: number;
max_response_time: number;
total_checks: number;
total_up_checks: number;
total_down_checks: number;
uptime_percentage: number;
last_check_timestamp: Date | null;
last_response_time: number;
time_of_last_failure: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure,
created_at, updated_at`;
export class TimescaleMonitorStatsRepository implements IMonitorStatsRepository {
constructor(private pool: Pool) {}
create = async (data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(
`INSERT INTO monitor_stats (monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING ${COLUMNS}`,
[
data.monitorId,
data.avgResponseTime,
data.maxResponseTime,
data.totalChecks,
data.totalUpChecks,
data.totalDownChecks,
data.uptimePercentage,
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
data.lastResponseTime,
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create monitor stats", status: 500 });
}
return this.toEntity(row);
};
findByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(`SELECT ${COLUMNS} FROM monitor_stats WHERE monitor_id = $1`, [monitorId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(
`UPDATE monitor_stats SET
avg_response_time = $2,
max_response_time = $3,
total_checks = $4,
total_up_checks = $5,
total_down_checks = $6,
uptime_percentage = $7,
last_check_timestamp = $8,
last_response_time = $9,
time_of_last_failure = $10,
updated_at = NOW()
WHERE monitor_id = $1
RETURNING ${COLUMNS}`,
[
monitorId,
data.avgResponseTime,
data.maxResponseTime,
data.totalChecks,
data.totalUpChecks,
data.totalDownChecks,
data.uptimePercentage,
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
data.lastResponseTime,
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
deleteByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(`DELETE FROM monitor_stats WHERE monitor_id = $1 RETURNING ${COLUMNS}`, [monitorId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
deleteByMonitorIds = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
return 0;
}
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = ANY($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM monitor_stats`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private toEntity = (row: MonitorStatsRow): MonitorStats => ({
id: row.id,
monitorId: row.monitor_id,
avgResponseTime: row.avg_response_time,
maxResponseTime: row.max_response_time,
totalChecks: Number(row.total_checks),
totalUpChecks: Number(row.total_up_checks),
totalDownChecks: Number(row.total_down_checks),
uptimePercentage: row.uptime_percentage,
lastCheckTimestamp: row.last_check_timestamp ? row.last_check_timestamp.getTime() : 0,
lastResponseTime: row.last_response_time,
timeOfLastFailure: row.time_of_last_failure ? row.time_of_last_failure.getTime() : undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,4 +1,4 @@
import { type MonitorType, type Monitor, type MonitorsSummary } from "@/types/index.js";
import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } from "@/types/index.js";
export interface TeamQueryConfig {
limit?: number;
@@ -29,7 +29,17 @@ export interface IMonitorsRepository {
// update
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
updateStatusWindowAndChecks(
monitorId: string,
teamId: string,
status: boolean,
checkSnapshot: CheckSnapshot,
windowSize: number,
maxRecentChecks: number,
statusPatch?: Partial<Monitor>
): Promise<Monitor>;
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]>;
// delete
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
@@ -182,6 +182,33 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.toEntity(updatedMonitor);
};
updateStatusWindowAndChecks = async (
monitorId: string,
teamId: string,
status: boolean,
checkSnapshot: CheckSnapshot,
windowSize: number,
maxRecentChecks: number,
statusPatch?: Partial<Monitor>
): Promise<Monitor> => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
{
$push: {
statusWindow: { $each: [status], $slice: -windowSize },
recentChecks: { $each: [checkSnapshot], $slice: -maxRecentChecks },
},
...(statusPatch && { $set: statusPatch }),
},
{ returnDocument: "after" }
);
if (!updatedMonitor) {
throw new AppError({ message: `Failed to update status and checks for monitor with id ${monitorId}`, status: 500 });
}
return this.toEntity(updatedMonitor);
};
togglePauseById = async (monitorId: string, teamId: string) => {
const monitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
@@ -207,6 +234,34 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.toEntity(monitor);
};
bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const filter = {
_id: { $in: objectIds },
teamId: new mongoose.Types.ObjectId(teamId),
isActive: pause, // Only pause if active (true), only resume if inactive (false)
};
const nextStatus = pause ? "paused" : "initializing";
const eligible = await MonitorModel.find(filter);
if (eligible.length === 0) return [];
const now = new Date();
await MonitorModel.updateMany(
{ _id: { $in: eligible.map((doc) => doc._id) } },
{ $set: { isActive: !pause, status: nextStatus, updatedAt: now } },
{ timestamps: false }
);
eligible.forEach((doc) => {
doc.isActive = !pause;
doc.status = nextStatus;
doc.updatedAt = now;
});
return this.mapDocuments(eligible);
};
deleteById = async (monitorId: string, teamId: string) => {
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
@@ -392,6 +447,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -452,6 +509,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
File diff suppressed because it is too large. Load diff
@@ -32,6 +32,8 @@ class MongoNotificationsRepository implements INotificationsRepository {
homeserverUrl: doc.homeserverUrl ?? undefined,
roomId: doc.roomId ?? undefined,
accessToken: doc.accessToken ?? undefined,
accountSid: doc.accountSid ?? undefined,
twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -1,140 +0,0 @@
import type { Pool } from "pg";
import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js";
import type { Notification, NotificationChannel } from "@/types/notification.js";
import { AppError } from "@/utils/AppError.js";
interface NotificationRow {
id: string;
user_id: string;
team_id: string;
type: NotificationChannel;
notification_name: string;
address: string | null;
phone: string | null;
homeserver_url: string | null;
room_id: string | null;
access_token: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone,
homeserver_url, room_id, access_token, created_at, updated_at`;
export class TimescaleNotificationsRepository implements INotificationsRepository {
constructor(private pool: Pool) {}
create = async (data: Partial<Notification>): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(
`INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING ${COLUMNS}`,
[
data.userId,
data.teamId,
data.type,
data.notificationName,
data.address ?? null,
data.phone ?? null,
data.homeserverUrl ?? null,
data.roomId ?? null,
data.accessToken ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create notification", status: 500 });
}
return this.toEntity(row);
};
findById = async (id: string, teamId: string): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = $1 AND team_id = $2`, [id, teamId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found", status: 404 });
}
return this.toEntity(row);
};
findNotificationsByIds = async (ids: string[]): Promise<Notification[]> => {
if (!ids.length) {
return [];
}
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = ANY($1)`, [ids]);
return result.rows.map(this.toEntity);
};
findByTeamId = async (teamId: string): Promise<Notification[]> => {
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE team_id = $1`, [teamId]);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, teamId: string, patch: Partial<Notification>): Promise<Notification> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof Notification, string][] = [
["type", "type"],
["notificationName", "notification_name"],
["address", "address"],
["phone", "phone"],
["homeserverUrl", "homeserver_url"],
["roomId", "room_id"],
["accessToken", "access_token"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(patch[key]);
}
}
if (sets.length === 0) {
return this.findById(id, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<NotificationRow>(
`UPDATE notifications SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found or could not be updated", status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string, teamId: string): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(`DELETE FROM notifications WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, [
id,
teamId,
]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found or could not be deleted", status: 404 });
}
return this.toEntity(row);
};
private toEntity = (row: NotificationRow): Notification => ({
id: row.id,
userId: row.user_id,
teamId: row.team_id,
type: row.type,
notificationName: row.notification_name,
address: row.address ?? undefined,
phone: row.phone ?? undefined,
homeserverUrl: row.homeserver_url ?? undefined,
roomId: row.room_id ?? undefined,
accessToken: row.access_token ?? undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,59 +0,0 @@
import type { Pool } from "pg";
import crypto from "crypto";
import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js";
import type { RecoveryToken } from "@/types/recoveryToken.js";
import { AppError } from "@/utils/AppError.js";
interface RecoveryTokenRow {
id: string;
email: string;
token: string;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, email, token, expiry, created_at, updated_at`;
export class TimescaleRecoveryTokensRepository implements IRecoveryTokensRepository {
constructor(private pool: Pool) {}
create = async (email: string): Promise<RecoveryToken> => {
const token = crypto.randomBytes(32).toString("hex");
const result = await this.pool.query<RecoveryTokenRow>(
`INSERT INTO recovery_tokens (email, token)
VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET token = $2, updated_at = NOW()
RETURNING ${COLUMNS}`,
[email, token]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create recovery token", status: 500 });
}
return this.toEntity(row);
};
findByToken = async (token: string): Promise<RecoveryToken> => {
const result = await this.pool.query<RecoveryTokenRow>(`SELECT ${COLUMNS} FROM recovery_tokens WHERE token = $1`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Recovery token not found", status: 404 });
}
return this.toEntity(row);
};
deleteManyByEmail = async (email: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM recovery_tokens WHERE email = $1`, [email]);
return result.rowCount ?? 0;
};
private toEntity = (row: RecoveryTokenRow): RecoveryToken => ({
id: row.id,
email: row.email,
token: row.token,
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,217 +0,0 @@
import type { Pool } from "pg";
import type { ISettingsRepository } from "./ISettingsRepository.js";
import type { Settings, SettingsUpdate } from "@/types/settings.js";
interface SettingsRow {
id: string;
check_ttl: number;
language: string | null;
jwt_secret: string | null;
pagespeed_api_key: string | null;
system_email_host: string | null;
system_email_port: number | null;
system_email_address: string | null;
system_email_password: string | null;
system_email_user: string | null;
system_email_connection_host: string | null;
system_email_tls_servername: string | null;
system_email_secure: boolean | null;
system_email_pool: boolean | null;
system_email_ignore_tls: boolean | null;
system_email_require_tls: boolean | null;
system_email_reject_unauthorized: boolean | null;
show_url: boolean | null;
version: string | null;
threshold_cpu_usage: number | null;
threshold_memory_usage: number | null;
threshold_disk_usage: number | null;
threshold_temperature: number | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key,
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
created_at, updated_at`;
export class TimescaleSettingsRepository implements ISettingsRepository {
constructor(private pool: Pool) {}
create = async (settings: Partial<Settings>): Promise<Settings> => {
const result = await this.pool.query<SettingsRow>(
`INSERT INTO app_settings (check_ttl, language, jwt_secret, pagespeed_api_key,
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
RETURNING ${COLUMNS}`,
[
settings.checkTTL ?? 30,
settings.language ?? null,
settings.jwtSecret ?? null,
settings.pagespeedApiKey ?? null,
settings.systemEmailHost ?? null,
settings.systemEmailPort ?? null,
settings.systemEmailAddress ?? null,
settings.systemEmailPassword ?? null,
settings.systemEmailUser ?? null,
settings.systemEmailConnectionHost ?? null,
settings.systemEmailTLSServername ?? null,
settings.systemEmailSecure ?? false,
settings.systemEmailPool ?? false,
settings.systemEmailIgnoreTLS ?? false,
settings.systemEmailRequireTLS ?? false,
settings.systemEmailRejectUnauthorized ?? true,
settings.showURL ?? false,
settings.version ?? 1,
settings.globalThresholds?.cpu ?? null,
settings.globalThresholds?.memory ?? null,
settings.globalThresholds?.disk ?? null,
settings.globalThresholds?.temperature ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new Error("Failed to create app settings");
}
return this.toEntity(row);
};
findSingleton = async (): Promise<Settings | null> => {
const result = await this.pool.query<SettingsRow>(`SELECT ${COLUMNS} FROM app_settings LIMIT 1`);
const row = result.rows[0];
if (!row) {
return null;
}
return this.toEntity(row);
};
update = async (settings: SettingsUpdate): Promise<Settings> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof SettingsUpdate, string][] = [
["checkTTL", "check_ttl"],
["language", "language"],
["jwtSecret", "jwt_secret"],
["pagespeedApiKey", "pagespeed_api_key"],
["systemEmailHost", "system_email_host"],
["systemEmailPort", "system_email_port"],
["systemEmailAddress", "system_email_address"],
["systemEmailPassword", "system_email_password"],
["systemEmailUser", "system_email_user"],
["systemEmailConnectionHost", "system_email_connection_host"],
["systemEmailTLSServername", "system_email_tls_servername"],
["systemEmailSecure", "system_email_secure"],
["systemEmailPool", "system_email_pool"],
["systemEmailIgnoreTLS", "system_email_ignore_tls"],
["systemEmailRequireTLS", "system_email_require_tls"],
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
["showURL", "show_url"],
["version", "version"],
];
for (const [key, column] of fieldMap) {
if (key in settings) {
sets.push(`${column} = $${paramIndex++}`);
// null means unset the field
values.push(settings[key] ?? null);
}
}
// Handle globalThresholds
if ("globalThresholds" in settings) {
const thresholds = settings.globalThresholds;
if (thresholds === null || thresholds === undefined) {
sets.push(`threshold_cpu_usage = NULL`);
sets.push(`threshold_memory_usage = NULL`);
sets.push(`threshold_disk_usage = NULL`);
sets.push(`threshold_temperature = NULL`);
} else {
if ("cpu" in thresholds) {
sets.push(`threshold_cpu_usage = $${paramIndex++}`);
values.push(thresholds.cpu ?? null);
}
if ("memory" in thresholds) {
sets.push(`threshold_memory_usage = $${paramIndex++}`);
values.push(thresholds.memory ?? null);
}
if ("disk" in thresholds) {
sets.push(`threshold_disk_usage = $${paramIndex++}`);
values.push(thresholds.disk ?? null);
}
if ("temperature" in thresholds) {
sets.push(`threshold_temperature = $${paramIndex++}`);
values.push(thresholds.temperature ?? null);
}
}
}
if (sets.length === 0) {
const existing = await this.findSingleton();
if (!existing) {
throw new Error("App settings not found");
}
return existing;
}
sets.push(`updated_at = NOW()`);
// Upsert: update the single row, or insert if none exists
const result = await this.pool.query<SettingsRow>(`UPDATE app_settings SET ${sets.join(", ")} RETURNING ${COLUMNS}`, values);
const row = result.rows[0];
if (!row) {
// No row existed, create one
return this.create(settings as Partial<Settings>);
}
return this.toEntity(row);
};
deleteLegacy = async (): Promise<boolean> => {
// No legacy rows in TimescaleDB — singleton is enforced by trigger
const result = await this.pool.query(`DELETE FROM app_settings WHERE version IS NULL`);
return (result.rowCount ?? 0) > 0;
};
private toEntity = (row: SettingsRow): Settings => ({
id: row.id,
checkTTL: row.check_ttl,
language: row.language ?? "",
jwtSecret: row.jwt_secret ?? undefined,
pagespeedApiKey: row.pagespeed_api_key ?? undefined,
systemEmailHost: row.system_email_host ?? undefined,
systemEmailPort: row.system_email_port ?? undefined,
systemEmailAddress: row.system_email_address ?? undefined,
systemEmailPassword: row.system_email_password ?? undefined,
systemEmailUser: row.system_email_user ?? undefined,
systemEmailConnectionHost: row.system_email_connection_host ?? undefined,
systemEmailTLSServername: row.system_email_tls_servername ?? undefined,
systemEmailSecure: row.system_email_secure ?? false,
systemEmailPool: row.system_email_pool ?? false,
systemEmailIgnoreTLS: row.system_email_ignore_tls ?? false,
systemEmailRequireTLS: row.system_email_require_tls ?? false,
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
showURL: row.show_url ?? false,
singleton: true,
version: Number(row.version ?? 1),
globalThresholds:
row.threshold_cpu_usage !== null ||
row.threshold_memory_usage !== null ||
row.threshold_disk_usage !== null ||
row.threshold_temperature !== null
? {
cpu: row.threshold_cpu_usage ?? undefined,
memory: row.threshold_memory_usage ?? undefined,
disk: row.threshold_disk_usage ?? undefined,
temperature: row.threshold_temperature ?? undefined,
}
: undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -59,6 +59,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
customCSS: doc.customCSS,
theme: doc.theme,
themeMode: doc.themeMode,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
@@ -1,260 +0,0 @@
import type { Pool } from "pg";
import type { IStatusPagesRepository } from "./IStatusPagesRepository.js";
import type { StatusPage, StatusPageType } from "@/types/statusPage.js";
import { AppError } from "@/utils/AppError.js";
const parsePostgresArray = (value: unknown): StatusPageType[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as StatusPageType[];
};
interface StatusPageRow {
id: string;
user_id: string;
team_id: string;
types: StatusPageType[];
company_name: string;
url: string;
timezone: string | null;
color: string;
logo_data: Buffer | null;
logo_content_type: string | null;
is_published: boolean;
show_charts: boolean;
show_uptime_percentage: boolean;
show_admin_login_link: boolean;
show_infrastructure: boolean;
custom_css: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`;
export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
constructor(private pool: Pool) {}
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const result = await this.pool.query<StatusPageRow>(
`INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING ${COLUMNS}`,
[
userId,
teamId,
data.type ?? ["uptime"],
data.companyName ?? "",
data.url ?? "",
data.timezone ?? null,
data.color ?? "#4169E1",
image?.buffer ?? null,
image?.mimetype ?? null,
data.isPublished ?? false,
data.showCharts ?? false,
data.showUptimePercentage ?? false,
data.showAdminLoginLink ?? false,
data.showInfrastructure ?? false,
data.customCSS ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create status page", status: 500 });
}
// Insert monitor associations
if (data.monitors?.length) {
for (const [i, monitorId] of data.monitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[row.id, monitorId, i]
);
}
}
if (data.subMonitors?.length) {
for (const [i, monitorId] of data.subMonitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[row.id, monitorId, i]
);
}
}
return this.loadEntity(row);
};
findByUrl = async (url: string): Promise<StatusPage> => {
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE url = $1`, [url]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.loadEntity(row);
};
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE team_id = $1`, [teamId]);
const pages: StatusPage[] = [];
for (const row of result.rows) {
pages.push(await this.loadEntity(row));
}
return pages;
};
updateById = async (
id: string,
teamId: string,
image: Express.Multer.File | undefined,
patch: Partial<StatusPage> & { removeLogo?: string }
): Promise<StatusPage> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof StatusPage, string][] = [
["type", "types"],
["companyName", "company_name"],
["url", "url"],
["timezone", "timezone"],
["color", "color"],
["isPublished", "is_published"],
["showCharts", "show_charts"],
["showUptimePercentage", "show_uptime_percentage"],
["showAdminLoginLink", "show_admin_login_link"],
["showInfrastructure", "show_infrastructure"],
["customCSS", "custom_css"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(patch[key]);
}
}
if (image) {
sets.push(`logo_data = $${paramIndex++}`);
values.push(image.buffer);
sets.push(`logo_content_type = $${paramIndex++}`);
values.push(image.mimetype);
} else if (patch.removeLogo === "true") {
sets.push(`logo_data = NULL`);
sets.push(`logo_content_type = NULL`);
}
if (sets.length > 0) {
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<StatusPageRow>(
`UPDATE status_pages SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
}
// Update monitor associations if provided
if (patch.monitors !== undefined) {
await this.pool.query(`DELETE FROM status_page_monitors WHERE status_page_id = $1`, [id]);
for (const [i, monitorId] of patch.monitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[id, monitorId, i]
);
}
}
if (patch.subMonitors !== undefined) {
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE status_page_id = $1`, [id]);
for (const [i, monitorId] of patch.subMonitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[id, monitorId, i]
);
}
}
// Re-fetch to return full entity
const freshResult = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
const freshRow = freshResult.rows[0];
if (!freshRow) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.loadEntity(freshRow);
};
deleteById = async (id: string, teamId: string): Promise<StatusPage> => {
// Load entity before delete (join tables cascade)
const existing = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
const row = existing.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
const entity = await this.loadEntity(row);
await this.pool.query(`DELETE FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
return entity;
};
removeMonitorFromStatusPages = async (monitorId: string): Promise<number> => {
// Return count of distinct status pages affected, matching Mongo's modifiedCount
const result = await this.pool.query(
`SELECT COUNT(DISTINCT status_page_id)::int AS count FROM (
SELECT status_page_id FROM status_page_monitors WHERE monitor_id = $1
UNION
SELECT status_page_id FROM status_page_sub_monitors WHERE monitor_id = $1
) affected`,
[monitorId]
);
const affectedCount = result.rows[0]?.count ?? 0;
await this.pool.query(`DELETE FROM status_page_monitors WHERE monitor_id = $1`, [monitorId]);
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE monitor_id = $1`, [monitorId]);
return affectedCount;
};
private loadEntity = async (row: StatusPageRow): Promise<StatusPage> => {
const [monitorsResult, subMonitorsResult] = await Promise.all([
this.pool.query(`SELECT monitor_id FROM status_page_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
this.pool.query(`SELECT monitor_id FROM status_page_sub_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
]);
return {
id: row.id,
userId: row.user_id,
teamId: row.team_id,
type: Array.isArray(row.types) ? row.types : parsePostgresArray(row.types),
companyName: row.company_name,
url: row.url,
timezone: row.timezone ?? undefined,
color: row.color,
monitors: monitorsResult.rows.map((r) => r.monitor_id),
subMonitors: subMonitorsResult.rows.map((r) => r.monitor_id),
originalMonitors: [],
logo: row.logo_data
? {
data: row.logo_data.toString("base64"),
contentType: row.logo_content_type ?? "",
}
: undefined,
isPublished: row.is_published,
showCharts: row.show_charts,
showUptimePercentage: row.show_uptime_percentage,
showAdminLoginLink: row.show_admin_login_link,
showInfrastructure: row.show_infrastructure,
customCSS: row.custom_css ?? "",
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
};
}
@@ -1,24 +0,0 @@
import type { Pool } from "pg";
import { ITeamsRepository } from "@/repositories/teams/ITeamsRepository.js";
import { Team } from "@/types/team.js";
export class TimescaleTeamsRepository implements ITeamsRepository {
constructor(private pool: Pool) {}
create = async (email: string): Promise<Team> => {
const result = await this.pool.query(`INSERT INTO teams (email) VALUES ($1) RETURNING id, email, created_at, updated_at`, [email]);
return this.toEntity(result.rows[0]);
};
findAllTeamIds = async (): Promise<string[]> => {
const result = await this.pool.query(`SELECT id FROM teams`);
return result.rows.map((row) => row.id);
};
private toEntity = (row: { id: string; email: string; created_at: Date; updated_at: Date }): Team => ({
id: row.id,
email: row.email,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,222 +0,0 @@
import type { Pool } from "pg";
import { IUsersRepository } from "./IUsersRepository.js";
import type { User, UserRole } from "@/types/user.js";
import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "TimescaleUsersRepository";
// pg returns custom enum arrays as strings like "{user,admin}" — parse to JS array
const parsePostgresArray = (value: unknown): UserRole[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as UserRole[];
};
interface UserRow {
id: string;
team_id: string;
first_name: string;
last_name: string;
email: string;
password: string;
avatar_image: string | null;
profile_image: Buffer | null;
profile_image_content_type: string | null;
is_active: boolean;
is_verified: boolean;
roles: UserRole[];
check_ttl: number | null;
created_at: Date;
updated_at: Date;
}
export class TimescaleUsersRepository implements IUsersRepository {
constructor(private pool: Pool) {}
create = async (user: Partial<User>, imageFile?: Express.Multer.File | null): Promise<User> => {
let avatarImage: string | undefined;
let profileImage: Buffer | undefined;
let profileImageContentType: string | undefined;
if (imageFile) {
profileImage = imageFile.buffer;
profileImageContentType = imageFile.mimetype;
avatarImage = await GenerateAvatarImage(imageFile);
}
const result = await this.pool.query<UserRow>(
`INSERT INTO users (team_id, first_name, last_name, email, password, avatar_image, profile_image, profile_image_content_type, is_active, is_verified, roles, check_ttl)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
[
user.teamId || null,
user.firstName,
user.lastName,
user.email,
user.password,
avatarImage || null,
profileImage || null,
profileImageContentType || null,
user.isActive ?? true,
user.isVerified ?? false,
user.role ?? ["user"],
user.checkTTL ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 });
}
const entity = this.toEntity(row);
entity.password = "";
return entity;
};
findByEmail = async (email: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users WHERE email = $1`,
[email]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findById = async (id: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users WHERE id = $1`,
[id]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findAll = async (): Promise<User[]> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users`
);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, patch: Partial<User & { deleteProfileImage?: boolean }>, file?: Express.Multer.File | null): Promise<User> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (ParseBoolean(patch.deleteProfileImage) === true) {
sets.push(`profile_image = NULL`);
sets.push(`profile_image_content_type = NULL`);
sets.push(`avatar_image = NULL`);
} else if (file) {
const avatarImage = await GenerateAvatarImage(file);
sets.push(`profile_image = $${paramIndex++}`);
values.push(file.buffer);
sets.push(`profile_image_content_type = $${paramIndex++}`);
values.push(file.mimetype);
sets.push(`avatar_image = $${paramIndex++}`);
values.push(avatarImage);
}
if (patch.firstName !== undefined) {
sets.push(`first_name = $${paramIndex++}`);
values.push(patch.firstName);
}
if (patch.lastName !== undefined) {
sets.push(`last_name = $${paramIndex++}`);
values.push(patch.lastName);
}
if (patch.email !== undefined) {
sets.push(`email = $${paramIndex++}`);
values.push(patch.email);
}
if (patch.password !== undefined) {
sets.push(`password = $${paramIndex++}`);
values.push(patch.password);
}
if (patch.isActive !== undefined) {
sets.push(`is_active = $${paramIndex++}`);
values.push(patch.isActive);
}
if (patch.isVerified !== undefined) {
sets.push(`is_verified = $${paramIndex++}`);
values.push(patch.isVerified);
}
if (patch.role !== undefined) {
sets.push(`roles = $${paramIndex++}`);
values.push(patch.role);
}
if (patch.teamId !== undefined) {
sets.push(`team_id = $${paramIndex++}`);
values.push(patch.teamId);
}
if (patch.checkTTL !== undefined) {
sets.push(`check_ttl = $${paramIndex++}`);
values.push(patch.checkTTL);
}
if (sets.length === 0) {
return this.findById(id);
}
sets.push(`updated_at = NOW()`);
values.push(id);
const result = await this.pool.query<UserRow>(
`UPDATE users SET ${sets.join(", ")} WHERE id = $${paramIndex}
RETURNING id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`DELETE FROM users WHERE id = $1
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
[id]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findSuperAdmin = async (): Promise<boolean> => {
const result = await this.pool.query(`SELECT 1 FROM users WHERE 'superadmin' = ANY(roles) LIMIT 1`);
return (result.rowCount ?? 0) > 0;
};
private toEntity = (row: UserRow): User => ({
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
email: row.email,
password: row.password,
avatarImage: row.avatar_image ?? undefined,
profileImage: undefined,
isActive: row.is_active,
isVerified: row.is_verified,
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
teamId: row.team_id ?? "",
checkTTL: row.check_ttl ?? undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
+2 -2
View File
@@ -32,8 +32,8 @@ class AuthRoutes {
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser);
this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser);
}
getRouter() {
+1
View File
@@ -31,6 +31,7 @@ class MonitorRoutes {
// General monitor routes
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors);
// Util routes
this.router.get("/certificate/:monitorId", (req, res, next) => {
+10 -10
View File
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.accessibility = (categories.accessibility?.score || 0) * 100;
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
check.seo = (categories.seo?.score || 0) * 100;
check.performance = (categories.performance?.score || 0) * 100;
check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
cls: mapAudit(audits["cumulative-layout-shift"]),
si: mapAudit(audits["speed-index"]),
fcp: mapAudit(audits["first-contentful-paint"]),
lcp: mapAudit(audits["largest-contentful-paint"]),
tbt: mapAudit(audits["total-blocking-time"]),
};
}
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload?.errors
? hardwarePayload.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu;
check.memory = memory;
@@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService {
}
}
if (decision.shouldResolveIncident) {
if (!activeIncident) {
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
if (!decision.shouldResolveIncident || !activeIncident) {
return null;
}
return null;
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
};
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
+54 -41
View File
@@ -9,7 +9,8 @@ import type {
GamesMap,
GroupedGeoCheckResult,
} from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js";
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js";
import type { GeoContinent } from "@/types/geoCheck.js";
import type {
IChecksRepository,
@@ -23,12 +24,14 @@ import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "../infrastructure/emailService.js";
import { ILogger } from "@/utils/logger.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;
@@ -74,6 +77,7 @@ export interface IMonitorService {
// update
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>;
// delete
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
@@ -91,7 +95,6 @@ export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue;
private emailService: IEmailService;
private logger: ILogger;
private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
@@ -103,7 +106,6 @@ export class MonitorService implements IMonitorService {
constructor({
jobQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -114,7 +116,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository,
}: {
jobQueue: ISuperSimpleQueue;
emailService: IEmailService;
logger: ILogger;
games: GamesMap;
monitorsRepository: IMonitorsRepository;
@@ -125,7 +126,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository: IIncidentsRepository;
}) {
this.jobQueue = jobQueue;
this.emailService = emailService;
this.logger = logger;
this.games = games;
this.monitorsRepository = monitorsRepository;
@@ -176,7 +176,7 @@ export class MonitorService implements IMonitorService {
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) {
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
}
@@ -185,8 +185,7 @@ export class MonitorService implements IMonitorService {
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
const demoData = demoMonitorsData;
const monitors = demoData.map((monitor) => ({
const monitors = demoMonitorsData.map((monitor) => ({
userId,
teamId,
name: monitor.name,
@@ -214,22 +213,14 @@ export class MonitorService implements IMonitorService {
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
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);
if (
checksData.monitorType !== "http" &&
checksData.monitorType !== "ping" &&
checksData.monitorType !== "docker" &&
checksData.monitorType !== "port" &&
checksData.monitorType !== "game" &&
checksData.monitorType !== "grpc" &&
checksData.monitorType !== "websocket"
) {
if (!isUptimeChecksResult(checksData)) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
@@ -263,7 +254,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
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,
@@ -305,7 +296,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
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,
@@ -346,7 +337,7 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] };
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id,
@@ -360,8 +351,7 @@ export class MonitorService implements IMonitorService {
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
return await this.monitorsRepository.findById(monitorId, teamId);
};
getMonitorsByTeamId = async ({
@@ -373,11 +363,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[];
filter?: string;
}): Promise<Monitor[] | null> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
type,
filter,
});
return monitors;
return await this.monitorsRepository.findByTeamId(teamId, { type, filter });
};
getMonitorsWithChecksByTeamId = async ({
@@ -432,8 +418,7 @@ export class MonitorService implements IMonitorService {
};
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => {
const groups = await this.monitorsRepository.findGroupsByTeamId(teamId);
return groups;
return await this.monitorsRepository.findGroupsByTeamId(teamId);
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
@@ -474,6 +459,43 @@ export class MonitorService implements IMonitorService {
return monitor;
};
bulkPauseMonitors = async ({
teamId,
monitorIds,
pause,
}: {
teamId: string;
monitorIds: string[];
pause: boolean;
}): Promise<{ monitors: Monitor[]; failedCount: number }> => {
const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause);
const results = await Promise.allSettled(
monitors.map(async (monitor) => {
if (monitor.isActive) {
await this.jobQueue.resumeJob(monitor);
} else {
await this.jobQueue.pauseJob(monitor);
}
})
);
let failedCount = 0;
results.forEach((result, index) => {
if (result.status === "rejected") {
failedCount++;
this.logger.error({
message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`,
service: SERVICE_NAME,
method: "bulkPauseMonitors",
stack: result.reason instanceof Error ? result.reason.stack : undefined,
});
}
});
return { monitors, failedCount };
};
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) => {
@@ -574,15 +596,6 @@ export class MonitorService implements IMonitorService {
const createdMonitors = await this.createMonitors(cleanedMonitors);
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({
message: "Failed to import any monitors. Please check the file format and try again.",
service: SERVICE_NAME,
method: "importMonitorsFromJSON",
status: 400,
});
}
return { imported: createdMonitors.length, errors };
return { imported: createdMonitors!.length, errors };
};
}
@@ -1,5 +1,7 @@
import { type IStatusPagesRepository } from "@/repositories/index.js";
import { StatusPage } from "@/types/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js";
export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
getStatusPageByUrl(url: string): Promise<StatusPage>;
@@ -11,29 +13,52 @@ export interface IStatusPageService {
export class StatusPageService implements IStatusPageService {
private statusPagesRepository: IStatusPagesRepository;
constructor(statusPagesRepository: IStatusPagesRepository) {
private settingsService: ISettingsService;
constructor(statusPagesRepository: IStatusPagesRepository, settingsService: ISettingsService) {
this.statusPagesRepository = statusPagesRepository;
this.settingsService = settingsService;
}
private withoutThemeFields = (data: Partial<StatusPage>): Partial<StatusPage> => {
const { theme: _theme, themeMode: _themeMode, ...rest } = data;
return rest;
};
private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
...statusPage,
theme: DEFAULT_STATUS_PAGE_THEME,
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
});
private normalizeTheme = (statusPage: StatusPage): StatusPage =>
this.settingsService.areStatusPageThemesEnabled() ? statusPage : this.applyDefaultTheme(statusPage);
private normalizeInput = (data: Partial<StatusPage>): Partial<StatusPage> =>
this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data);
createStatusPage = async (
userId: string,
teamId: string,
image: Express.Multer.File | undefined,
data: Partial<StatusPage>
): Promise<StatusPage> => {
return await this.statusPagesRepository.create(userId, teamId, image, data);
const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data));
return this.normalizeTheme(created);
};
getStatusPageByUrl = async (url: string): Promise<StatusPage> => {
return await this.statusPagesRepository.findByUrl(url);
const statusPage = await this.statusPagesRepository.findByUrl(url);
return this.normalizeTheme(statusPage);
};
getStatusPagesByTeamId = async (teamId: string): Promise<StatusPage[]> => {
return await this.statusPagesRepository.findByTeamId(teamId);
const statusPages = await this.statusPagesRepository.findByTeamId(teamId);
return statusPages.map((sp) => this.normalizeTheme(sp));
};
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
return await this.statusPagesRepository.updateById(id, teamId, image, data);
const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data));
return this.normalizeTheme(updated);
};
deleteStatusPage = async (statusPageId: string, teamId: string): Promise<StatusPage> => {
+8 -24
View File
@@ -110,10 +110,7 @@ export class UserService implements IUserService {
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
};
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
@@ -253,14 +250,10 @@ export class UserService implements IUserService {
currentUserEmail: string
) => {
// Change Password check
if (updates?.password && updates?.newPassword) {
// Get user's email
// Add user email to body for DB operation
if (updates.password && updates.newPassword) {
updates.email = currentUserEmail;
// Get user
const user = await this.usersRepository.findByEmail(currentUserEmail);
// Compare passwords
const match = await bcrypt.compare(updates?.password, user.password);
const match = await bcrypt.compare(updates.password, user.password);
// If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor
if (!match) {
@@ -286,7 +279,7 @@ export class UserService implements IUserService {
const recoveryToken = await this.recoveryTokensRepository.create(email);
const name = user.firstName;
const settings = this.settingsService.getSettings();
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
const url = `${settings.clientHost}/set-new-password/${recoveryToken.token}`;
const html = await this.emailService.buildEmail("passwordResetTemplate", {
name,
@@ -336,17 +329,10 @@ export class UserService implements IUserService {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
// 1. Find all the monitors associated with the team ID if superadmin
const res = await this.monitorsRepository.findByTeamId(teamId, {});
if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts
if (res && res.length > 0) {
await Promise.all(
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor);
})
);
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors) {
await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
}
}
// 6. Delete the user by id
@@ -410,9 +396,7 @@ export class UserService implements IUserService {
if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
}
const user = await this.usersRepository.findById(userId);
return user;
return await this.usersRepository.findById(userId);
};
editUserById = async (userId: string, patch: Partial<User>) => {
+2
View File
@@ -30,6 +30,8 @@ export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
export * from "@/service/infrastructure/notificationProviders/pushover.js";
export * from "@/service/infrastructure/notificationProviders/twilio.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -28,6 +28,8 @@ type QueueJobSummary = {
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedAt: number | null;
runCount: number;
@@ -37,6 +39,7 @@ type QueueJobSummary = {
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
};
export interface ISuperSimpleQueue {
@@ -51,7 +54,6 @@ export interface ISuperSimpleQueue {
getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>;
flushQueues(): Promise<{ success: boolean }>;
obliterate(): Promise<void>;
}
export class SuperSimpleQueue implements ISuperSimpleQueue {
@@ -73,22 +75,91 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return SuperSimpleQueue.SERVICE_NAME;
}
private registerListeners = () => {
this.scheduler.on("scheduler:start", () => {
this.logger.info({
message: "Scheduler started",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:stop", () => {
this.logger.info({
message: "Scheduler stopped",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:error", (error) => {
this.logger.error({
message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:abort", (job, reason) => {
this.logger.warn({
message: `${job.id} aborted: ${reason}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:attempt", (job, attempt) => {
this.logger.debug({
message: `${job.id} attempt ${attempt}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:complete", (job) => {
this.logger.debug({
message: `${job.id} completed successfully`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:exhausted", (job, error) => {
this.logger.error({
message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:fail", (job, error, attempt) => {
this.logger.warn({
message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:start", (job) => {
this.logger.debug({
message: `${job.id} started`,
service: SERVICE_NAME,
});
});
};
static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
const scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
// dbUri: envSettings.dbConnectionString,
});
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
await instance.init();
return instance;
}
init = async () => {
try {
this.scheduler.start();
this.registerListeners();
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
@@ -155,8 +226,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to pause monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to pause geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
@@ -170,9 +253,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
await this.scheduler.resumeJob(`${monitor.id}-geo`);
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to resume geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -237,8 +331,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
failedAt: job.lastFailedAt ?? null,
failCount,
failReason: job.lastFailReason ?? null,
@@ -270,10 +364,13 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorInterval: job?.data?.interval || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorInterval: job.data?.interval || null,
monitorGeoInterval: job.data?.geoCheckInterval || null,
monitorActive: job.data?.isActive ?? null,
active: job.active,
repeat: job.repeat ?? null,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
failCount: job.failCount ?? 0,
@@ -294,12 +391,4 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
success: Boolean(stopRes && flushRes && initRes),
};
};
obliterate = async () => {
this.logger.warn({
message: "obliterate method not implemented",
service: SERVICE_NAME,
method: "obliterate",
});
};
}
@@ -168,15 +168,17 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
});
}
// Step 7. Handle incidents (best effort, don't wait)
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
// Step 7. Handle incidents
try {
await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status);
} catch (error: unknown) {
this.logger.warn({
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
});
});
}
} catch (error: unknown) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
@@ -377,7 +379,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return true;
}
}
return false;
return acc;
}
return acc;
}, false);
@@ -9,7 +9,6 @@ const SERVICE_NAME = "BufferService";
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
removeCheckFromBuffer(check: Check): boolean;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
flushGeoBuffer(): Promise<void>;
@@ -72,44 +71,6 @@ export class BufferService implements IBufferService {
}
}
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
return false;
}
const index = this.buffer.findIndex((check) => {
if (checkToRemove.id && check.id) {
return check.id.toString() === checkToRemove.id.toString();
}
return (
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
check.metadata.type === checkToRemove.metadata.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
check.message === checkToRemove.message
);
});
if (index !== -1) {
this.buffer.splice(index, 1);
return true;
}
return false;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
}
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -35,7 +35,6 @@ export class EmailService implements IEmailService {
private logger: ILogger;
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
constructor(
settingsService: ISettingsService,
@@ -54,7 +53,6 @@ export class EmailService implements IEmailService {
this.nodemailer = nodemailer;
this.logger = logger;
this.templateLookup = {};
this.loadTemplate = () => undefined;
this.init();
}
@@ -63,7 +61,7 @@ export class EmailService implements IEmailService {
}
init = () => {
this.loadTemplate = (templateName) => {
const loadTemplate = (templateName: string) => {
try {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
@@ -79,12 +77,12 @@ export class EmailService implements IEmailService {
};
this.templateLookup = {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
};
};
@@ -120,6 +118,7 @@ export class EmailService implements IEmailService {
systemEmailPool,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailTLSServername,
@@ -160,14 +159,17 @@ export class EmailService implements IEmailService {
return false;
}
const trimmedDisplayName = systemEmailDisplayName?.trim();
const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress;
try {
const info = await this.transporter.sendMail({
to: to,
from: systemEmailAddress,
from: from,
subject: subject,
html: html,
});
return info?.messageId;
return info.messageId;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -0,0 +1,104 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
const SERVICE_NAME = "DNSProvider";
export class DNSProvider implements IStatusProvider<DNSStatusPayload> {
readonly type = "dns";
constructor(private createResolver: () => Resolver) {}
supports(type: MonitorType) {
return type === "dns";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DNSStatusPayload>> {
try {
const { url, dnsServer, dnsRecordType = "A" } = monitor;
if (!dnsServer) {
throw new Error("DNS server is required for DNS monitoring");
}
let hostname: string;
try {
const withScheme = /^[a-z]+:\/\//i.test(url) ? url : `http://${url}`; // Ensure URL has a scheme for proper parsing
hostname = new URL(withScheme).hostname;
} catch {
hostname = url;
}
const resolver = this.createResolver();
resolver.setServers([dnsServer]);
const {
response: results,
responseTime,
error,
} = await timeRequest(() => {
switch (dnsRecordType.toUpperCase()) {
case "A":
return resolver.resolve4(hostname);
case "AAAA":
return resolver.resolve6(hostname);
case "CNAME":
return resolver.resolveCname(hostname);
case "MX":
return resolver.resolveMx(hostname);
case "TXT":
return resolver.resolveTxt(hostname);
case "NS":
return resolver.resolveNs(hostname);
default:
return resolver.resolve(hostname, dnsRecordType);
}
});
if (error) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: error instanceof Error ? error.message : String(error),
responseTime,
payload: {
hostname,
dnsServer,
recordType: dnsRecordType,
resolved: false,
results,
},
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: true,
code: 200,
message: "Success",
responseTime,
payload: {
hostname,
dnsServer,
recordType: dnsRecordType,
resolved: true,
results,
},
};
} catch (error: unknown) {
const originalMessage = error instanceof Error ? error.message : String(error);
throw new AppError({
message: originalMessage || "Error performing DNS check",
status: 500,
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url },
});
}
}
}
@@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
if (matchTypes.length > 1) {
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
const matchUsed = exactIdMatch ? "exact ID" : "exact name";
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
this.logger.warn({
message,
@@ -138,11 +139,24 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
};
}
if (!response) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: "No response from Docker container inspect",
responseTime,
payload: null,
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response?.State?.Status === "running",
status: response.State?.Status === "running",
code: 200,
message: "Docker container status fetched successfully",
responseTime,
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" });
}
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const target = `${host}:${port}`;
const currentFilePath = fileURLToPath(import.meta.url);
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more