Merge branch 'feature/teams-notification-channel' of https://github.com/tb102122/Checkmate into feature/teams-notification-channel
This commit is contained in:
commit
7213bace08
37 files changed
+447
-263
No files matched your search
@@ -59,6 +59,7 @@ import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.
|
||||
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";
|
||||
|
||||
// Third-party
|
||||
import axios from "axios";
|
||||
@@ -78,6 +79,7 @@ import { games, GameDig } from "gamedig";
|
||||
import jmespath from "jmespath";
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
import WebSocket from "ws";
|
||||
|
||||
// Repositories
|
||||
import {
|
||||
@@ -186,6 +188,7 @@ export const initializeServices = async ({
|
||||
const portProvider = new PortProvider(net);
|
||||
const gameProvider = new GameProvider(logger, GameDig);
|
||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||
|
||||
const networkService = new NetworkService(axios, logger, [
|
||||
pingProvider,
|
||||
@@ -196,6 +199,7 @@ export const initializeServices = async ({
|
||||
portProvider,
|
||||
gameProvider,
|
||||
grpcProvider,
|
||||
webSocketProvider,
|
||||
]);
|
||||
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
|
||||
|
||||
|
||||
@@ -180,6 +180,20 @@ class AuthController {
|
||||
}
|
||||
};
|
||||
|
||||
deleteUserById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
getUserByIdParamValidation.parse(req.params);
|
||||
const targetUserId = req.params.userId;
|
||||
await this.userService.deleteUserById(req.user, targetUserId);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "User removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getAllUsers = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const allUsers = await this.userService.getAllUsers();
|
||||
|
||||
+2
-3
@@ -50,9 +50,8 @@ const startApp = async () => {
|
||||
openApiSpec,
|
||||
});
|
||||
|
||||
const port = 52345;
|
||||
const server = app.listen(port, () => {
|
||||
logger.info({ message: `Server started on port:${port}` });
|
||||
const server = app.listen(env.PORT, () => {
|
||||
logger.info({ message: `Server started on port:${env.PORT}` });
|
||||
});
|
||||
|
||||
initShutdownListener(server, services);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { IGeoChecksRepository } from "./IGeoChecksRepository.js";
|
||||
import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js";
|
||||
import type { GeoChecksQueryResult, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
|
||||
import type { FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
|
||||
import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js";
|
||||
import mongoose from "mongoose";
|
||||
import { getDateForRange } from "@/utils/dataUtils.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "GeoChecksRepository";
|
||||
|
||||
class MongoGeoChecksRepository implements IGeoChecksRepository {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: any;
|
||||
constructor(logger: any) {
|
||||
private logger: ILogger;
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,10 @@ class AuthRoutes {
|
||||
|
||||
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
|
||||
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
|
||||
this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById);
|
||||
this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById);
|
||||
this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
|
||||
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);
|
||||
|
||||
@@ -68,7 +68,7 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
}
|
||||
|
||||
// Step 1: Create measurement request
|
||||
const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations);
|
||||
const measurementId = await this.globalPingService.createMeasurement(monitor.type, monitor.url, monitor.geoCheckLocations);
|
||||
|
||||
if (!measurementId) {
|
||||
// GlobalPing API is down, skip this check
|
||||
@@ -76,7 +76,6 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
message: "Skipping geo check due to API unavailability",
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -90,7 +89,6 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
message: "No successful geo check results",
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id, measurementId },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -102,17 +100,16 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
message: `Geo check completed for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id, resultsCount: results.length },
|
||||
});
|
||||
|
||||
return geoCheck;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: "Error executing geo check",
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id, error: error.message },
|
||||
stack: error.stack,
|
||||
details: { monitorId: monitor.id, error: error instanceof Error ? error.message : "Unknown error" },
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -172,19 +169,9 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
|
||||
const { sortOrder, dateRange, page, rowsPerPage, continent } = query;
|
||||
const continents = continent ? (Array.isArray(continent) ? continent : [continent]) : undefined;
|
||||
|
||||
this.logger.debug({
|
||||
message: "getGeoChecksByMonitor query params",
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoChecksByMonitor",
|
||||
details: { continent, continents, query },
|
||||
});
|
||||
|
||||
const parsedPage = page ? parseInt(page) : page;
|
||||
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
|
||||
|
||||
const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continents);
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PageSpeedDetailsResult,
|
||||
GamesMap,
|
||||
} from "@/types/monitor.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
import type { GeoContinent } from "@/types/geoCheck.js";
|
||||
import type {
|
||||
IChecksRepository,
|
||||
@@ -226,7 +227,8 @@ export class MonitorService implements IMonitorService {
|
||||
checksData.monitorType !== "docker" &&
|
||||
checksData.monitorType !== "port" &&
|
||||
checksData.monitorType !== "game" &&
|
||||
checksData.monitorType !== "grpc"
|
||||
checksData.monitorType !== "grpc" &&
|
||||
checksData.monitorType !== "websocket"
|
||||
) {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
|
||||
}
|
||||
@@ -340,7 +342,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
|
||||
}
|
||||
|
||||
if (monitor.type !== "http" || !monitor.geoCheckEnabled) {
|
||||
if (!supportsGeoCheck(monitor.type) || !monitor.geoCheckEnabled) {
|
||||
return { groupedGeoChecks: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface IUserService {
|
||||
validateRecovery(recoveryToken: string): Promise<void>;
|
||||
resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>;
|
||||
deleteUser(user: User): Promise<void>;
|
||||
deleteUserById(actor: User, targetUserId: string): Promise<void>;
|
||||
getAllUsers(): Promise<User[]>;
|
||||
getUserById(roles: UserRole[], userId: string): Promise<User>;
|
||||
editUserById(userId: string, patch: Partial<User>): Promise<void>;
|
||||
@@ -281,7 +282,12 @@ export class UserService implements IUserService {
|
||||
url,
|
||||
});
|
||||
if (!html) {
|
||||
throw new Error("Failed to build password reset email HTML");
|
||||
throw new AppError({
|
||||
message: "Failed to build password reset email HTML",
|
||||
service: SERVICE_NAME,
|
||||
method: "requestRecovery",
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html);
|
||||
return msgId;
|
||||
@@ -352,13 +358,53 @@ export class UserService implements IUserService {
|
||||
await this.usersRepository.deleteById(userId);
|
||||
};
|
||||
|
||||
deleteUserById = async (actor: User, targetUserId: string) => {
|
||||
if (actor.id === targetUserId) {
|
||||
throw new AppError({ message: "Cannot delete your own account from here", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
|
||||
}
|
||||
|
||||
const targetUser = await this.usersRepository.findById(targetUserId);
|
||||
|
||||
if (targetUser.teamId !== actor.teamId) {
|
||||
throw new AppError({ message: "User is not on your team", service: SERVICE_NAME, method: "deleteUserById", status: 403 });
|
||||
}
|
||||
|
||||
if (targetUser.role.includes("demo")) {
|
||||
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
|
||||
}
|
||||
|
||||
const actorRoles = actor.role;
|
||||
const targetRoles = targetUser.role;
|
||||
|
||||
// Check actor can manage all of target's roles
|
||||
for (const targetRole of targetRoles) {
|
||||
const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
|
||||
if (!canManage) {
|
||||
throw new AppError({
|
||||
message: "You do not have permission to remove this user",
|
||||
service: SERVICE_NAME,
|
||||
method: "deleteUserById",
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.usersRepository.deleteById(targetUserId);
|
||||
|
||||
this.logger.info({
|
||||
message: `User ${targetUserId} deleted by ${actor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "deleteUserById",
|
||||
});
|
||||
};
|
||||
|
||||
getAllUsers = async () => {
|
||||
return await this.usersRepository.findAll();
|
||||
};
|
||||
|
||||
getUserById = async (roles: any, userId: any) => {
|
||||
if (!roles.includes("superadmin")) {
|
||||
throw new AppError({ message: "User is not a superadmin", service: SERVICE_NAME, status: 403 });
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IMonitorsRepository } from "@/repositories/index.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import Scheduler from "super-simple-scheduler";
|
||||
import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
const SERVICE_NAME = "JobQueue";
|
||||
|
||||
type QueueJobFailure = {
|
||||
@@ -125,8 +126,13 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
data: monitor,
|
||||
});
|
||||
|
||||
// Return early if we don't need geo checks
|
||||
if (!supportsGeoCheck(monitor.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add geo check job if enabled for HTTP monitors
|
||||
if (monitor.geoCheckEnabled && monitor.type === "http") {
|
||||
if (monitor.geoCheckEnabled) {
|
||||
this.scheduler.addJob({
|
||||
id: `${monitorId}-geo`,
|
||||
template: "geo-check-job",
|
||||
@@ -175,7 +181,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
|
||||
// Handle geo check job lifecycle
|
||||
const geoJobId = `${monitor.id}-geo`;
|
||||
if (monitor.geoCheckEnabled && monitor.type === "http") {
|
||||
if (monitor.geoCheckEnabled && supportsGeoCheck(monitor.type)) {
|
||||
// Check if geo job exists
|
||||
const existingGeoJob = await this.scheduler.getJob(geoJobId);
|
||||
if (existingGeoJob) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const SERVICE_NAME = "JobQueueHelper";
|
||||
import type { Monitor } from "@/types/monitor.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { INetworkService, INotificationsService, IStatusService, IncidentService, type IGeoChecksService } from "@/service/index.js";
|
||||
import type { StatusChangeResult } from "@/types/index.js";
|
||||
@@ -280,19 +281,18 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" });
|
||||
}
|
||||
|
||||
if (monitor.type !== "http") {
|
||||
if (!monitor.geoCheckEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!supportsGeoCheck(monitor.type)) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitorId} is not HTTP type, skipping geo check`,
|
||||
message: `Monitor ${monitorId} type does not support geo checks, skipping`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
|
||||
this.logger.warn({
|
||||
message: `No geo check locations configured for monitor ${monitorId}`,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
import { MonitorType } from "@/types/index.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
import got from "got";
|
||||
|
||||
@@ -8,7 +10,7 @@ const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_TIMEOUT_MS = 30000;
|
||||
|
||||
interface GlobalPingMeasurementRequest {
|
||||
type: "http";
|
||||
type: MonitorType;
|
||||
target: string;
|
||||
locations: Array<{ continent: GeoContinent }>;
|
||||
limit: number;
|
||||
@@ -44,13 +46,22 @@ interface GlobalPingProbeResult {
|
||||
firstByte: number;
|
||||
download: number;
|
||||
};
|
||||
stats?: {
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
total: number;
|
||||
loss: number;
|
||||
rcv: number;
|
||||
drop: number;
|
||||
};
|
||||
rawOutput?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IGlobalPingService {
|
||||
readonly serviceName: string;
|
||||
createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null>;
|
||||
createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>;
|
||||
pollForResults(measurementId: string, timeoutMs?: number): Promise<GeoCheckResult[]>;
|
||||
}
|
||||
|
||||
@@ -67,13 +78,16 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
return GlobalPingService.SERVICE_NAME;
|
||||
}
|
||||
|
||||
async createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null> {
|
||||
async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null> {
|
||||
try {
|
||||
if (!supportsGeoCheck(monitorType)) {
|
||||
throw new Error(`Unsupported monitor type for GlobalPing: ${monitorType}`);
|
||||
}
|
||||
// GlobalPing API expects target without protocol (http:// or https://)
|
||||
const cleanTarget = url.replace(/^https?:\/\//, "");
|
||||
|
||||
const requestBody: GlobalPingMeasurementRequest = {
|
||||
type: "http",
|
||||
type: monitorType,
|
||||
target: cleanTarget,
|
||||
locations: locations.map((continent) => ({ continent })),
|
||||
limit: locations.length,
|
||||
@@ -88,10 +102,9 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
const measurementId = response.body.id;
|
||||
|
||||
this.logger.debug({
|
||||
message: `Created GlobalPing measurement: ${measurementId}`,
|
||||
message: `Created GlobalPing measurement: ${measurementId} for target: ${cleanTarget}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "createMeasurement",
|
||||
details: { measurementId, url, locations },
|
||||
});
|
||||
|
||||
return measurementId;
|
||||
@@ -165,8 +178,7 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
const successfulResults: GeoCheckResult[] = [];
|
||||
|
||||
for (const probeResult of probeResults) {
|
||||
// Skip failed or timeout results
|
||||
if (probeResult.result.status !== "finished" || !probeResult.result.statusCode || !probeResult.result.timings) {
|
||||
if (probeResult.result.status !== "finished") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -180,23 +192,38 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
latitude: probeResult.probe.latitude,
|
||||
};
|
||||
|
||||
const timings: GeoCheckTimings = {
|
||||
total: probeResult.result.timings.total,
|
||||
dns: probeResult.result.timings.dns,
|
||||
tcp: probeResult.result.timings.tcp,
|
||||
tls: probeResult.result.timings.tls,
|
||||
firstByte: probeResult.result.timings.firstByte,
|
||||
download: probeResult.result.timings.download,
|
||||
};
|
||||
// HTTP results have statusCode and timings, ping results have stats
|
||||
if (probeResult.result.statusCode && probeResult.result.timings) {
|
||||
const timings: GeoCheckTimings = {
|
||||
total: probeResult.result.timings.total,
|
||||
dns: probeResult.result.timings.dns,
|
||||
tcp: probeResult.result.timings.tcp,
|
||||
tls: probeResult.result.timings.tls,
|
||||
firstByte: probeResult.result.timings.firstByte,
|
||||
download: probeResult.result.timings.download,
|
||||
};
|
||||
|
||||
const result: GeoCheckResult = {
|
||||
location,
|
||||
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
|
||||
statusCode: probeResult.result.statusCode,
|
||||
timings,
|
||||
};
|
||||
|
||||
successfulResults.push(result);
|
||||
successfulResults.push({
|
||||
location,
|
||||
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
|
||||
statusCode: probeResult.result.statusCode,
|
||||
timings,
|
||||
});
|
||||
} else if (probeResult.result.stats) {
|
||||
successfulResults.push({
|
||||
location,
|
||||
status: probeResult.result.stats.loss === 0,
|
||||
statusCode: probeResult.result.stats.loss === 0 ? 200 : 5000,
|
||||
timings: {
|
||||
total: probeResult.result.stats.avg,
|
||||
dns: 0,
|
||||
tcp: 0,
|
||||
tls: 0,
|
||||
firstByte: 0,
|
||||
download: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return successfulResults;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MonitorType, Monitor } from "@/types/monitor.js";
|
||||
import { MonitorStatusResponse } from "@/types/network.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import ping from "ping";
|
||||
import { buildStatusResponse, timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
import { timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
const SERVICE_NAME = "PingProvider";
|
||||
|
||||
type Ping = typeof ping;
|
||||
@@ -33,7 +33,7 @@ export class PingProvider implements IStatusProvider<PingStatusPayload> {
|
||||
|
||||
const sanitizedHost = this.sanitizeHost(monitor.url);
|
||||
const { response, error } = await timeRequest<PingStatusPayload>(() => this.ping.promise.probe(sanitizedHost));
|
||||
const safeTime = typeof response?.time === "number" ? response.time : parseFloat(String(response?.time)) || 0;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -42,16 +42,18 @@ export class PingProvider implements IStatusProvider<PingStatusPayload> {
|
||||
throw new Error(`No response from ping for host: ${sanitizedHost}`);
|
||||
}
|
||||
|
||||
return buildStatusResponse<PingStatusPayload>({
|
||||
monitor,
|
||||
const responseTime = typeof response.time === "number" ? response.time : parseFloat(String(response.time)) || 0;
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: response.alive ?? false,
|
||||
code: response.alive ? 200 : 5000,
|
||||
message: response.alive ? "Success" : "Ping failed",
|
||||
responseTime,
|
||||
payload: response,
|
||||
overrides: {
|
||||
status: response.alive ?? false,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime: safeTime,
|
||||
},
|
||||
});
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new AppError({
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
||||
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Monitor, MonitorType } from "@/types/monitor.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
import type WebSocket from "ws";
|
||||
|
||||
type WebSocketConstructor = typeof WebSocket;
|
||||
|
||||
const SERVICE_NAME = "WebSocketProvider";
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
export class WebSocketProvider implements IStatusProvider<WebSocketStatusPayload> {
|
||||
readonly type = "websocket";
|
||||
|
||||
constructor(private WS: WebSocketConstructor) {}
|
||||
|
||||
supports(type: MonitorType): boolean {
|
||||
return type === "websocket";
|
||||
}
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<WebSocketStatusPayload>> {
|
||||
try {
|
||||
const { url } = monitor;
|
||||
if (!url) {
|
||||
throw new Error("URL is required for WebSocket monitoring");
|
||||
}
|
||||
|
||||
const { responseTime, error } = await timeRequest(async () => {
|
||||
return new Promise<{ connected: boolean }>((resolve, reject) => {
|
||||
const options: WebSocket.ClientOptions = {};
|
||||
if (monitor.ignoreTlsErrors) {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
const ws = new this.WS(url, options);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("WebSocket connection timeout"));
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
ws.on("open", () => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve({ connected: true });
|
||||
});
|
||||
|
||||
ws.on("error", (err: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "WebSocket check failed";
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: errorMessage,
|
||||
responseTime: responseTime,
|
||||
timings: undefined,
|
||||
payload: { connected: false },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "WebSocket check successful",
|
||||
responseTime: responseTime,
|
||||
timings: undefined,
|
||||
payload: { connected: true },
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const originalMessage = err instanceof Error ? err.message : String(err);
|
||||
throw new AppError({
|
||||
message: originalMessage || "Error performing WebSocket check",
|
||||
status: 500,
|
||||
service: SERVICE_NAME,
|
||||
method: "handle",
|
||||
details: { url: monitor.url },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
import { HTTPError, RequestError } from "got";
|
||||
import type { Response } from "got";
|
||||
import type { Monitor } from "@/types/monitor.js";
|
||||
import { MonitorStatusResponse, MonitorStatusResponseOverrides } from "@/types/network.js";
|
||||
|
||||
export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => {
|
||||
const start = process.hrtime.bigint();
|
||||
try {
|
||||
@@ -17,69 +12,3 @@ export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ res
|
||||
|
||||
export const NETWORK_ERROR = 5000;
|
||||
export const PING_ERROR = 5001;
|
||||
|
||||
interface BuildStatusResponseArgs<T> {
|
||||
monitor: Monitor;
|
||||
response?: Response<T> | null;
|
||||
error?: Error | RequestError | HTTPError | null;
|
||||
payload?: T | null;
|
||||
jsonPath?: string;
|
||||
matchMethod?: MonitorStatusResponse["matchMethod"];
|
||||
expectedValue?: string;
|
||||
extracted?: unknown;
|
||||
overrides?: MonitorStatusResponseOverrides<T>;
|
||||
}
|
||||
|
||||
export const buildStatusResponse = <T>({
|
||||
monitor,
|
||||
response,
|
||||
error,
|
||||
payload,
|
||||
jsonPath,
|
||||
matchMethod,
|
||||
expectedValue,
|
||||
extracted,
|
||||
overrides,
|
||||
}: BuildStatusResponseArgs<T>): MonitorStatusResponse<T> => {
|
||||
if (error) {
|
||||
const statusResponse: MonitorStatusResponse<T> = {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: error.message ?? "Network error",
|
||||
responseTime: 0,
|
||||
timings: undefined,
|
||||
jsonPath,
|
||||
matchMethod,
|
||||
expectedValue,
|
||||
extracted,
|
||||
payload,
|
||||
};
|
||||
|
||||
if (error instanceof HTTPError || error instanceof RequestError) {
|
||||
statusResponse.code = error?.response?.statusCode ?? NETWORK_ERROR;
|
||||
statusResponse.message = error.message;
|
||||
statusResponse.responseTime = error.timings?.phases?.total ?? 0;
|
||||
statusResponse.timings = error.timings;
|
||||
}
|
||||
return { ...statusResponse, ...(overrides ?? {}) };
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: response?.ok ?? false,
|
||||
code: response?.statusCode ?? NETWORK_ERROR,
|
||||
message: response?.statusMessage ?? "",
|
||||
responseTime: response?.timings?.phases?.total ?? 0,
|
||||
timings: response?.timings,
|
||||
payload: payload ?? response?.body,
|
||||
jsonPath,
|
||||
matchMethod,
|
||||
expectedValue,
|
||||
extracted,
|
||||
};
|
||||
};
|
||||
@@ -3,9 +3,12 @@ export type { CheckSnapshot } from "@/types/check.js";
|
||||
import type { GeoContinent } from "@/types/geoCheck.js";
|
||||
export type { GeoContinent } from "@/types/geoCheck.js";
|
||||
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "unknown"] as const;
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
|
||||
|
||||
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
|
||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ export interface MonitorStatusResponse<
|
||||
| HardwareStatusPayload
|
||||
| DockerStatusPayload
|
||||
| GameStatusPayload
|
||||
| GrpcStatusPayload,
|
||||
| GrpcStatusPayload
|
||||
| WebSocketStatusPayload,
|
||||
> {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
@@ -111,6 +112,10 @@ export interface GrpcStatusPayload {
|
||||
servingStatus: string;
|
||||
}
|
||||
|
||||
export interface WebSocketStatusPayload {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface MonitorPayloadMap {
|
||||
ping: PingStatusPayload;
|
||||
http: HttpStatusPayload;
|
||||
@@ -120,6 +125,7 @@ export interface MonitorPayloadMap {
|
||||
port: PortStatusPayload;
|
||||
game: GameStatusPayload;
|
||||
grpc: GrpcStatusPayload;
|
||||
websocket: WebSocketStatusPayload;
|
||||
default: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const getChecksParamValidation = z.object({
|
||||
});
|
||||
|
||||
export const getChecksQueryValidation = z.object({
|
||||
type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"]).optional(),
|
||||
type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(),
|
||||
sortOrder: z.enum(["asc", "desc"]).optional(),
|
||||
limit: z.coerce.number().optional(),
|
||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
// Server Configuration
|
||||
PORT: z.string().default("52345"),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("debug"),
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export const getMonitorsByTeamIdParamValidation = z.object({});
|
||||
export const getMonitorsByTeamIdQueryValidation = z.object({
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])),
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
|
||||
])
|
||||
.optional(),
|
||||
filter: z.union([z.string(), z.literal(""), z.null()]).optional(),
|
||||
@@ -37,8 +37,8 @@ export const getMonitorsWithChecksQueryValidation = z.object({
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])),
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
|
||||
])
|
||||
.optional(),
|
||||
explain: booleanCoercion.optional(),
|
||||
|
||||
Reference in new issue
Block a user