mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-01-25 11:19:16 -06:00
Merge pull request #2977 from bluewave-labs/feat/v2/services
feat/v2/services
This commit is contained in:
1411
server/package-lock.json
generated
1411
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/nodemailer": "7.0.1",
|
||||
"@types/ping": "0.4.4",
|
||||
"c8": "10.1.3",
|
||||
"chai": "5.2.0",
|
||||
"eslint": "^9.17.0",
|
||||
|
||||
25
server/src/config/index.ts
Normal file
25
server/src/config/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import dotenv from "dotenv";
|
||||
|
||||
export interface IEevConfig {
|
||||
NODE_ENV: string;
|
||||
JWT_SECRET: string;
|
||||
PORT: number;
|
||||
PAGESPEED_API_KEY: string;
|
||||
SMTP_HOST: string;
|
||||
SMTP_PORT: number;
|
||||
SMTP_USER: string;
|
||||
SMTP_PASS: string;
|
||||
}
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config: IEevConfig = {
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
JWT_SECRET: process.env.JWT_SECRET || "your_jwt_secret",
|
||||
PORT: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
|
||||
PAGESPEED_API_KEY: process.env.PAGESPEED_API_KEY || "",
|
||||
SMTP_HOST: process.env.SMTP_HOST || "smtp.example.com",
|
||||
SMTP_PORT: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : 587,
|
||||
SMTP_USER: process.env.SMTP_USER || "user@example.com",
|
||||
SMTP_PASS: process.env.SMTP_PASS || "your_smtp_password",
|
||||
};
|
||||
0
server/src/routes/v2/auth.ts
Normal file
0
server/src/routes/v2/auth.ts
Normal file
206
server/src/service/v2/business/AuthService.ts
Normal file
206
server/src/service/v2/business/AuthService.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { User, Role, ITokenizedUser, Monitor, Check, NotificationChannel } from "../../../db/v2/models/index.js";
|
||||
import ApiError from "../../../utils/ApiError.js";
|
||||
import { Types } from "mongoose";
|
||||
import { IJobQueue } from "../infrastructure/JobQueue.js";
|
||||
|
||||
export const PERMISSIONS = {
|
||||
users: {
|
||||
all: "users.*",
|
||||
create: "users.create",
|
||||
view: "users.view",
|
||||
update: "users.update",
|
||||
delete: "users.delete",
|
||||
},
|
||||
|
||||
monitors: {
|
||||
all: "monitors.*",
|
||||
create: "monitors.create",
|
||||
view: "monitors.view",
|
||||
update: "monitors.update",
|
||||
delete: "monitors.delete",
|
||||
},
|
||||
notifications: {
|
||||
all: "notifications.*",
|
||||
create: "notifications.create",
|
||||
view: "notifications.view",
|
||||
update: "notifications.update",
|
||||
delete: "notifications.delete",
|
||||
},
|
||||
checks: {
|
||||
all: "checks.*",
|
||||
create: "checks.create",
|
||||
view: "checks.view",
|
||||
update: "checks.update",
|
||||
delete: "checks.delete",
|
||||
},
|
||||
statusPages: {
|
||||
all: "statusPages.*",
|
||||
create: "statusPages.create",
|
||||
view: "statusPages.view",
|
||||
update: "statusPages.update",
|
||||
delete: "statusPages.delete",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_ROLES = [
|
||||
{
|
||||
name: "SuperAdmin",
|
||||
description: "Super admin with all permissions",
|
||||
permissions: ["*"],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: "Admin",
|
||||
description: "Admin with full permissions",
|
||||
permissions: [PERMISSIONS.monitors.all, PERMISSIONS.users.all],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: "Manager",
|
||||
description: "Can manage users",
|
||||
permissions: [PERMISSIONS.users.create, PERMISSIONS.users.update, PERMISSIONS.monitors.all],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: "Member",
|
||||
description: "Basic team member",
|
||||
permissions: [PERMISSIONS.users.update, PERMISSIONS.monitors.create, PERMISSIONS.monitors.view, PERMISSIONS.monitors.update],
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
export type RegisterData = {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
roles?: Types.ObjectId[]; // Optional roles for invite-based registration
|
||||
};
|
||||
|
||||
export type LoginData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type AuthResult = ITokenizedUser;
|
||||
|
||||
export interface IAuthService {
|
||||
register(signupData: RegisterData): Promise<ITokenizedUser>;
|
||||
registerWithInvite(signupData: RegisterData): Promise<ITokenizedUser>;
|
||||
login(loginData: LoginData): Promise<ITokenizedUser>;
|
||||
cleanup(): Promise<void>;
|
||||
cleanMonitors(): Promise<void>;
|
||||
}
|
||||
|
||||
class AuthService implements IAuthService {
|
||||
private jobQueue: IJobQueue;
|
||||
constructor(jobQueue: IJobQueue) {
|
||||
this.jobQueue = jobQueue;
|
||||
}
|
||||
|
||||
async register(signupData: RegisterData): Promise<ITokenizedUser> {
|
||||
const userCount = await User.countDocuments();
|
||||
|
||||
if (userCount > 0) {
|
||||
throw new Error("Registration is closed. Please request an invite.");
|
||||
}
|
||||
|
||||
const { email, firstName, lastName, password } = signupData;
|
||||
|
||||
// Create all default roles
|
||||
const rolePromises = DEFAULT_ROLES.map((roleData) =>
|
||||
new Role({
|
||||
...roleData,
|
||||
}).save()
|
||||
);
|
||||
const roles = await Promise.all(rolePromises);
|
||||
|
||||
// Hash password and create user
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Find admin role and assign to first user
|
||||
const superAdminRole = roles.find((role) => role.name === "SuperAdmin");
|
||||
|
||||
const user = new User({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
passwordHash,
|
||||
roles: [superAdminRole!._id],
|
||||
});
|
||||
|
||||
const savedUser = await user.save();
|
||||
return {
|
||||
sub: savedUser._id.toString(),
|
||||
roles: savedUser.roles.map((role) => role.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
async registerWithInvite(signupData: RegisterData): Promise<ITokenizedUser> {
|
||||
const { email, firstName, lastName, password, roles } = signupData;
|
||||
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
const user = new User({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
passwordHash,
|
||||
roles: roles || [],
|
||||
});
|
||||
try {
|
||||
const savedUser = await user.save();
|
||||
return {
|
||||
sub: savedUser._id.toString(),
|
||||
roles: savedUser.roles.map((role) => role.toString()),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.code === 11000) {
|
||||
const dupError = new ApiError("Email already in use", 409);
|
||||
dupError.stack = error?.stack;
|
||||
throw dupError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async login(loginData: LoginData): Promise<ITokenizedUser> {
|
||||
const { email, password } = loginData;
|
||||
|
||||
// Find user by email
|
||||
const user = await User.findOne({ email });
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid email or password");
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("Invalid email or password");
|
||||
}
|
||||
|
||||
return {
|
||||
sub: user._id.toString(),
|
||||
roles: user.roles.map((role) => role.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
await User.deleteMany({});
|
||||
await Role.deleteMany({});
|
||||
await Monitor.deleteMany({});
|
||||
await Check.deleteMany({});
|
||||
await NotificationChannel.deleteMany({});
|
||||
await this.jobQueue.flush();
|
||||
}
|
||||
|
||||
async cleanMonitors() {
|
||||
await Monitor.deleteMany({});
|
||||
await Check.deleteMany({});
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthService;
|
||||
132
server/src/service/v2/business/CheckService.ts
Normal file
132
server/src/service/v2/business/CheckService.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { json } from "stream/consumers";
|
||||
import { ICheck, Check, Monitor } from "../../../db/v2/models/index.js";
|
||||
import type { ISystemInfo, ICaptureInfo } from "../../../db/v2/models/index.js";
|
||||
import { MonitorType } from "../../../db/v2/models/monitors/Monitor.js";
|
||||
import { StatusResponse } from "../infrastructure/NetworkService.js";
|
||||
import type { ICapturePayload, ILighthousePayload } from "../infrastructure/NetworkService.js";
|
||||
import mongoose from "mongoose";
|
||||
|
||||
export interface ICheckService {
|
||||
buildCheck: (statusResponse: StatusResponse, type: MonitorType) => Promise<ICheck>;
|
||||
cleanupOrphanedChecks: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
class CheckService implements ICheckService {
|
||||
private isCapturePayload = (payload: any): payload is ICapturePayload => {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
|
||||
if (!("data" in payload) || typeof payload.data !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = payload.data as Partial<ISystemInfo>;
|
||||
if (!data.cpu || typeof data.cpu !== "object" || typeof data.cpu.usage_percent !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.memory || typeof data.memory !== "object" || typeof data.memory.usage_percent !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.disk && !Array.isArray(data.disk)) {
|
||||
return false;
|
||||
}
|
||||
if (data.net && !Array.isArray(data.net)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!("capture" in payload) || typeof payload.capture !== "object") return false;
|
||||
const capture = payload.capture as Record<string, any>;
|
||||
if (typeof capture.version !== "string" || typeof capture.mode !== "string") return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private isPagespeedPayload = (payload: any): payload is ILighthousePayload => {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
|
||||
if (!("lighthouseResult" in payload) || typeof payload.lighthouseResult !== "object") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private buildBaseCheck = (statusResponse: StatusResponse) => {
|
||||
const monitorId = new mongoose.Types.ObjectId(statusResponse.monitorId);
|
||||
const check = new Check({
|
||||
monitorId: monitorId,
|
||||
type: statusResponse?.type,
|
||||
status: statusResponse?.status,
|
||||
message: statusResponse?.message,
|
||||
responseTime: statusResponse?.responseTime,
|
||||
timings: statusResponse?.timings,
|
||||
});
|
||||
return check;
|
||||
};
|
||||
|
||||
private buildInfrastructureCheck = (statusResponse: StatusResponse<ICapturePayload>) => {
|
||||
if (!this.isCapturePayload(statusResponse.payload)) {
|
||||
throw new Error("Invalid payload for infrastructure monitor");
|
||||
}
|
||||
const check = this.buildBaseCheck(statusResponse);
|
||||
check.system = statusResponse.payload.data;
|
||||
check.capture = statusResponse.payload.capture;
|
||||
return check;
|
||||
};
|
||||
|
||||
private buildPagespeedCheck = (statusResponse: StatusResponse<ILighthousePayload>) => {
|
||||
if (!this.isPagespeedPayload(statusResponse.payload)) {
|
||||
throw new Error("Invalid payload for pagespeed monitor");
|
||||
}
|
||||
const check = this.buildBaseCheck(statusResponse);
|
||||
const lighthouseResult = statusResponse?.payload?.lighthouseResult;
|
||||
check.lighthouse = {
|
||||
accessibility: lighthouseResult?.categories?.accessibility?.score || 0,
|
||||
bestPractices: lighthouseResult?.categories?.["best-practices"]?.score || 0,
|
||||
seo: lighthouseResult?.categories?.seo?.score || 0,
|
||||
performance: lighthouseResult?.categories?.performance?.score || 0,
|
||||
audits: {
|
||||
cls: lighthouseResult?.audits?.["cumulative-layout-shift"] || {},
|
||||
si: lighthouseResult?.audits?.["speed-index"] || {},
|
||||
fcp: lighthouseResult?.audits?.["first-contentful-paint"] || {},
|
||||
lcp: lighthouseResult?.audits?.["largest-contentful-paint"] || {},
|
||||
tbt: lighthouseResult?.audits?.["total-blocking-time"] || {},
|
||||
},
|
||||
};
|
||||
return check;
|
||||
};
|
||||
|
||||
buildCheck = async (statusResponse: StatusResponse, type: MonitorType): Promise<ICheck> => {
|
||||
switch (type) {
|
||||
case "infrastructure":
|
||||
return this.buildInfrastructureCheck(statusResponse as StatusResponse<ICapturePayload>);
|
||||
|
||||
case "pagespeed":
|
||||
return this.buildPagespeedCheck(statusResponse as StatusResponse<ILighthousePayload>);
|
||||
case "http":
|
||||
case "https":
|
||||
return this.buildBaseCheck(statusResponse);
|
||||
|
||||
case "ping":
|
||||
return this.buildBaseCheck(statusResponse);
|
||||
default:
|
||||
throw new Error(`Unsupported monitor type: ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
cleanupOrphanedChecks = async () => {
|
||||
try {
|
||||
const monitorIds = await Monitor.find().distinct("_id");
|
||||
const result = await Check.deleteMany({
|
||||
monitorId: { $nin: monitorIds },
|
||||
});
|
||||
console.log(`Deleted ${result.deletedCount} orphaned Checks.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up orphaned Checks:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default CheckService;
|
||||
61
server/src/service/v2/business/InviteService.ts
Normal file
61
server/src/service/v2/business/InviteService.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import crypto from "node:crypto";
|
||||
import { ITokenizedUser, IInvite, Invite } from "../../../db/v2/models/index.js";
|
||||
import ApiError from "../../../utils/ApiError.js";
|
||||
|
||||
export interface IInviteService {
|
||||
create: (tokenizedUser: ITokenizedUser, invite: IInvite) => Promise<{ token: string }>;
|
||||
getAll: () => Promise<IInvite[]>;
|
||||
get: (tokenHash: string) => Promise<IInvite>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
class InviteService implements IInviteService {
|
||||
constructor() {}
|
||||
|
||||
create = async (tokenizedUser: ITokenizedUser, inviteData: IInvite) => {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
try {
|
||||
const invite = await Invite.create({
|
||||
...inviteData,
|
||||
tokenHash,
|
||||
createdBy: tokenizedUser.sub,
|
||||
updatedBy: tokenizedUser.sub,
|
||||
});
|
||||
if (!invite) {
|
||||
throw new ApiError("Failed to create invite", 500);
|
||||
}
|
||||
return { token };
|
||||
} catch (error: any) {
|
||||
if (error?.code === 11000) {
|
||||
const dupError = new ApiError("Invite with this email already exists", 409);
|
||||
dupError.stack = error?.stack;
|
||||
throw dupError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
get = async (token: string) => {
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
const invite = await Invite.findOne({ tokenHash });
|
||||
if (!invite) {
|
||||
throw new ApiError("Invite not found", 404);
|
||||
}
|
||||
return invite;
|
||||
};
|
||||
|
||||
getAll = async () => {
|
||||
return Invite.find();
|
||||
};
|
||||
|
||||
delete = async (id: string) => {
|
||||
const result = await Invite.deleteOne({ _id: id });
|
||||
if (!result.deletedCount) {
|
||||
throw new ApiError("Invite not found", 404);
|
||||
}
|
||||
return result.deletedCount === 1;
|
||||
};
|
||||
}
|
||||
|
||||
export default InviteService;
|
||||
141
server/src/service/v2/business/MaintenanceService.ts
Normal file
141
server/src/service/v2/business/MaintenanceService.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { ITokenizedUser, IMaintenance, Maintenance } from "../../../db/v2/models/index.js";
|
||||
import ApiError from "../../../utils/ApiError.js";
|
||||
|
||||
export interface IMaintenanceService {
|
||||
create: (
|
||||
tokenizedUser: ITokenizedUser,
|
||||
|
||||
maintenance: IMaintenance
|
||||
) => Promise<IMaintenance>;
|
||||
getAll: () => Promise<IMaintenance[]>;
|
||||
get: (id: string) => Promise<IMaintenance>;
|
||||
toggleActive: (tokenizedUser: ITokenizedUser, id: string) => Promise<IMaintenance>;
|
||||
update: (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<IMaintenance>) => Promise<IMaintenance>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
isInMaintenance: (monitorId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
type MaintenanceCache = Map<string, IMaintenance[]>;
|
||||
|
||||
class MaintenanceService implements IMaintenanceService {
|
||||
private maintenanceCache: MaintenanceCache;
|
||||
private lastRefresh: number;
|
||||
private CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
this.maintenanceCache = new Map();
|
||||
this.lastRefresh = 0;
|
||||
}
|
||||
|
||||
create = async (tokenizedUser: ITokenizedUser, maintenanceData: IMaintenance) => {
|
||||
const maintenance = await Maintenance.create({
|
||||
...maintenanceData,
|
||||
createdBy: tokenizedUser.sub,
|
||||
updatedBy: tokenizedUser.sub,
|
||||
});
|
||||
return maintenance;
|
||||
};
|
||||
|
||||
get = async (id: string) => {
|
||||
const maintenance = await Maintenance.findById(id);
|
||||
if (!maintenance) {
|
||||
throw new ApiError("Maintenance not found", 404);
|
||||
}
|
||||
return maintenance;
|
||||
};
|
||||
|
||||
getAll = async () => {
|
||||
return Maintenance.find();
|
||||
};
|
||||
|
||||
toggleActive = async (tokenizedUser: ITokenizedUser, id: string) => {
|
||||
const updatedMaintenance = await Maintenance.findOneAndUpdate(
|
||||
{ _id: id },
|
||||
[
|
||||
{
|
||||
$set: {
|
||||
isActive: { $not: "$isActive" },
|
||||
updatedBy: tokenizedUser.sub,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
{ new: true }
|
||||
);
|
||||
if (!updatedMaintenance) {
|
||||
throw new ApiError("Maintenance not found", 404);
|
||||
}
|
||||
return updatedMaintenance;
|
||||
};
|
||||
|
||||
update = async (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<IMaintenance>) => {
|
||||
const allowedFields: (keyof IMaintenance)[] = ["name", "monitors", "startTime", "endTime", "isActive"];
|
||||
const safeUpdate: Partial<IMaintenance> = {};
|
||||
for (const field of allowedFields) {
|
||||
if (updateData[field] !== undefined) {
|
||||
(safeUpdate as any)[field] = updateData[field];
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMaintenance = await Maintenance.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
$set: {
|
||||
...safeUpdate,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: tokenizedUser.sub,
|
||||
},
|
||||
},
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!updatedMaintenance) {
|
||||
throw new ApiError("Failed to update maintenance", 500);
|
||||
}
|
||||
|
||||
return updatedMaintenance;
|
||||
};
|
||||
|
||||
delete = async (id: string) => {
|
||||
const result = await Maintenance.deleteOne({ _id: id });
|
||||
if (!result.deletedCount) {
|
||||
throw new ApiError("Maintenance not found", 404);
|
||||
}
|
||||
return result.deletedCount === 1;
|
||||
};
|
||||
|
||||
private refreshCache = async () => {
|
||||
const now = new Date();
|
||||
|
||||
const activeMaintenances = await Maintenance.find({
|
||||
isActive: true,
|
||||
startTime: { $lte: now },
|
||||
endTime: { $gte: now },
|
||||
}).lean();
|
||||
|
||||
// Reset cache
|
||||
const newCache = new Map();
|
||||
|
||||
for (const m of activeMaintenances) {
|
||||
for (const monitorId of m.monitors) {
|
||||
const key = monitorId.toString();
|
||||
if (!newCache.has(key)) newCache.set(key, []);
|
||||
newCache.get(key)!.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
this.maintenanceCache = newCache;
|
||||
this.lastRefresh = Date.now();
|
||||
};
|
||||
|
||||
isInMaintenance = async (monitorId: string) => {
|
||||
const now = Date.now();
|
||||
if (now - this.lastRefresh > this.CACHE_TTL_MS) {
|
||||
await this.refreshCache();
|
||||
}
|
||||
const maintenances = this.maintenanceCache.get(monitorId) || [];
|
||||
return maintenances.length > 0;
|
||||
};
|
||||
}
|
||||
|
||||
export default MaintenanceService;
|
||||
25
server/src/service/v2/business/MonitorStatsService.ts
Normal file
25
server/src/service/v2/business/MonitorStatsService.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { MonitorStats } from "../../../db/v2/models/index.js";
|
||||
import { Monitor } from "../../../db/v2/models/index.js";
|
||||
export interface IMonitorStatsService {
|
||||
cleanupOrphanedMonitorStats: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
class MonitorStatsService implements IMonitorStatsService {
|
||||
constructor() {}
|
||||
|
||||
async cleanupOrphanedMonitorStats() {
|
||||
try {
|
||||
const monitorIds = await Monitor.find().distinct("_id");
|
||||
const result = await MonitorStats.deleteMany({
|
||||
monitorId: { $nin: monitorIds },
|
||||
});
|
||||
console.log(`Deleted ${result.deletedCount} orphaned MonitorStats.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up orphaned MonitorStats:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MonitorStatsService;
|
||||
100
server/src/service/v2/business/NotificationChannelService.ts
Normal file
100
server/src/service/v2/business/NotificationChannelService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ITokenizedUser, INotificationChannel, NotificationChannel, Monitor } from "../../../db/v2/models/index.js";
|
||||
import ApiError from "../../../utils/ApiError.js";
|
||||
|
||||
export interface INotificationChannelService {
|
||||
create: (
|
||||
tokenizedUser: ITokenizedUser,
|
||||
|
||||
notificationChannel: INotificationChannel
|
||||
) => Promise<INotificationChannel>;
|
||||
getAll: () => Promise<INotificationChannel[]>;
|
||||
get: (id: string) => Promise<INotificationChannel>;
|
||||
toggleActive: (tokenizedUser: ITokenizedUser, id: string) => Promise<INotificationChannel>;
|
||||
update: (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<INotificationChannel>) => Promise<INotificationChannel>;
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
class NotificationChannelService implements INotificationChannelService {
|
||||
constructor() {}
|
||||
|
||||
create = async (tokenizedUser: ITokenizedUser, notificationChannelData: INotificationChannel) => {
|
||||
const notificationChannel = await NotificationChannel.create({
|
||||
...notificationChannelData,
|
||||
createdBy: tokenizedUser.sub,
|
||||
updatedBy: tokenizedUser.sub,
|
||||
});
|
||||
return notificationChannel;
|
||||
};
|
||||
|
||||
get = async (id: string) => {
|
||||
const channel = await NotificationChannel.findById(id);
|
||||
if (!channel) {
|
||||
throw new ApiError("Notification channel not found", 404);
|
||||
}
|
||||
return channel;
|
||||
};
|
||||
|
||||
getAll = async () => {
|
||||
return NotificationChannel.find();
|
||||
};
|
||||
|
||||
toggleActive = async (tokenizedUser: ITokenizedUser, id: string) => {
|
||||
const updatedChannel = await NotificationChannel.findOneAndUpdate(
|
||||
{ _id: id },
|
||||
[
|
||||
{
|
||||
$set: {
|
||||
isActive: { $not: "$isActive" },
|
||||
updatedBy: tokenizedUser.sub,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
{ new: true }
|
||||
);
|
||||
if (!updatedChannel) {
|
||||
throw new ApiError("Notification channel not found", 404);
|
||||
}
|
||||
return updatedChannel;
|
||||
};
|
||||
|
||||
update = async (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<INotificationChannel>) => {
|
||||
const allowedFields: (keyof INotificationChannel)[] = ["name", "config", "isActive"];
|
||||
const safeUpdate: Partial<INotificationChannel> = {};
|
||||
for (const field of allowedFields) {
|
||||
if (updateData[field] !== undefined) {
|
||||
(safeUpdate as any)[field] = updateData[field];
|
||||
}
|
||||
}
|
||||
|
||||
const updatedChannel = await NotificationChannel.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
$set: {
|
||||
...safeUpdate,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: tokenizedUser.sub,
|
||||
},
|
||||
},
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!updatedChannel) {
|
||||
throw new ApiError("Failed to update notification channel", 500);
|
||||
}
|
||||
|
||||
return updatedChannel;
|
||||
};
|
||||
|
||||
delete = async (id: string) => {
|
||||
const result = await NotificationChannel.deleteOne({ _id: id });
|
||||
if (!result.deletedCount) {
|
||||
throw new ApiError("Notification channel not found", 404);
|
||||
}
|
||||
|
||||
await Monitor.updateMany({ notificationChannels: id }, { $pull: { notificationChannels: id } });
|
||||
return result.deletedCount === 1;
|
||||
};
|
||||
}
|
||||
|
||||
export default NotificationChannelService;
|
||||
23
server/src/service/v2/business/QueueService.ts
Normal file
23
server/src/service/v2/business/QueueService.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IJobQueue } from "../infrastructure/JobQueue.js";
|
||||
|
||||
class QueueService {
|
||||
private jobQueue: IJobQueue;
|
||||
|
||||
constructor(jobQueue: IJobQueue) {
|
||||
this.jobQueue = jobQueue;
|
||||
}
|
||||
|
||||
async getMetrics() {
|
||||
return await this.jobQueue.getMetrics();
|
||||
}
|
||||
|
||||
async getJobs() {
|
||||
return await this.jobQueue.getJobs();
|
||||
}
|
||||
|
||||
async flush() {
|
||||
return await this.jobQueue.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export default QueueService;
|
||||
13
server/src/service/v2/business/UserService.ts
Normal file
13
server/src/service/v2/business/UserService.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IUser, User } from "../../../db/v2/models/index.js";
|
||||
|
||||
export interface IUserService {
|
||||
getAllUsers(): Promise<IUser[]>;
|
||||
}
|
||||
|
||||
class UserService implements IUserService {
|
||||
async getAllUsers(): Promise<IUser[]> {
|
||||
return await User.find();
|
||||
}
|
||||
}
|
||||
|
||||
export default UserService;
|
||||
80
server/src/service/v2/infrastructure/JobGenerator.ts
Normal file
80
server/src/service/v2/infrastructure/JobGenerator.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { IMonitor } from "../../../db/v2/models/index.js";
|
||||
import { INetworkService } from "./NetworkService.js";
|
||||
import { ICheckService } from "../business/CheckService.js";
|
||||
import { IMonitorStatsService } from "../business/MonitorStatsService.js";
|
||||
import { IStatusService } from "./StatusService.js";
|
||||
import { INotificationService } from "./NotificationService.js";
|
||||
import { IMaintenanceService } from "../business/MaintenanceService.js";
|
||||
import ApiError from "../../../utils/ApiError.js";
|
||||
|
||||
export interface IJobGenerator {
|
||||
generateJob: () => (Monitor: IMonitor) => Promise<void>;
|
||||
generateCleanupJob: () => () => Promise<void>;
|
||||
}
|
||||
|
||||
class JobGenerator implements IJobGenerator {
|
||||
private networkService: INetworkService;
|
||||
private checkService: ICheckService;
|
||||
private monitorStatsService: IMonitorStatsService;
|
||||
private statusService: IStatusService;
|
||||
private notificationService: INotificationService;
|
||||
private maintenanceService: IMaintenanceService;
|
||||
|
||||
constructor(
|
||||
networkService: INetworkService,
|
||||
checkService: ICheckService,
|
||||
monitorStatsService: IMonitorStatsService,
|
||||
statusService: IStatusService,
|
||||
notificationService: INotificationService,
|
||||
maintenanceService: IMaintenanceService
|
||||
) {
|
||||
this.networkService = networkService;
|
||||
this.checkService = checkService;
|
||||
this.monitorStatsService = monitorStatsService;
|
||||
this.statusService = statusService;
|
||||
this.notificationService = notificationService;
|
||||
this.maintenanceService = maintenanceService;
|
||||
}
|
||||
|
||||
generateJob = () => {
|
||||
return async (monitor: IMonitor) => {
|
||||
try {
|
||||
const monitorId = monitor._id.toString();
|
||||
if (!monitorId) {
|
||||
throw new ApiError("No monitorID for creating job", 400);
|
||||
}
|
||||
|
||||
// Check for active maintenance window, if found, skip the check
|
||||
const isInMaintenance = await this.maintenanceService.isInMaintenance(monitorId);
|
||||
if (isInMaintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await this.networkService.requestStatus(monitor);
|
||||
const check = await this.checkService.buildCheck(status, monitor.type);
|
||||
await check.save();
|
||||
const [updatedMonitor, statusChanged] = await this.statusService.updateMonitorStatus(monitor, status);
|
||||
|
||||
if (statusChanged) {
|
||||
await this.notificationService.handleNotifications(updatedMonitor);
|
||||
}
|
||||
await this.statusService.updateMonitorStats(updatedMonitor, status, statusChanged);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
generateCleanupJob = () => {
|
||||
return async () => {
|
||||
try {
|
||||
await this.checkService.cleanupOrphanedChecks();
|
||||
await this.monitorStatsService.cleanupOrphanedMonitorStats();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default JobGenerator;
|
||||
227
server/src/service/v2/infrastructure/JobQueue.ts
Normal file
227
server/src/service/v2/infrastructure/JobQueue.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { IJob } from "super-simple-scheduler/dist/job/job.js";
|
||||
import { Monitor, IMonitor } from "../../../db/v2/models/index.js";
|
||||
import Scheduler from "super-simple-scheduler";
|
||||
import { IJobGenerator } from "./JobGenerator.js";
|
||||
export interface IJobMetrics {
|
||||
jobs: number;
|
||||
activeJobs: number;
|
||||
failingJobs: number;
|
||||
jobsWithFailures: Array<{
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
failedAt: number | null;
|
||||
failCount: number | null;
|
||||
failReason: string | null;
|
||||
}>;
|
||||
totalRuns: number;
|
||||
totalFailures: number;
|
||||
}
|
||||
|
||||
export interface IJobData extends IJob {
|
||||
lastRunTook: number | null;
|
||||
}
|
||||
|
||||
export interface IJobQueue {
|
||||
init: () => Promise<boolean>;
|
||||
addJob: (monitor: IMonitor) => Promise<boolean>;
|
||||
pauseJob: (monitor: IMonitor) => Promise<boolean>;
|
||||
resumeJob: (monitor: IMonitor) => Promise<boolean>;
|
||||
updateJob: (monitor: IMonitor) => Promise<boolean>;
|
||||
deleteJob: (monitor: IMonitor) => Promise<boolean>;
|
||||
getMetrics: () => Promise<IJobMetrics | null>;
|
||||
getJobs: () => Promise<IJobData[] | null>;
|
||||
flush: () => Promise<boolean>;
|
||||
shutdown: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export default class JobQueue implements IJobQueue {
|
||||
private scheduler: Scheduler;
|
||||
private static instance: JobQueue | null = null;
|
||||
private jobGenerator: any;
|
||||
constructor() {
|
||||
this.scheduler = new Scheduler({
|
||||
logLevel: "debug",
|
||||
});
|
||||
}
|
||||
|
||||
static async create(jobGenerator: IJobGenerator) {
|
||||
if (!JobQueue.instance) {
|
||||
const instance = new JobQueue();
|
||||
instance.jobGenerator = jobGenerator;
|
||||
await instance.init();
|
||||
JobQueue.instance = instance;
|
||||
}
|
||||
return JobQueue.instance;
|
||||
}
|
||||
|
||||
static getInstance(): JobQueue | null {
|
||||
return JobQueue.instance;
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
try {
|
||||
this.scheduler.start();
|
||||
// Add template and jobs
|
||||
this.scheduler.addTemplate("monitor-job", this.jobGenerator.generateJob());
|
||||
|
||||
// Add a cleanup job
|
||||
this.scheduler.addTemplate("cleanup-job", this.jobGenerator.generateCleanupJob());
|
||||
await this.scheduler.addJob({
|
||||
id: "cleanup-orphaned-checks",
|
||||
template: "cleanup-job",
|
||||
repeat: 24 * 60 * 60 * 1000, // 24 hours
|
||||
active: true,
|
||||
});
|
||||
|
||||
const monitors = await Monitor.find();
|
||||
for (const monitor of monitors) {
|
||||
this.addJob(monitor);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
addJob = async (monitor: IMonitor) => {
|
||||
try {
|
||||
return await this.scheduler?.addJob({
|
||||
id: monitor._id.toString(),
|
||||
template: "monitor-job",
|
||||
repeat: monitor.interval,
|
||||
active: monitor.isActive,
|
||||
data: monitor,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
pauseJob = async (monitor: IMonitor) => {
|
||||
try {
|
||||
return await this.scheduler?.pauseJob(monitor._id.toString());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
resumeJob = async (monitor: IMonitor) => {
|
||||
try {
|
||||
return await this.scheduler.resumeJob(monitor._id.toString());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
updateJob = async (monitor: IMonitor) => {
|
||||
try {
|
||||
return await this.scheduler.updateJob(monitor._id.toString(), {
|
||||
repeat: monitor.interval,
|
||||
data: monitor,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
deleteJob = async (monitor: IMonitor) => {
|
||||
try {
|
||||
this.scheduler?.removeJob(monitor._id.toString());
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
getMetrics = async (): Promise<IJobMetrics | null> => {
|
||||
try {
|
||||
const jobs = await this.scheduler.getJobs();
|
||||
const metrics: IJobMetrics = jobs.reduce<IJobMetrics>(
|
||||
(acc, job) => {
|
||||
if (!job.data) return acc;
|
||||
|
||||
acc.totalRuns += job.runCount || 0;
|
||||
acc.totalFailures += job.failCount || 0;
|
||||
acc.jobs++;
|
||||
|
||||
// Check if job is currently failing (has recent failures)
|
||||
const hasFailures = job.failCount && job.failCount > 0;
|
||||
const isCurrentlyFailing = hasFailures && job.lastFailedAt && (!job.lastRunAt || job.lastFailedAt > job.lastRunAt);
|
||||
|
||||
if (isCurrentlyFailing) {
|
||||
acc.failingJobs++;
|
||||
}
|
||||
|
||||
if (job.lockedAt) {
|
||||
acc.activeJobs++;
|
||||
}
|
||||
|
||||
if (hasFailures) {
|
||||
acc.jobsWithFailures.push({
|
||||
monitorId: job.id,
|
||||
monitorUrl: job.data?.url || null,
|
||||
monitorType: job.data?.type || null,
|
||||
failedAt: job.lastFailedAt || null,
|
||||
failCount: job.failCount || null,
|
||||
failReason: job.lastFailReason || null,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
jobs: 0,
|
||||
activeJobs: 0,
|
||||
failingJobs: 0,
|
||||
jobsWithFailures: [],
|
||||
totalRuns: 0,
|
||||
totalFailures: 0,
|
||||
}
|
||||
);
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
getJobs = async (): Promise<IJobData[] | null> => {
|
||||
try {
|
||||
const jobs = await this.scheduler.getJobs();
|
||||
return jobs.map((job) => {
|
||||
return {
|
||||
...job,
|
||||
lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastRunAt ? null : job.lastFinishedAt - job.lastRunAt,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
flush = async () => {
|
||||
try {
|
||||
return await this.scheduler.flushJobs();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
shutdown = async () => {
|
||||
try {
|
||||
return await this.scheduler.stop();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
193
server/src/service/v2/infrastructure/NetworkService.ts
Normal file
193
server/src/service/v2/infrastructure/NetworkService.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Got, HTTPError } from "got";
|
||||
import ping from "ping";
|
||||
import { IMonitor } from "../../../db/v2/models/index.js";
|
||||
import { GotTimings } from "../../../db/v2/models/checks/Check.js";
|
||||
import type { Response } from "got";
|
||||
import type { ISystemInfo, ICaptureInfo, ILighthouseResult } from "../../../db/v2/models/index.js";
|
||||
import { MonitorType, MonitorStatus } from "../../../db/v2/models/monitors/Monitor.js";
|
||||
import ApiError from "../../../utils/ApiError.js";
|
||||
import { config } from "../../../config/index.js";
|
||||
export interface INetworkService {
|
||||
requestHttp: (monitor: IMonitor) => Promise<StatusResponse>;
|
||||
requestInfrastructure: (monitor: IMonitor) => Promise<StatusResponse>;
|
||||
requestStatus: (monitor: IMonitor) => Promise<StatusResponse>;
|
||||
requestPagespeed: (monitor: IMonitor) => Promise<StatusResponse>;
|
||||
requestPing: (monitor: IMonitor) => Promise<StatusResponse>;
|
||||
}
|
||||
|
||||
export interface ICapturePayload {
|
||||
data: ISystemInfo;
|
||||
capture: ICaptureInfo;
|
||||
}
|
||||
|
||||
export interface ILighthousePayload {
|
||||
lighthouseResult: ILighthouseResult;
|
||||
}
|
||||
|
||||
export interface StatusResponse<TPayload = unknown> {
|
||||
monitorId: string;
|
||||
type: MonitorType;
|
||||
code?: number;
|
||||
status: MonitorStatus;
|
||||
message: string;
|
||||
responseTime: number;
|
||||
timings?: GotTimings;
|
||||
payload?: TPayload;
|
||||
}
|
||||
|
||||
class NetworkService implements INetworkService {
|
||||
private got: Got;
|
||||
private NETWORK_ERROR: number;
|
||||
constructor(got: Got) {
|
||||
this.got = got;
|
||||
this.NETWORK_ERROR = 5000;
|
||||
}
|
||||
|
||||
private buildStatusResponse = <T>(monitor: IMonitor, response: Response<T> | null, error: any | null): StatusResponse<T> => {
|
||||
if (error) {
|
||||
const statusResponse: StatusResponse<T> = {
|
||||
monitorId: monitor._id.toString(),
|
||||
type: monitor.type,
|
||||
status: "down" as MonitorStatus,
|
||||
code: this.NETWORK_ERROR,
|
||||
message: error.message || "Network error",
|
||||
responseTime: 0,
|
||||
timings: { phases: {} } as GotTimings,
|
||||
};
|
||||
if (error instanceof HTTPError) {
|
||||
statusResponse.code = error?.response?.statusCode || this.NETWORK_ERROR;
|
||||
statusResponse.message = error.message || "HTTP error";
|
||||
statusResponse.responseTime = error.timings?.phases?.total || 0;
|
||||
statusResponse.timings = error.timings;
|
||||
}
|
||||
return statusResponse;
|
||||
}
|
||||
|
||||
const statusResponse: StatusResponse<T> = {
|
||||
monitorId: monitor._id.toString(),
|
||||
type: monitor.type,
|
||||
code: response?.statusCode || this.NETWORK_ERROR,
|
||||
status: response?.ok === true ? "up" : "down",
|
||||
message: response?.statusMessage || "",
|
||||
responseTime: response?.timings?.phases?.total || 0,
|
||||
timings: response?.timings || ({ phases: {} } as GotTimings),
|
||||
};
|
||||
|
||||
return statusResponse;
|
||||
};
|
||||
|
||||
requestHttp = async (monitor: IMonitor) => {
|
||||
try {
|
||||
const url = monitor.url;
|
||||
if (!url) {
|
||||
throw new Error("No URL provided");
|
||||
}
|
||||
|
||||
try {
|
||||
const response: Response = await this.got(url);
|
||||
return this.buildStatusResponse(monitor, response, null);
|
||||
} catch (error) {
|
||||
return this.buildStatusResponse(monitor, null, error);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
requestInfrastructure = async (monitor: IMonitor) => {
|
||||
const url = monitor.url;
|
||||
if (!url) {
|
||||
throw new Error("No URL provided");
|
||||
}
|
||||
const secret = monitor.secret;
|
||||
if (!secret) {
|
||||
throw new Error("No secret provided for infrastructure monitor");
|
||||
}
|
||||
|
||||
let statusResponse: StatusResponse<ICapturePayload>;
|
||||
try {
|
||||
const response: Response<ICapturePayload> | null = await this.got(url, {
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
responseType: "json",
|
||||
});
|
||||
|
||||
statusResponse = this.buildStatusResponse(monitor, response, null);
|
||||
if (!response?.body) {
|
||||
throw new ApiError("No payload received from infrastructure monitor", 500);
|
||||
}
|
||||
statusResponse.payload = response?.body;
|
||||
return statusResponse;
|
||||
} catch (error) {
|
||||
statusResponse = this.buildStatusResponse(monitor, null, error);
|
||||
}
|
||||
return statusResponse;
|
||||
};
|
||||
|
||||
requestPagespeed = async (monitor: IMonitor) => {
|
||||
const apiKey = config.PAGESPEED_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("No API key provided for pagespeed monitor");
|
||||
}
|
||||
const url = monitor.url;
|
||||
if (!url) {
|
||||
throw new Error("No URL provided");
|
||||
}
|
||||
|
||||
let statusResponse: StatusResponse<ILighthousePayload>;
|
||||
|
||||
try {
|
||||
const response: Response = await this.got(url);
|
||||
statusResponse = this.buildStatusResponse(monitor, response, null) as StatusResponse<ILighthousePayload>;
|
||||
} catch (error) {
|
||||
statusResponse = this.buildStatusResponse(monitor, null, error);
|
||||
}
|
||||
|
||||
const pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance&key=${apiKey}`;
|
||||
const pagespeedResponse = await this.got<ILighthousePayload>(pagespeedUrl, {
|
||||
responseType: "json",
|
||||
});
|
||||
const payload = pagespeedResponse.body;
|
||||
if (payload) {
|
||||
statusResponse.payload = payload;
|
||||
return statusResponse;
|
||||
} else {
|
||||
throw new ApiError("No payload received from pagespeed monitor", 500);
|
||||
}
|
||||
};
|
||||
|
||||
requestPing = async (monitor: IMonitor) => {
|
||||
const response = await ping.promise.probe(monitor.url);
|
||||
const status = response?.alive === true ? "up" : "down";
|
||||
|
||||
const rawTime = typeof response?.time === "string" ? parseFloat(response.time) : Number(response?.time);
|
||||
const responseTime = Number.isFinite(rawTime) ? rawTime : 0;
|
||||
|
||||
return {
|
||||
monitorId: monitor._id.toString(),
|
||||
type: monitor.type,
|
||||
status: status as MonitorStatus,
|
||||
message: "Ping successful",
|
||||
responseTime,
|
||||
timings: { phases: {} } as GotTimings,
|
||||
};
|
||||
};
|
||||
|
||||
requestStatus = async (monitor: IMonitor) => {
|
||||
switch (monitor?.type) {
|
||||
case "http":
|
||||
return await this.requestHttp(monitor); // uses GOT
|
||||
case "https":
|
||||
return await this.requestHttp(monitor); // uses GOT
|
||||
case "infrastructure":
|
||||
return await this.requestInfrastructure(monitor); // uses GOT
|
||||
case "pagespeed":
|
||||
return await this.requestPagespeed(monitor); // uses GOT
|
||||
case "ping":
|
||||
return await this.requestPing(monitor); // uses PING
|
||||
default:
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default NetworkService;
|
||||
58
server/src/service/v2/infrastructure/NotificationService.ts
Normal file
58
server/src/service/v2/infrastructure/NotificationService.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import UserService from "../business/UserService.js";
|
||||
import { IMonitor, NotificationChannel } from "../../../db/v2/models/index.js";
|
||||
import { EmailService, SlackService, DiscordService, WebhookService } from "./NotificationServices/index.js";
|
||||
export interface INotificationService {
|
||||
handleNotifications: (monitor: IMonitor) => Promise<void>;
|
||||
}
|
||||
|
||||
class NotificationService implements INotificationService {
|
||||
private emailService: EmailService;
|
||||
private slackService: SlackService;
|
||||
private discordService: DiscordService;
|
||||
private webhookService: WebhookService;
|
||||
private userService: UserService;
|
||||
|
||||
constructor(userService: UserService) {
|
||||
this.userService = userService;
|
||||
this.emailService = new EmailService(userService);
|
||||
this.slackService = new SlackService();
|
||||
this.discordService = new DiscordService();
|
||||
this.webhookService = new WebhookService();
|
||||
}
|
||||
|
||||
handleNotifications = async (monitor: IMonitor) => {
|
||||
const notificationIds = monitor.notificationChannels || [];
|
||||
|
||||
if (notificationIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationChannels = await NotificationChannel.find({
|
||||
_id: { $in: notificationIds },
|
||||
});
|
||||
|
||||
for (const channel of notificationChannels) {
|
||||
// Implement sending logic based on channel.type and channel.config
|
||||
let service;
|
||||
switch (channel.type) {
|
||||
case "email":
|
||||
await this.emailService.sendMessage(this.emailService.buildAlert(monitor), channel);
|
||||
break;
|
||||
case "slack":
|
||||
await this.slackService.sendMessage(this.slackService.buildAlert(monitor), channel);
|
||||
break;
|
||||
case "discord":
|
||||
await this.discordService.sendMessage(this.discordService.buildAlert(monitor), channel);
|
||||
break;
|
||||
case "webhook":
|
||||
await this.webhookService.sendMessage(this.webhookService.buildAlert(monitor), channel);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown notification channel type: ${channel.type}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
export default NotificationService;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
|
||||
import { IAlert, IMessageService } from "./IMessageService.js";
|
||||
import got from "got";
|
||||
import ApiError from "../../../../utils/ApiError.js";
|
||||
class DiscordService implements IMessageService {
|
||||
constructor() {}
|
||||
|
||||
private toDiscordEmbeds = (alert: IAlert) => {
|
||||
return {
|
||||
color: alert.status === "up" ? 65280 : 16711680,
|
||||
title: `Monitor name: ${alert.name}`,
|
||||
description: `Status: **${alert.status}**`,
|
||||
fields: [
|
||||
{
|
||||
name: "Url",
|
||||
value: alert.url,
|
||||
},
|
||||
{
|
||||
name: "Checked at",
|
||||
value: alert.checkTime ? alert.checkTime.toISOString() : "N/A",
|
||||
},
|
||||
{ name: "Alert time", value: alert.alertTime.toISOString() },
|
||||
...(alert.details
|
||||
? Object.entries(alert.details).map(([key, value]) => ({
|
||||
name: key,
|
||||
value,
|
||||
}))
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
buildAlert = (monitor: IMonitor) => {
|
||||
const name = monitor?.name || "Unnamed monitor";
|
||||
const monitorStatus = monitor?.status || "unknown status";
|
||||
const url = monitor?.url || "no URL";
|
||||
const checkTime = monitor?.lastCheckedAt || null;
|
||||
const alertTime = new Date();
|
||||
return {
|
||||
name,
|
||||
url,
|
||||
status: monitorStatus,
|
||||
checkTime,
|
||||
alertTime,
|
||||
};
|
||||
};
|
||||
|
||||
sendMessage = async (alert: IAlert, channel: INotificationChannel) => {
|
||||
const notificationUrl = channel?.config?.url;
|
||||
if (!notificationUrl) {
|
||||
throw new ApiError("Webhook URL not configured", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
content: "Status Alert",
|
||||
embeds: [this.toDiscordEmbeds(alert)],
|
||||
};
|
||||
await got.post(notificationUrl, { json: payload });
|
||||
} catch (error) {
|
||||
console.warn("Failed to send Discord message", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
testMessage = async () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export default DiscordService;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
|
||||
import { IMessageService, IAlert } from "./IMessageService.js";
|
||||
import nodemailer, { Transporter } from "nodemailer";
|
||||
import { config } from "../../../../config/index.js";
|
||||
import UserService from "../../business/UserService.js";
|
||||
import ApiError from "../../../../utils/ApiError.js";
|
||||
class EmailService implements IMessageService {
|
||||
private transporter: Transporter;
|
||||
private userService: UserService;
|
||||
|
||||
constructor(userService: UserService) {
|
||||
this.userService = userService;
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.SMTP_HOST,
|
||||
port: config.SMTP_PORT,
|
||||
secure: config.SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: config.SMTP_USER,
|
||||
pass: config.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
buildAlert = (monitor: IMonitor) => {
|
||||
const name = monitor?.name || "Unnamed monitor";
|
||||
const monitorStatus = monitor?.status || "unknown status";
|
||||
const url = monitor?.url || "no URL";
|
||||
const checkTime = monitor?.lastCheckedAt || null;
|
||||
const alertTime = new Date();
|
||||
return {
|
||||
name,
|
||||
url,
|
||||
status: monitorStatus,
|
||||
checkTime,
|
||||
alertTime,
|
||||
};
|
||||
};
|
||||
|
||||
sendMessage = async (alert: string | IAlert, channel: INotificationChannel) => {
|
||||
try {
|
||||
const users = await this.userService.getAllUsers();
|
||||
const emails = users.map((u) => u.email).join(",");
|
||||
|
||||
if (!emails || emails.length === 0) {
|
||||
throw new ApiError("No user emails found", 500);
|
||||
}
|
||||
|
||||
await this.transporter.sendMail({
|
||||
from: `"Checkmate" <${config.SMTP_USER}>`,
|
||||
to: emails,
|
||||
subject: "Monitor Alert",
|
||||
text: JSON.stringify(alert, null, 2),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
testMessage = async () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export default EmailService;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
|
||||
|
||||
export interface IAlert {
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
details?: Record<string, string>;
|
||||
checkTime: Date | null;
|
||||
alertTime: Date;
|
||||
}
|
||||
export interface IMessageService {
|
||||
buildAlert: (monitor: IMonitor) => IAlert;
|
||||
sendMessage: (alert: IAlert, channel: INotificationChannel) => Promise<boolean>;
|
||||
testMessage: (message: string, channel: INotificationChannel) => Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
|
||||
import { IAlert, IMessageService } from "./IMessageService.js";
|
||||
import got from "got";
|
||||
|
||||
class SlackService implements IMessageService {
|
||||
constructor() {}
|
||||
|
||||
private toSlackBlocks = (alert: IAlert) => {
|
||||
return [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Status Alert",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*Monitor name:* ${alert.name}`,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*Status:* ${alert.status}`,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*URL:* ${alert.url}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*Checked at:* ${alert?.checkTime?.toISOString() || "N/A"}`,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
|
||||
...(alert.details
|
||||
? Object.entries(alert.details).map(([key, value]) => ({
|
||||
type: "section",
|
||||
fields: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*${key}:* ${value}`,
|
||||
},
|
||||
],
|
||||
}))
|
||||
: []),
|
||||
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Alert generated at:* ${alert?.alertTime?.toISOString() || "N/A"}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
buildAlert = (monitor: IMonitor) => {
|
||||
const name = monitor?.name || "Unnamed monitor";
|
||||
const monitorStatus = monitor?.status || "unknown status";
|
||||
const url = monitor?.url || "no URL";
|
||||
const checkTime = monitor?.lastCheckedAt || null;
|
||||
const alertTime = new Date();
|
||||
return {
|
||||
name,
|
||||
url,
|
||||
status: monitorStatus,
|
||||
checkTime,
|
||||
alertTime,
|
||||
};
|
||||
};
|
||||
|
||||
sendMessage = async (alert: IAlert, channel: INotificationChannel) => {
|
||||
const notificationUrl = channel?.config?.url;
|
||||
if (!notificationUrl) {
|
||||
throw new Error("Webhook URL not configured");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
text: "Status Alert",
|
||||
blocks: this.toSlackBlocks(alert),
|
||||
};
|
||||
await got.post(notificationUrl, { json: payload });
|
||||
} catch (error) {
|
||||
console.warn("Error sending Slack message:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
testMessage = async () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export default SlackService;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
|
||||
import { IAlert, IMessageService } from "./IMessageService.js";
|
||||
import ApiError from "../../../../utils/ApiError.js";
|
||||
import got from "got";
|
||||
class WebhookService implements IMessageService {
|
||||
constructor() {}
|
||||
|
||||
buildAlert = (monitor: IMonitor) => {
|
||||
const name = monitor?.name || "Unnamed monitor";
|
||||
const monitorStatus = monitor?.status || "unknown status";
|
||||
const url = monitor?.url || "no URL";
|
||||
const checkTime = monitor?.lastCheckedAt || null;
|
||||
const alertTime = new Date();
|
||||
return {
|
||||
name,
|
||||
url,
|
||||
status: monitorStatus,
|
||||
checkTime,
|
||||
alertTime,
|
||||
};
|
||||
};
|
||||
|
||||
sendMessage = async (alert: IAlert, channel: INotificationChannel) => {
|
||||
const notificationUrl = channel?.config?.url;
|
||||
if (!notificationUrl) {
|
||||
throw new ApiError("Webhook URL not configured", 400);
|
||||
}
|
||||
try {
|
||||
await got.post(notificationUrl, { json: { ...alert } });
|
||||
} catch (error) {
|
||||
console.warn("Failed to send webhook notification:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
testMessage = async () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export default WebhookService;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as DiscordService } from "./Discord.js";
|
||||
export { default as EmailService } from "./Email.js";
|
||||
export { default as SlackService } from "./Slack.js";
|
||||
export { default as WebhookService } from "./Webhook.js";
|
||||
export * from "./IMessageService.js";
|
||||
100
server/src/service/v2/infrastructure/StatusService.ts
Normal file
100
server/src/service/v2/infrastructure/StatusService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { IMonitor, IMonitorStats, MonitorStats } from "../../../db/v2/models/index.js";
|
||||
import { StatusResponse } from "./NetworkService.js";
|
||||
import ApiError from "../../../utils/ApiError.js";
|
||||
|
||||
const MAX_LATEST_CHECKS = 25;
|
||||
export interface IStatusService {
|
||||
updateMonitorStatus: (monitor: IMonitor, status: StatusResponse) => Promise<StatusChangeResult>;
|
||||
|
||||
calculateAvgResponseTime: (stats: IMonitorStats, statusResponse: StatusResponse) => number;
|
||||
|
||||
updateMonitorStats: (monitor: IMonitor, status: StatusResponse, statusChanged: boolean) => Promise<IMonitorStats | null>;
|
||||
}
|
||||
|
||||
export type StatusChangeResult = [updatedMonitor: IMonitor, statusChanged: boolean];
|
||||
|
||||
class StatusService implements IStatusService {
|
||||
updateMonitorStatus = async (monitor: IMonitor, statusResponse: StatusResponse): Promise<StatusChangeResult> => {
|
||||
const newStatus = statusResponse.status;
|
||||
monitor.lastCheckedAt = new Date();
|
||||
|
||||
// Store latest checks for display
|
||||
monitor.latestChecks = monitor.latestChecks || [];
|
||||
monitor.latestChecks.push({
|
||||
status: newStatus,
|
||||
responseTime: statusResponse.responseTime,
|
||||
checkedAt: monitor.lastCheckedAt,
|
||||
});
|
||||
while (monitor.latestChecks.length > MAX_LATEST_CHECKS) {
|
||||
monitor.latestChecks.shift();
|
||||
}
|
||||
|
||||
// Update monitor status
|
||||
if (monitor.status === "initializing") {
|
||||
monitor.status = newStatus;
|
||||
return [await monitor.save(), true];
|
||||
} else {
|
||||
const { n } = monitor;
|
||||
const latestChecks = monitor.latestChecks.slice(-n);
|
||||
// Return early if not enough statuses to evaluate
|
||||
if (latestChecks.length < n) {
|
||||
return [await monitor.save(), false];
|
||||
}
|
||||
|
||||
// If all different than current status, update status
|
||||
const allDifferent = latestChecks.every((check) => check.status !== monitor.status);
|
||||
if (allDifferent && monitor.status !== newStatus) {
|
||||
monitor.status = newStatus;
|
||||
}
|
||||
return [await monitor.save(), allDifferent];
|
||||
}
|
||||
};
|
||||
|
||||
calculateAvgResponseTime = (stats: IMonitorStats, statusResponse: StatusResponse): number => {
|
||||
let avgResponseTime = stats.avgResponseTime;
|
||||
// Set initial
|
||||
if (avgResponseTime === 0) {
|
||||
avgResponseTime = statusResponse.responseTime;
|
||||
} else {
|
||||
avgResponseTime = (avgResponseTime * (stats.totalChecks - 1) + statusResponse.responseTime) / stats.totalChecks;
|
||||
}
|
||||
return avgResponseTime;
|
||||
};
|
||||
|
||||
updateMonitorStats = async (monitor: IMonitor, statusResponse: StatusResponse, statusChanged: boolean) => {
|
||||
const stats = await MonitorStats.findOne({ monitorId: monitor._id });
|
||||
if (!stats) {
|
||||
throw new ApiError("MonitorStats not found", 500);
|
||||
}
|
||||
|
||||
// Update check counts
|
||||
stats.totalChecks += 1;
|
||||
stats.totalUpChecks += statusResponse.status === "up" ? 1 : 0;
|
||||
stats.totalDownChecks += statusResponse.status === "down" ? 1 : 0;
|
||||
|
||||
// Update streak
|
||||
if (!statusChanged) {
|
||||
stats.currentStreak += 1;
|
||||
} else {
|
||||
stats.currentStreak = 1;
|
||||
stats.currentStreakStatus = statusResponse.status;
|
||||
stats.currentStreakStartedAt = Date.now();
|
||||
}
|
||||
|
||||
// Update time stamps
|
||||
stats.lastCheckTimestamp = Date.now();
|
||||
stats.timeOfLastFailure = statusResponse.status === "down" ? Date.now() : stats.timeOfLastFailure;
|
||||
|
||||
// Update stats that need updated check counts
|
||||
stats.avgResponseTime = this.calculateAvgResponseTime(stats, statusResponse);
|
||||
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
|
||||
|
||||
// Other
|
||||
stats.lastResponseTime = statusResponse.responseTime;
|
||||
stats.maxResponseTime = Math.max(stats.maxResponseTime, statusResponse.responseTime);
|
||||
|
||||
return await stats.save();
|
||||
};
|
||||
}
|
||||
|
||||
export default StatusService;
|
||||
15
server/src/utils/ApiError.ts
Normal file
15
server/src/utils/ApiError.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
class ApiError extends Error {
|
||||
public status: number;
|
||||
|
||||
constructor(message: string, status: number = 500) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = this.constructor.name;
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiError;
|
||||
22
server/src/utils/JWTUtils.ts
Normal file
22
server/src/utils/JWTUtils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { AuthResult } from "../service/v2/business/AuthService.js";
|
||||
|
||||
const encode = (data: AuthResult): string => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("JWT_SECRET is not defined");
|
||||
}
|
||||
const token = jwt.sign(data, secret, { expiresIn: "99d" });
|
||||
return token;
|
||||
};
|
||||
|
||||
const decode = (token: string): AuthResult => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("JWT_SECRET is not defined");
|
||||
}
|
||||
const decoded = jwt.verify(token, secret) as AuthResult;
|
||||
return decoded;
|
||||
};
|
||||
|
||||
export { encode, decode };
|
||||
Reference in New Issue
Block a user