status page service/repo refactor

This commit is contained in:
Alex Holliday committed 2026-01-22 20:57:20 +00:00
1 parent 2359255ef7
commit e6c268f14e
18 files changed
+357 -47

No files matched your search

@@ -148,7 +148,7 @@ const StatusPageBarChart = ({ checks = [] }) => {
</>
}
placement="top"
key={`check-${check?._id}`}
key={`check-${check?.id}`}
slotProps={{
popper: {
className: "bar-tooltip",
+2
View File
@@ -12,6 +12,7 @@ import {
Calendar,
Check,
CheckCircle,
CheckCircle2,
CheckSquare,
ChevronDown,
ChevronLeft,
@@ -82,6 +83,7 @@ const iconComponents = {
Calendar,
Check,
CheckCircle,
CheckCircle2,
CheckSquare,
ChevronDown,
ChevronLeft,
@@ -2,13 +2,14 @@ import { useState } from "react";
import { networkService } from "../../../../main.jsx";
import { createToast } from "../../../../Utils/toastUtils.jsx";
const useCreateStatusPage = (isCreate, url) => {
const useCreateStatusPage = (isCreate) => {
const [isLoading, setIsLoading] = useState(false);
const [networkError, setNetworkError] = useState(false);
const createStatusPage = async ({ form }) => {
const createStatusPage = async ({ form, id }) => {
console.log(id);
setIsLoading(true);
try {
await networkService.createStatusPage({ form, isCreate, url });
await networkService.createStatusPage({ form, isCreate, id });
return true;
} catch (error) {
setNetworkError(true);
+2 -3
View File
@@ -54,10 +54,10 @@ const CreateStatusPage = () => {
//Utils
const theme = useTheme();
const [monitors, isLoading, networkError] = useMonitorsFetch();
const [createStatusPage] = useCreateStatusPage(isCreate);
const navigate = useNavigate();
const { t } = useTranslation();
const [createStatusPage] = useCreateStatusPage(isCreate);
const [statusPage, statusPageMonitors, statusPageIsLoading, , fetchStatusPage] =
useStatusPageFetch(isCreate, url);
const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url);
@@ -144,13 +144,12 @@ const CreateStatusPage = () => {
logo: { type: form.logo?.type ?? null, size: form.logo?.size ?? null },
};
console.log(toSubmit);
const { error } = statusPageValidation.validate(toSubmit, {
abortEarly: false,
});
if (typeof error === "undefined") {
const success = await createStatusPage({ form });
const success = await createStatusPage({ form, id: statusPage?.id });
if (success) {
createToast({
body: isCreate ? t("statusPage.createSuccess") : t("statusPage.updateSuccess"),
@@ -14,7 +14,7 @@ const useStatusPagesFetch = () => {
const fetchStatusPages = async () => {
try {
const res = await networkService.getStatusPagesByTeamId();
setStatusPages(res?.data?.data);
setStatusPages(res?.data?.data || []);
} catch (error) {
setNetworkError(true);
createToast({
+2 -2
View File
@@ -909,7 +909,7 @@ class NetworkService {
}
async createStatusPage(config) {
const { form, isCreate } = config;
const { form, isCreate, id } = config;
const fd = new FormData();
fd.append("type", form.type);
@@ -955,7 +955,7 @@ class NetworkService {
return this.axiosInstance.post(`/status-page`, fd, {});
}
return this.axiosInstance.put(`/status-page`, fd, {});
return this.axiosInstance.put(`/status-page/${id}`, fd, {});
}
async deleteStatusPage(config) {
+1 -1
View File
@@ -36,7 +36,7 @@ export const initializeControllers = (services: InitializedServices): Initialize
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
queueController: new QueueController(services.jobQueue),
logController: new LogController(services.logger),
statusPageController: new StatusPageController(services.db),
statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService),
notificationController: new NotificationController(services.notificationsService, services.monitorsRepository),
diagnosticController: new DiagnosticController(services.diagnosticService),
incidentController: new IncidentController(services.incidentService),
+5
View File
@@ -22,6 +22,7 @@ import DiagnosticService from "../service/business/diagnosticService.js";
import InviteService from "../service/business/inviteService.js";
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
import { MonitorService } from "@/service/index.js";
import { StatusPageService, IStatusPageService } from "../service/business/statusPageService.js";
import IncidentService from "../service/business/incidentService.js";
import axios from "axios";
import got from "got";
@@ -109,6 +110,7 @@ export type InitializedServices = {
errorService: any;
logger: any;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
// Repositories
monitorsRepository: IMonitorsRepository;
@@ -273,6 +275,8 @@ export const initializeServices = async ({
statusPagesRepository,
});
const statusPageService = new StatusPageService(statusPagesRepository);
const services = {
//v1
settingsService,
@@ -292,6 +296,7 @@ export const initializeServices = async ({
errorService,
logger,
notificationsService,
statusPageService,
// Repositories
monitorsRepository,
@@ -99,6 +99,13 @@ const requireTeamId = (teamId?: string): string => {
return teamId;
};
const requireUserId = (userId?: string): string => {
if (!userId) {
throw new AppError({ message: "User ID is required", status: 400 });
}
return userId;
};
export {
fetchMonitorCertificate,
requireString,
@@ -108,4 +115,5 @@ export {
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
requireUserId,
};
+51 -30
View File
@@ -2,14 +2,25 @@ import { Request, Response, NextFunction } from "express";
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation } from "@/validation/joi.js";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { IStatusPageService } from "@/service/business/statusPageService.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { ParseBoolean } from "@/utils/utils.js";
import { MonitorWithChecks } from "@/types/index.js";
import { NormalizeData } from "@/utils/dataUtils.js";
const SERVICE_NAME = "statusPageController";
class StatusPageController {
static SERVICE_NAME = SERVICE_NAME;
private db: any;
constructor(db: any) {
this.db = db;
private statusPageService: IStatusPageService;
private monitorsRepository: IMonitorsRepository;
private settingsService: ISettingsService;
constructor(statusPageService: IStatusPageService, monitorsRepository: IMonitorsRepository, settingsService: ISettingsService) {
this.statusPageService = statusPageService;
this.monitorsRepository = monitorsRepository;
this.settingsService = settingsService;
}
get serviceName() {
@@ -21,13 +32,10 @@ class StatusPageController {
await createStatusPageBodyValidation.validateAsync(req.body);
await imageValidation.validateAsync(req.file);
const { id, teamId } = req.user ?? {};
const statusPage = await this.db.statusPageModule.createStatusPage({
statusPageData: req.body,
image: req.file,
userId: id,
teamId,
});
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req?.user?.id);
const statusPage = await this.statusPageService.createStatusPage(userId, teamId, req.file, req.body);
return res.status(200).json({
success: true,
msg: "Status page created successfully",
@@ -42,8 +50,12 @@ class StatusPageController {
try {
await createStatusPageBodyValidation.validateAsync(req.body);
await imageValidation.validateAsync(req.file);
const statusPage = await this.db.statusPageModule.updateStatusPage(req.body, req.file);
const teamId = requireTeamId(req?.user?.teamId);
const statusPageId = req.params.id;
if (!statusPageId) {
throw new AppError({ message: "Status page ID is required", status: 400 });
}
const statusPage = await this.statusPageService.updateStatusPage(statusPageId, teamId, req.file, req.body);
if (statusPage === null) {
throw new AppError({ message: "Status page not found", status: 404 });
}
@@ -57,29 +69,32 @@ class StatusPageController {
}
};
getStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
const statusPage = await this.db.statusPageModule.getStatusPage();
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data: statusPage,
});
} catch (error) {
next(error);
}
};
getStatusPageByUrl = async (req: Request, res: Response, next: NextFunction) => {
try {
await getStatusPageParamValidation.validateAsync(req.params);
await getStatusPageQueryValidation.validateAsync(req.query);
const statusPage = await this.db.statusPageModule.getStatusPageByUrl(req.params.url);
if (!req.params.url) {
throw new AppError({ message: "Status page URL is required", status: 400 });
}
const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url);
const settings = await this.settingsService.getDBSettings();
const showURL = settings.showURL;
const monitors = await this.monitorsRepository.findByIdsWithChecks(statusPage.monitors);
const normalizedMonitors = monitors.map((monitor) => {
const normalizedChecks = NormalizeData(monitor.checks, 10, 100);
if (!showURL) {
const { url, port, secret, notifications, ...rest } = monitor;
return { ...rest, checks: normalizedChecks };
}
return { ...monitor, checks: normalizedChecks };
});
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data: statusPage,
data: { statusPage, monitors: normalizedMonitors },
});
} catch (error) {
next(error);
@@ -87,8 +102,8 @@ class StatusPageController {
};
getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req.user?.teamId;
const statusPages = await this.db.statusPageModule.getStatusPagesByTeamId(teamId);
const teamId = requireTeamId(req.user?.teamId);
const statusPages = await this.statusPageService.getStatusPagesByTeamId(teamId);
return res.status(200).json({
success: true,
@@ -99,9 +114,15 @@ class StatusPageController {
next(error);
}
};
deleteStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.db.statusPageModule.deleteStatusPage(req.params.url);
const statusPageId = req.params.id;
if (!statusPageId) {
throw new AppError({ message: "Status page ID is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
await this.statusPageService.deleteStatusPage(statusPageId, teamId);
return res.status(200).json({
success: true,
msg: "Status page deleted successfully",
@@ -1,4 +1,4 @@
import { type MonitorType, type Monitor, type MonitorsSummary } from "@/types/index.js";
import { type MonitorType, type Monitor, type MonitorsSummary, type MonitorWithChecks } from "@/types/index.js";
export interface TeamQueryConfig {
limit?: number;
@@ -25,6 +25,7 @@ export interface IMonitorsRepository {
findAll(): Promise<Monitor[] | null>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByIds(monitorIds: string[]): Promise<Monitor[]>;
findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise<MonitorWithChecks[]>;
// update
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
@@ -1,7 +1,7 @@
import { MonitorModel } from "@/db/models/index.js";
import type { MonitorDocument } from "@/db/models/index.js";
import type { Monitor, MonitorsSummary } from "@/types/index.js";
import mongoose, { type FilterQuery } from "mongoose";
import type { Monitor, MonitorsSummary, MonitorWithChecks, Check } from "@/types/index.js";
import mongoose, { type FilterQuery, type PipelineStage } from "mongoose";
import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js";
import { AppError } from "@/utils/AppError.js";
@@ -79,6 +79,78 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.mapDocuments(monitors);
};
findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise<MonitorWithChecks[]> => {
if (!monitorIds.length) {
return [];
}
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const pipeline: PipelineStage[] = [
{ $match: { _id: { $in: objectIds } } },
{
$lookup: {
from: "checks",
let: { monitorId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$metadata.monitorId", "$$monitorId"] } } },
{ $sort: { createdAt: -1 } },
{ $limit: checksCount },
],
as: "checks",
},
},
{
$lookup: {
from: "maintenancewindows",
let: { monitorId: "$_id" },
pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }],
as: "maintenanceWindows",
},
},
{
$lookup: {
from: "monitorstats",
localField: "_id",
foreignField: "monitorId",
as: "stats",
},
},
{
$addFields: {
isMaintenance: {
$reduce: {
input: "$maintenanceWindows",
initialValue: false,
in: {
$or: [
"$$value",
{
$and: [
{ $eq: ["$$this.active", true] },
{ $lte: ["$$this.start", "$$NOW"] },
{ $gte: ["$$this.end", "$$NOW"] },
],
},
],
},
},
},
uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] },
},
},
{
$project: {
maintenanceWindows: 0,
stats: 0,
},
},
];
const documents = await MonitorModel.aggregate(pipeline);
return documents.map((doc) => this.toEntityWithChecks(doc));
};
findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise<number> => {
const { type } = config ?? {};
@@ -249,6 +321,90 @@ class MongoMonitorsRepository implements IMonitorsRepository {
updatedAt: toDateString(doc.updatedAt),
};
};
private toEntityWithChecks = (doc: any): MonitorWithChecks => {
const toStringId = (value: unknown): string => {
if (value instanceof mongoose.Types.ObjectId) {
return value.toString();
}
return value?.toString() ?? "";
};
const toDateString = (value: Date | string): string => {
if (!value) return "";
return value instanceof Date ? value.toISOString() : value;
};
const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification));
const checks: Check[] = (doc.checks ?? []).map((check: any) => ({
id: toStringId(check._id),
metadata: {
monitorId: toStringId(check.metadata?.monitorId),
teamId: toStringId(check.metadata?.teamId),
type: check.metadata?.type,
},
status: check.status,
responseTime: check.responseTime,
timings: check.timings,
statusCode: check.statusCode,
message: check.message,
ack: check.ack,
ackAt: check.ackAt ?? null,
expiry: toDateString(check.expiry),
cpu: check.cpu,
memory: check.memory,
disk: check.disk,
host: check.host,
errors: check.errors,
capture: check.capture,
net: check.net,
accessibility: check.accessibility,
bestPractices: check.bestPractices,
seo: check.seo,
performance: check.performance,
audits: check.audits,
__v: check.__v,
createdAt: toDateString(check.createdAt),
updatedAt: toDateString(check.updatedAt),
}));
return {
id: toStringId(doc._id),
userId: toStringId(doc.userId),
teamId: toStringId(doc.teamId),
name: doc.name,
description: doc.description ?? undefined,
status: doc.status ?? undefined,
statusWindow: doc.statusWindow ?? [],
statusWindowSize: doc.statusWindowSize,
statusWindowThreshold: doc.statusWindowThreshold,
type: doc.type,
ignoreTlsErrors: doc.ignoreTlsErrors,
jsonPath: doc.jsonPath ?? undefined,
expectedValue: doc.expectedValue ?? undefined,
matchMethod: doc.matchMethod ?? undefined,
url: doc.url,
port: doc.port ?? undefined,
isActive: doc.isActive,
interval: doc.interval,
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
secret: doc.secret ?? undefined,
thresholds: doc.thresholds ?? undefined,
alertThreshold: doc.alertThreshold,
cpuAlertThreshold: doc.cpuAlertThreshold,
memoryAlertThreshold: doc.memoryAlertThreshold,
diskAlertThreshold: doc.diskAlertThreshold,
tempAlertThreshold: doc.tempAlertThreshold,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
group: doc.group ?? null,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
checks,
};
};
}
export default MongoMonitorsRepository;
@@ -2,10 +2,15 @@ import type { StatusPage } from "@/types/statusPage.js";
export interface IStatusPagesRepository {
// create
create(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
// single fetch
findByUrl(url: string): Promise<StatusPage>;
findByTeamId(teamId: string): Promise<StatusPage[]>;
// collection fetch
// update
updateById(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
// delete
deleteById(id: string, teamId: string): Promise<StatusPage>;
// other
removeMonitorFromStatusPages(monitorId: string): Promise<number>;
}
@@ -2,6 +2,7 @@ import { IStatusPagesRepository } from "@/repositories/index.js";
import { type StatusPageDocument, StatusPageModel } from "@/db/models/StatusPage.js";
import type { StatusPage, StatusPageLogo } from "@/types/statusPage.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
class MongoStatusPagesRepository implements IStatusPagesRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
@@ -56,6 +57,74 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
};
};
private mapDocuments = (documents: StatusPageDocument[]): StatusPage[] => {
if (!documents?.length) {
return [];
}
return documents.map((doc) => this.toEntity(doc));
};
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const statusPage = new StatusPageModel({
...data,
userId,
teamId,
});
if (image) {
statusPage.logo = {
data: image.buffer,
contentType: image.mimetype,
};
}
await statusPage.save();
return this.toEntity(statusPage);
};
findByUrl = async (url: string): Promise<StatusPage> => {
const statusPage = await StatusPageModel.findOne({
url,
});
if (!statusPage) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.toEntity(statusPage);
// Get status page
};
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
const statusPages = await StatusPageModel.find({ teamId });
return this.mapDocuments(statusPages);
};
updateById = async (id: string, teamId: string, image: Express.Multer.File | undefined, patch: Partial<StatusPage>): Promise<StatusPage> => {
if (image) {
patch.logo = {
data: image.buffer,
contentType: image.mimetype,
};
} else {
patch.logo = null;
}
const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, patch, {
new: true,
});
if (!statusPage) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.toEntity(statusPage);
};
deleteById = async (id: string, teamId: string): Promise<StatusPage> => {
const statusPage = await StatusPageModel.findOneAndDelete({ _id: id, teamId });
if (!statusPage) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.toEntity(statusPage);
};
removeMonitorFromStatusPages = async (monitorId: string): Promise<number> => {
const res = await StatusPageModel.updateMany({ monitors: monitorId }, { $pull: { monitors: monitorId } });
return res.modifiedCount;
+1 -2
View File
@@ -13,11 +13,10 @@ class StatusPageRoutes {
}
initRoutes(verifyJWT: RequestHandler) {
this.router.get("/", this.statusPageController.getStatusPage);
this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId);
this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage);
this.router.put("/", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
this.router.get("/:url", this.statusPageController.getStatusPageByUrl);
this.router.delete("/:url(*)", verifyJWT, this.statusPageController.deleteStatusPage);
@@ -0,0 +1,42 @@
import { type IStatusPagesRepository } from "@/repositories/index.js";
import { StatusPage } from "@/types/index.js";
export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
getStatusPageByUrl(url: string): Promise<StatusPage>;
getStatusPagesByTeamId(teamId: string): Promise<StatusPage[]>;
updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
deleteStatusPage(statusPageId: string, teamId: string): Promise<StatusPage>;
}
export class StatusPageService implements IStatusPageService {
private statusPagesRepository: IStatusPagesRepository;
constructor(statusPagesRepository: IStatusPagesRepository) {
this.statusPagesRepository = statusPagesRepository;
}
createStatusPage = async (
userId: string,
teamId: string,
image: Express.Multer.File | undefined,
data: Partial<StatusPage>
): Promise<StatusPage> => {
return await this.statusPagesRepository.create(userId, teamId, image, data);
};
getStatusPageByUrl = async (url: string): Promise<StatusPage> => {
return await this.statusPagesRepository.findByUrl(url);
};
getStatusPagesByTeamId = async (teamId: string): Promise<StatusPage[]> => {
return await this.statusPagesRepository.findByTeamId(teamId);
};
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
return await this.statusPagesRepository.updateById(id, teamId, image, data);
};
deleteStatusPage = async (statusPageId: string, teamId: string): Promise<StatusPage> => {
return await this.statusPagesRepository.deleteById(statusPageId, teamId);
};
}
+1
View File
@@ -11,3 +11,4 @@ export * from "@/service/infrastructure/notificationsService.js";
export * from "@/service/infrastructure/statusService.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/matrix.js";
export * from "@/service/business/statusPageService.js";
+2 -1
View File
@@ -1,3 +1,4 @@
import type { Check } from "@/types/check.js";
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const;
export type MonitorType = (typeof MonitorTypes)[number];
@@ -53,7 +54,7 @@ export interface MonitorsSummary {
}
export interface MonitorWithChecks extends Monitor {
checks: import("./check.js").Check[];
checks: Check[];
}
export interface MonitorsWithChecksByTeamIdResult {