diff --git a/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx b/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx index 7da4d58ba..309201b84 100644 --- a/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx +++ b/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx @@ -148,7 +148,7 @@ const StatusPageBarChart = ({ checks = [] }) => { } placement="top" - key={`check-${check?._id}`} + key={`check-${check?.id}`} slotProps={{ popper: { className: "bar-tooltip", diff --git a/client/src/Components/v1/Icon/index.jsx b/client/src/Components/v1/Icon/index.jsx index 5828601fd..9f7c61585 100644 --- a/client/src/Components/v1/Icon/index.jsx +++ b/client/src/Components/v1/Icon/index.jsx @@ -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, diff --git a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx b/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx index dac5997f9..256fa4be9 100644 --- a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx +++ b/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx @@ -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); diff --git a/client/src/Pages/StatusPage/Create/index.jsx b/client/src/Pages/StatusPage/Create/index.jsx index f8fcf0e91..4eb171232 100644 --- a/client/src/Pages/StatusPage/Create/index.jsx +++ b/client/src/Pages/StatusPage/Create/index.jsx @@ -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"), diff --git a/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx b/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx index 839ff4cdf..9336e5d31 100644 --- a/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx +++ b/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx @@ -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({ diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 5b28281fe..4a096badd 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -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) { diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 3b81d27db..7a20ed93c 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -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), diff --git a/server/src/config/services.ts b/server/src/config/services.ts index fff56f9fb..3535f44d7 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -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"; @@ -42,7 +43,6 @@ import { games, GameDig } from "gamedig"; import jmespath from "jmespath"; // DB Modules -import { NormalizeData } from "../utils/dataUtils.js"; import { GenerateAvatarImage } from "../utils/imageProcessing.js"; import { ParseBoolean } from "../utils/utils.js"; @@ -50,16 +50,13 @@ import { ParseBoolean } from "../utils/utils.js"; import Monitor from "../db/models/Monitor.js"; import User from "../db/models/User.js"; import InviteToken from "../db/models/Invite.js"; -import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; import MonitorStats from "../db/models/MonitorStats.js"; import NotificationModel from "../db/models/Notification.js"; import RecoveryToken from "../db/models/RecoveryToken.js"; -import AppSettings from "../db/models/AppSettings.js"; import Incident from "../db/models/Incident.js"; -import StatusPageModule from "../db/modules/statusPageModule.js"; import IncidentModule from "../db/modules/incidentModule.js"; // repositories @@ -109,6 +106,7 @@ export type InitializedServices = { errorService: any; logger: any; notificationsService: INotificationsService; + statusPageService: IStatusPageService; // Repositories monitorsRepository: IMonitorsRepository; @@ -137,13 +135,11 @@ export const initializeServices = async ({ settingsRepository: ISettingsRepository; }): Promise => { // Create DB - const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, AppSettings }); const incidentModule = new IncidentModule({ logger, Incident, Monitor, User }); const db = new MongoDB({ logger, envSettings, - statusPageModule, incidentModule, }); @@ -273,6 +269,8 @@ export const initializeServices = async ({ statusPagesRepository, }); + const statusPageService = new StatusPageService(statusPagesRepository); + const services = { //v1 settingsService, @@ -292,6 +290,7 @@ export const initializeServices = async ({ errorService, logger, notificationsService, + statusPageService, // Repositories monitorsRepository, diff --git a/server/src/controllers/controllerUtils.ts b/server/src/controllers/controllerUtils.ts index af3eddd44..e6c8767fa 100755 --- a/server/src/controllers/controllerUtils.ts +++ b/server/src/controllers/controllerUtils.ts @@ -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, }; diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index b63e925c0..3501a6592 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -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", diff --git a/server/src/db/MongoDB.js b/server/src/db/MongoDB.js index 07d3d31c8..f149fbd6d 100755 --- a/server/src/db/MongoDB.js +++ b/server/src/db/MongoDB.js @@ -4,10 +4,9 @@ import { runMigrations } from "./migration/index.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, statusPageModule, incidentModule }) { + constructor({ logger, envSettings, incidentModule }) { this.logger = logger; this.envSettings = envSettings; - this.statusPageModule = statusPageModule; this.incidentModule = incidentModule; } diff --git a/server/src/db/modules/statusPageModule.js b/server/src/db/modules/statusPageModule.js deleted file mode 100755 index 78a632cb8..000000000 --- a/server/src/db/modules/statusPageModule.js +++ /dev/null @@ -1,299 +0,0 @@ -const SERVICE_NAME = "statusPageModule"; - -class StatusPageModule { - constructor({ StatusPage, NormalizeData, AppSettings }) { - this.StatusPage = StatusPage; - this.NormalizeData = NormalizeData; - this.AppSettings = AppSettings; - } - - createStatusPage = async ({ statusPageData, image, userId, teamId }) => { - try { - const statusPage = new this.StatusPage({ - ...statusPageData, - userId, - teamId, - }); - if (image) { - statusPage.logo = { - data: image.buffer, - contentType: image.mimetype, - }; - } - await statusPage.save(); - return statusPage; - } catch (error) { - if (error?.code === 11000) { - // Handle duplicate URL errors - error.status = 400; - error.message = "Status page URL must be unique"; - } - error.service = SERVICE_NAME; - error.method = "createStatusPage"; - throw error; - } - }; - - updateStatusPage = async (statusPageData, image) => { - try { - if (image) { - statusPageData.logo = { - data: image.buffer, - contentType: image.mimetype, - }; - } else { - statusPageData.logo = null; - } - - if (statusPageData.deleteSubmonitors === "true") { - statusPageData.subMonitors = []; - } - const statusPage = await this.StatusPage.findOneAndUpdate({ url: statusPageData.url }, statusPageData, { - new: true, - }); - - return statusPage; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "updateStatusPage"; - throw error; - } - }; - - getStatusPageByUrl = async (url) => { - // TODO This is deprecated, can remove and have controller call getStatusPage - try { - return this.getStatusPage(url); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getStatusPageByUrl"; - throw error; - } - }; - - getStatusPagesByTeamId = async (teamId) => { - try { - const statusPages = await this.StatusPage.find({ teamId }); - return statusPages; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getStatusPagesByTeamId"; - throw error; - } - }; - - getStatusPage = async (url) => { - try { - const preliminaryStatusPage = await this.StatusPage.findOne({ url }); - if (!preliminaryStatusPage) { - const error = new Error("Status page not found"); - error.status = 404; - throw error; - } - - if (!preliminaryStatusPage.monitors || preliminaryStatusPage.monitors.length === 0) { - const { _id, color, companyName, isPublished, logo, originalMonitors, showCharts, showUptimePercentage, timezone, showAdminLoginLink, url } = - preliminaryStatusPage; - return { - statusPage: { - _id, - color, - companyName, - isPublished, - logo, - originalMonitors, - showCharts, - showUptimePercentage, - timezone, - showAdminLoginLink, - url, - }, - monitors: [], - }; - } - - const statusPageQuery = await this.StatusPage.aggregate([ - { $match: { url: url } }, - { - $set: { - originalMonitors: "$monitors", - }, - }, - { - $lookup: { - from: "monitors", - localField: "monitors", - foreignField: "_id", - as: "monitors", - }, - }, - { - $unwind: { - path: "$monitors", - preserveNullAndEmptyArrays: true, - }, - }, - { - $lookup: { - from: "maintenancewindows", - let: { monitorId: "$monitors._id" }, - pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }], - as: "monitors.maintenanceWindows", - }, - }, - { - $lookup: { - from: "checks", - let: { monitorId: "$monitors._id" }, - pipeline: [ - { - $match: { - $expr: { $eq: ["$metadata.monitorId", "$$monitorId"] }, - }, - }, - { $sort: { createdAt: -1 } }, - { $limit: 25 }, - ], - as: "monitors.checks", - }, - }, - { - $lookup: { - from: "monitorstats", - localField: "monitors._id", - foreignField: "monitorId", - as: "monitors.stats", - }, - }, - { - $addFields: { - "monitors.orderIndex": { - $indexOfArray: ["$originalMonitors", "$monitors._id"], - }, - "monitors.isMaintenance": { - $reduce: { - input: "$monitors.maintenanceWindows", - initialValue: false, - in: { - $or: [ - "$$value", - { - $and: [{ $eq: ["$$this.active", true] }, { $lte: ["$$this.start", "$$NOW"] }, { $gte: ["$$this.end", "$$NOW"] }], - }, - ], - }, - }, - }, - "monitors.uptimePercentage": { - $arrayElemAt: ["$monitors.stats.uptimePercentage", 0], - }, - }, - }, - { $match: { "monitors.orderIndex": { $ne: -1 } } }, - { $sort: { "monitors.orderIndex": 1 } }, - - { - $group: { - _id: "$_id", - statusPage: { $first: "$$ROOT" }, - monitors: { $push: "$monitors" }, - }, - }, - { - $project: { - statusPage: { - _id: 1, - color: 1, - companyName: 1, - isPublished: 1, - logo: 1, - originalMonitors: 1, - showCharts: 1, - showUptimePercentage: 1, - timezone: 1, - showAdminLoginLink: 1, - url: 1, - }, - monitors: { - _id: 1, - userId: 1, - teamId: 1, - name: 1, - description: 1, - status: 1, - type: 1, - ignoreTlsErrors: 1, - jsonPath: 1, - expectedValue: 1, - matchMethod: 1, - url: 1, - port: 1, - isActive: 1, - interval: 1, - uptimePercentage: 1, - notifications: 1, - secret: 1, - thresholds: 1, - alertThreshold: 1, - cpuAlertThreshold: 1, - memoryAlertThreshold: 1, - diskAlertThreshold: 1, - tempAlertThreshold: 1, - checks: 1, - isMaintenance: 1, - createdAt: 1, - updatedAt: 1, - }, - }, - }, - ]); - if (!statusPageQuery.length) { - const error = new Error("Status page not found"); - error.status = 404; - throw error; - } - - const { statusPage, monitors } = statusPageQuery[0]; - - const appSettings = await this.AppSettings.findOne({ singleton: true }).lean(); - const showURL = appSettings?.showURL === true; - - const normalizedMonitors = monitors.map((monitor) => { - const normalizedChecks = this.NormalizeData(monitor.checks, 10, 100); - if (!showURL) { - const { url, port, secret, notifications, ...rest } = monitor; - return { ...rest, checks: normalizedChecks }; - } - return { ...monitor, checks: normalizedChecks }; - }); - - return { statusPage, monitors: normalizedMonitors }; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getStatusPageByUrl"; - throw error; - } - }; - - deleteStatusPage = async (url) => { - try { - await this.StatusPage.deleteOne({ url }); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteStatusPage"; - throw error; - } - }; - deleteStatusPagesByMonitorId = async (monitorId) => { - try { - await this.StatusPage.deleteMany({ monitors: { $in: [monitorId] } }); - await this.StatusPage.deleteMany({ subMonitors: { $in: [monitorId] } }); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteStatusPageByMonitorId"; - throw error; - } - }; -} - -export default StatusPageModule; diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 8ead5a688..072772e9e 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -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; findByTeamId(teamId: string, config: TeamQueryConfig): Promise; findByIds(monitorIds: string[]): Promise; + findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index ad7e8577d..5ce029634 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -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,70 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.mapDocuments(monitors); }; + findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { + 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 => { const { type } = config ?? {}; @@ -249,6 +313,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; diff --git a/server/src/repositories/status-pages/IStatusPagesRepository.ts b/server/src/repositories/status-pages/IStatusPagesRepository.ts index 1b9f9303b..c901b3c6d 100644 --- a/server/src/repositories/status-pages/IStatusPagesRepository.ts +++ b/server/src/repositories/status-pages/IStatusPagesRepository.ts @@ -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): Promise; // single fetch + findByUrl(url: string): Promise; + findByTeamId(teamId: string): Promise; // collection fetch // update + updateById(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // delete + deleteById(id: string, teamId: string): Promise; // other removeMonitorFromStatusPages(monitorId: string): Promise; } diff --git a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts index e1eb4715b..d5461bef3 100644 --- a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts +++ b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts @@ -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): Promise => { + 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 => { + 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 => { + const statusPages = await StatusPageModel.find({ teamId }); + return this.mapDocuments(statusPages); + }; + + updateById = async (id: string, teamId: string, image: Express.Multer.File | undefined, patch: Partial): Promise => { + 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 => { + 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 => { const res = await StatusPageModel.updateMany({ monitors: monitorId }, { $pull: { monitors: monitorId } }); return res.modifiedCount; diff --git a/server/src/routes/statusPageRoute.ts b/server/src/routes/statusPageRoute.ts index 9d6902aac..f70802f73 100755 --- a/server/src/routes/statusPageRoute.ts +++ b/server/src/routes/statusPageRoute.ts @@ -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); diff --git a/server/src/service/business/statusPageService.ts b/server/src/service/business/statusPageService.ts new file mode 100644 index 000000000..971f6897f --- /dev/null +++ b/server/src/service/business/statusPageService.ts @@ -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): Promise; + getStatusPageByUrl(url: string): Promise; + getStatusPagesByTeamId(teamId: string): Promise; + updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; + + deleteStatusPage(statusPageId: string, teamId: string): Promise; +} + +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 + ): Promise => { + return await this.statusPagesRepository.create(userId, teamId, image, data); + }; + + getStatusPageByUrl = async (url: string): Promise => { + return await this.statusPagesRepository.findByUrl(url); + }; + + getStatusPagesByTeamId = async (teamId: string): Promise => { + return await this.statusPagesRepository.findByTeamId(teamId); + }; + + updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { + return await this.statusPagesRepository.updateById(id, teamId, image, data); + }; + + deleteStatusPage = async (statusPageId: string, teamId: string): Promise => { + return await this.statusPagesRepository.deleteById(statusPageId, teamId); + }; +} diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 7f7d0f14a..7ec49288f 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -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"; diff --git a/server/src/shutdown.js b/server/src/shutdown.ts similarity index 71% rename from server/src/shutdown.js rename to server/src/shutdown.ts index 27eb0f21d..877aef325 100644 --- a/server/src/shutdown.js +++ b/server/src/shutdown.ts @@ -1,6 +1,8 @@ +import { InitializedServices } from "./config/services.js"; import { logger } from "./utils/logger.js"; +import type { Server } from "http"; -export const initShutdownListener = (server, services) => { +export const initShutdownListener = (server: Server, services: InitializedServices) => { const SERVICE_NAME = "Server"; let isShuttingDown = false; @@ -19,11 +21,12 @@ export const initShutdownListener = (server, services) => { logger.info({ message: "Graceful shutdown complete" }); process.exit(0); } catch (error) { + const err = error as Error; logger.error({ - message: error.message, + message: err.message, service: SERVICE_NAME, method: "shutdown", - stack: error.stack, + stack: err.stack, }); process.exit(1); } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 1d41a7333..c7d99a88a 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -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 {