From 094def117ad45c6b8b87df0d10588b5adc4d7f1e Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:42:51 +0530 Subject: [PATCH 01/42] feat(monitors): add bulk pause/resume api endpoints and service logic - Validates bulk monitor IDs using zod - Implements optimized Mongo $in/$ne queries for performance - Orchestrates background job pausing/resuming via BullMQ - Added comprehensive unit tests for monitor service logic Fixes #3602 --- server/src/controllers/monitorController.ts | 24 ++++- .../monitors/IMonitorsRepository.ts | 1 + .../monitors/MongoMonitorsRepository.ts | 25 +++++ server/src/routes/monitorRoute.ts | 1 + server/src/service/business/monitorService.ts | 17 ++++ server/src/validation/monitorValidation.ts | 8 ++ .../test/unit/services/monitorService.test.ts | 91 +++++++++++++++++++ 7 files changed, 166 insertions(+), 1 deletion(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 7f1136b7c..4529f972d 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -14,12 +14,12 @@ import { getUptimeDetailsByIdParamValidation, getUptimeDetailsByIdQueryValidation, importMonitorsBodyValidation, + bulkPauseMonitorBodyValidation, } from "@/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorService, INotificationsService } from "@/service/index.js"; -import { GeoContinent } from "@/types/geoCheck.js"; const SERVICE_NAME = "monitorController"; @@ -36,6 +36,7 @@ export interface IMonitorController { deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise; editMonitor: (req: Request, res: Response, next: NextFunction) => Promise; pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise; + bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise; addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise; getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; @@ -307,6 +308,27 @@ class MonitorController implements IMonitorController { } }; + bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body); + + const { monitorIds, pause } = validatedBody; + const teamId = requireTeamId(req.user?.teamId); + + const monitors = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); + + const action = pause ? "paused" : "resumed"; + + return res.status(200).json({ + success: true, + msg: `${monitors.length} monitor(s) ${action} successfully`, + data: monitors, + }); + } catch (error) { + next(error); + } + }; + addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => { try { const id = requireUserId(req.user?.id); diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 941556589..ac4c8f9eb 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -39,6 +39,7 @@ export interface IMonitorsRepository { statusPatch?: Partial ): Promise; togglePauseById(monitorId: string, teamId: string): Promise; + bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise; // delete deleteById(monitorId: string, teamId: string): Promise; deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index bd6d506d5..9f5efecfe 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -234,6 +234,31 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(monitor); }; + bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const filter = { + _id: { $in: objectIds }, + teamId: new mongoose.Types.ObjectId(teamId), + isActive: pause, // Only pause if active (true), only resume if inactive (false) + }; + + const eligibleIds = (await MonitorModel.find(filter).select("_id").lean()).map((doc) => doc._id); + if (eligibleIds.length === 0) return []; + + await MonitorModel.updateMany( + { _id: { $in: eligibleIds } }, + { + $set: { + isActive: !pause, + status: pause ? "paused" : "initializing", + }, + } + ); + + const updatedMonitors = await MonitorModel.find({ _id: { $in: eligibleIds } }); + return this.mapDocuments(updatedMonitors); + }; + deleteById = async (monitorId: string, teamId: string) => { const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId }); diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index 277cd250b..0b5e24dd0 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -31,6 +31,7 @@ class MonitorRoutes { // General monitor routes this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor); + this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors); // Util routes this.router.get("/certificate/:monitorId", (req, res, next) => { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 4ab5c9476..e2430420d 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -73,6 +73,7 @@ export interface IMonitorService { // update editMonitor(args: { teamId: string; monitorId: string; body: Partial }): Promise; pauseMonitor(args: { teamId: string; monitorId: string }): Promise; + bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise; // delete deleteMonitor(args: { teamId: string; monitorId: string }): Promise; @@ -462,6 +463,22 @@ export class MonitorService implements IMonitorService { return monitor; }; + bulkPauseMonitors = async ({ teamId, monitorIds, pause }: { teamId: string; monitorIds: string[]; pause: boolean }): Promise => { + const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause); + + await Promise.all( + monitors.map(async (monitor) => { + if (monitor.isActive) { + await this.jobQueue.resumeJob(monitor); + } else { + await this.jobQueue.pauseJob(monitor); + } + }) + ); + + return monitors; + }; + deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.deleteById(monitorId, teamId); await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => { diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef..48b1da34a 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -113,6 +113,14 @@ export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); +export const bulkPauseMonitorBodyValidation = z.object({ + monitorIds: z + .array(z.string().min(1, "Monitor ID must not be empty")) + .min(1, "At least one monitor ID is required") + .max(100, "Cannot bulk update more than 100 monitors at once"), + pause: z.boolean(), +}); + export const getUptimeDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index cd424acf9..74a0efa98 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -23,6 +23,7 @@ const createMonitorsRepositoryMock = () => updateById: jest.fn(), updateNotifications: jest.fn(), togglePauseById: jest.fn(), + bulkTogglePause: jest.fn(), deleteById: jest.fn(), deleteByTeamId: jest.fn(), }) as unknown as IMonitorsRepository; @@ -839,6 +840,96 @@ describe("MonitorService", () => { }); }); + describe("bulkPauseMonitors", () => { + it("resumes jobs for monitors that are active after bulk toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1", isActive: true }), makeMonitor({ id: "m2", isActive: true })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: false, + }); + + expect(result).toHaveLength(2); + expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false); + expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2); + expect(jobQueue.pauseJob).not.toHaveBeenCalled(); + }); + + it("pauses jobs for monitors that are inactive after bulk toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1", isActive: false }), makeMonitor({ id: "m2", isActive: false })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result).toHaveLength(2); + expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true); + expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2); + expect(jobQueue.resumeJob).not.toHaveBeenCalled(); + }); + + it("handles mixed active/inactive monitors correctly", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const activeMonitor = makeMonitor({ id: "m1", isActive: true }); + const pausedMonitor = makeMonitor({ id: "m2", isActive: false }); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([activeMonitor, pausedMonitor]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result).toHaveLength(2); + expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1); + expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor); + expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1); + expect(jobQueue.pauseJob).toHaveBeenCalledWith(pausedMonitor); + }); + + it("returns empty array when no monitors were affected", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result).toHaveLength(0); + expect(jobQueue.pauseJob).not.toHaveBeenCalled(); + expect(jobQueue.resumeJob).not.toHaveBeenCalled(); + }); + + it("works with a single monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ id: "m1", isActive: false }); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([monitor]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1"], + pause: true, + }); + + expect(result).toHaveLength(1); + expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor); + }); + }); + describe("deleteMonitor", () => { it("deletes monitor and all associated data", async () => { const monitorsRepository = createMonitorsRepositoryMock(); From 6b9759e455f4f0b0f5eba375f33ffe117c1f6b7e Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:43:27 +0530 Subject: [PATCH 02/42] feat(monitors): create bulk actions ui components and selection hooks - Extracts generic table selection logic into useTableSelection hook - Creates BulkActionsBar component with Collapse animations - Implements useBulkMonitorActions to handle API logic and toasts - Adds i18n translation strings across all 16 locales Fixes #3602 --- .../Components/monitors/BulkActionsBar.tsx | 78 +++++++++++++++++++ client/src/Components/monitors/index.tsx | 1 + client/src/Hooks/useBulkMonitorActions.ts | 62 +++++++++++++++ client/src/Hooks/useTableSelection.ts | 74 ++++++++++++++++++ client/src/locales/ar.json | 5 +- client/src/locales/cs.json | 5 +- client/src/locales/de.json | 5 +- client/src/locales/en.json | 5 +- client/src/locales/es.json | 5 +- client/src/locales/fi.json | 5 +- client/src/locales/fr.json | 5 +- client/src/locales/ja.json | 5 +- client/src/locales/pt-BR.json | 5 +- client/src/locales/ru.json | 5 +- client/src/locales/th.json | 5 +- client/src/locales/tr.json | 5 +- client/src/locales/uk.json | 5 +- client/src/locales/vi.json | 5 +- client/src/locales/zh-CN.json | 5 +- client/src/locales/zh-TW.json | 5 +- 20 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 client/src/Components/monitors/BulkActionsBar.tsx create mode 100644 client/src/Hooks/useBulkMonitorActions.ts create mode 100644 client/src/Hooks/useTableSelection.ts diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx new file mode 100644 index 000000000..aef5e4871 --- /dev/null +++ b/client/src/Components/monitors/BulkActionsBar.tsx @@ -0,0 +1,78 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Collapse from "@mui/material/Collapse"; +import { Play, Pause } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; + +interface BulkActionsBarProps { + selectedCount: number; + onResume: () => void; + onPause: () => void; + onCancel: () => void; +} + +export const BulkActionsBar = ({ + selectedCount, + onResume, + onPause, + onCancel, +}: BulkActionsBarProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + 0}> + + + + {t("pages.common.monitors.actions.bulkSelected", { + count: selectedCount, + })} + + + + + + + + + + ); +}; diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx index efba79a08..46532706f 100644 --- a/client/src/Components/monitors/index.tsx +++ b/client/src/Components/monitors/index.tsx @@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails"; export * from "./charts/HistogramPageSpeedDetailsTooltip"; export * from "./charts/HistogramInfrastructure"; export * from "./HeaderMonitorsSummary"; +export * from "./BulkActionsBar"; diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts new file mode 100644 index 000000000..3bb520aa1 --- /dev/null +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -0,0 +1,62 @@ +import { useState, useEffect } from "react"; +import { usePost } from "@/Hooks/UseApi"; + +interface BulkPauseBody { + monitorIds: string[]; + pause: boolean; +} + +interface UseBulkMonitorActionsReturn { + selectedRows: string[]; + setSelectedRows: (rows: string[]) => void; + handleBulkPause: () => Promise; + handleBulkResume: () => Promise; + handleCancelSelection: () => void; +} + +export const useBulkMonitorActions = ( + refetch: () => void, + page?: number +): UseBulkMonitorActionsReturn => { + const [selectedRows, setSelectedRows] = useState([]); + const { post: bulkPost } = usePost(); + + // Clear selection when page changes + useEffect(() => { + setSelectedRows([]); + }, [page]); + + const handleBulkPause = async () => { + const result = await bulkPost("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause: true, + }); + if (result) { + setSelectedRows([]); + refetch(); + } + }; + + const handleBulkResume = async () => { + const result = await bulkPost("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause: false, + }); + if (result) { + setSelectedRows([]); + refetch(); + } + }; + + const handleCancelSelection = () => { + setSelectedRows([]); + }; + + return { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + }; +}; diff --git a/client/src/Hooks/useTableSelection.ts b/client/src/Hooks/useTableSelection.ts new file mode 100644 index 000000000..890225ade --- /dev/null +++ b/client/src/Hooks/useTableSelection.ts @@ -0,0 +1,74 @@ +import { useMemo, useCallback } from "react"; + +interface SelectableItem { + id: string; +} + +interface UseTableSelectionReturn { + isAllSelected: boolean; + isSomeSelected: boolean; + handleSelectAll: (checked: boolean) => void; + handleSelectRow: (itemId: string, checked: boolean) => void; + isRowSelected: (itemId: string) => boolean; +} + +export const useTableSelection = ( + items: SelectableItem[], + selectedIds: string[], + onSelectionChange?: (selected: string[]) => void +): UseTableSelectionReturn => { + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + const isAllSelected = useMemo( + () => + items.length > 0 && + items.every((item) => selectedSet.has(item.id)) && + selectedIds.length >= items.length, + [items, selectedSet, selectedIds.length] + ); + + const isSomeSelected = useMemo( + () => selectedIds.length > 0 && !isAllSelected, + [selectedIds.length, isAllSelected] + ); + + const handleSelectAll = useCallback( + (checked: boolean) => { + if (onSelectionChange) { + if (checked) { + const allIds = items.map((item) => item.id); + onSelectionChange(allIds); + } else { + onSelectionChange([]); + } + } + }, + [items, onSelectionChange] + ); + + const handleSelectRow = useCallback( + (itemId: string, checked: boolean) => { + if (onSelectionChange) { + if (checked) { + onSelectionChange([...selectedIds, itemId]); + } else { + onSelectionChange(selectedIds.filter((id) => id !== itemId)); + } + } + }, + [selectedIds, onSelectionChange] + ); + + const isRowSelected = useCallback( + (itemId: string) => selectedSet.has(itemId), + [selectedSet] + ); + + return { + isAllSelected, + isSomeSelected, + handleSelectAll, + handleSelectRow, + isRowSelected, + }; +}; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 11fa81309..70509feed 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -421,7 +421,10 @@ "inviteMember": "دعوة عضو", "openSite": "فتح الموقع", "pause": "إيقاف مؤقت", - "resume": "استئناف" + "resume": "استئناف", + "bulkSelected": "تم تحديد {{count}} جهاز(أجهزة) مراقبة", + "selectAll": "تحديد جميع أجهزة المراقبة", + "selectMonitor": "تحديد {{name}}" }, "statBoxes": { "activeFor": "نشط منذ", diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 454f0b673..e86677c29 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -421,7 +421,10 @@ "inviteMember": "Pozvat člena", "openSite": "Otevřít stránku", "pause": "Pozastavit", - "resume": "Obnovit" + "resume": "Obnovit", + "bulkSelected": "{{count}} monitor(ů) vybráno", + "selectAll": "Vybrat všechny monitory", + "selectMonitor": "Vybrat {{name}}" }, "statBoxes": { "activeFor": "Aktivní po dobu", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 8090aa5f3..cd93c4810 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -421,7 +421,10 @@ "inviteMember": "Mitglied einladen", "openSite": "Seite öffnen", "pause": "Pausieren", - "resume": "Fortsetzen" + "resume": "Fortsetzen", + "bulkSelected": "{{count}} Monitor(e) ausgewählt", + "selectAll": "Alle Monitore auswählen", + "selectMonitor": "{{name}} auswählen" }, "statBoxes": { "activeFor": "Aktiv seit", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d6e8bb4f3..2e22baa07 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -421,7 +421,10 @@ "inviteMember": "Invite member", "openSite": "Open site", "pause": "Pause", - "resume": "Resume" + "resume": "Resume", + "bulkSelected": "{{count}} Monitor(s) selected", + "selectAll": "Select all monitors", + "selectMonitor": "Select {{name}}" }, "statBoxes": { "activeFor": "Active for", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 506c317e3..04f447e40 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -428,7 +428,10 @@ "inviteMember": "Invitar miembro", "openSite": "Abrir sitio", "pause": "Pausar", - "resume": "Reanudar" + "resume": "Reanudar", + "bulkSelected": "{{count}} monitor(es) seleccionado(s)", + "selectAll": "Seleccionar todos los monitores", + "selectMonitor": "Seleccionar {{name}}" }, "statBoxes": { "activeFor": "Activo desde hace", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 6238837f2..74afedd84 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -421,7 +421,10 @@ "inviteMember": "Kutsu jäsen", "openSite": "Avaa sivusto", "pause": "Keskeytä", - "resume": "Jatka" + "resume": "Jatka", + "bulkSelected": "{{count}} monitori(a) valittu", + "selectAll": "Valitse kaikki monitorit", + "selectMonitor": "Valitse {{name}}" }, "statBoxes": { "activeFor": "Aktiivinen", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 306a2bd61..8e3c78b85 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -421,7 +421,10 @@ "inviteMember": "Inviter un membre", "openSite": "Ouvrir le site", "pause": "Pause", - "resume": "Reprendre" + "resume": "Reprendre", + "bulkSelected": "{{count}} moniteur(s) sélectionné(s)", + "selectAll": "Sélectionner tous les moniteurs", + "selectMonitor": "Sélectionner {{name}}" }, "statBoxes": { "activeFor": "Actif depuis", diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 923213a46..09db6971f 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -421,7 +421,10 @@ "inviteMember": "メンバーを招待", "openSite": "サイトを開く", "pause": "一時停止", - "resume": "再開" + "resume": "再開", + "bulkSelected": "{{count}} 件のモニターを選択中", + "selectAll": "すべてのモニターを選択", + "selectMonitor": "{{name}} を選択" }, "statBoxes": { "activeFor": "稼働期間", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index f7b7c4cbc..6c97a9869 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -421,7 +421,10 @@ "inviteMember": "Convidar membro", "openSite": "Abrir site", "pause": "Pausar", - "resume": "Retomar" + "resume": "Retomar", + "bulkSelected": "{{count}} monitor(es) selecionado(s)", + "selectAll": "Selecionar todos os monitores", + "selectMonitor": "Selecionar {{name}}" }, "statBoxes": { "activeFor": "Ativo há", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 1a5d12452..778d481f9 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -421,7 +421,10 @@ "inviteMember": "Пригласить участника", "openSite": "Открыть сайт", "pause": "Пауза", - "resume": "Продолжить" + "resume": "Продолжить", + "bulkSelected": "Выбрано мониторов: {{count}}", + "selectAll": "Выбрать все мониторы", + "selectMonitor": "Выбрать {{name}}" }, "statBoxes": { "activeFor": "Активен", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 433ee10cd..aab97dc93 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -421,7 +421,10 @@ "inviteMember": "เชิญสมาชิก", "openSite": "เปิดเว็บไซต์", "pause": "หยุดชั่วคราว", - "resume": "ดำเนินการต่อ" + "resume": "ดำเนินการต่อ", + "bulkSelected": "เลือก {{count}} มอนิเตอร์แล้ว", + "selectAll": "เลือกมอนิเตอร์ทั้งหมด", + "selectMonitor": "เลือก {{name}}" }, "statBoxes": { "activeFor": "ทำงานมาแล้ว", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index a77dd8eb3..6e6aaf365 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -421,7 +421,10 @@ "inviteMember": "Üye davet et", "openSite": "Siteyi aç", "pause": "Duraklat", - "resume": "Devam et" + "resume": "Devam et", + "bulkSelected": "{{count}} monitör seçildi", + "selectAll": "Tüm monitörleri seç", + "selectMonitor": "{{name}} seç" }, "statBoxes": { "activeFor": "Aktif süresi", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 5b467e4b4..f7ff24647 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -421,7 +421,10 @@ "inviteMember": "Запросити учасника", "openSite": "Відкрити сайт", "pause": "Призупинити", - "resume": "Відновити" + "resume": "Відновити", + "bulkSelected": "Вибрано моніторів: {{count}}", + "selectAll": "Вибрати всі монітори", + "selectMonitor": "Вибрати {{name}}" }, "statBoxes": { "activeFor": "Активний протягом", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index dc3e804de..90d8afd04 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -421,7 +421,10 @@ "inviteMember": "Mời thành viên", "openSite": "Mở trang web", "pause": "Tạm dừng", - "resume": "Tiếp tục" + "resume": "Tiếp tục", + "bulkSelected": "Đã chọn {{count}} monitor", + "selectAll": "Chọn tất cả monitor", + "selectMonitor": "Chọn {{name}}" }, "statBoxes": { "activeFor": "Hoạt động từ", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index ed5e70c1e..73a4ce166 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -421,7 +421,10 @@ "inviteMember": "邀请成员", "openSite": "打开站点", "pause": "暂停", - "resume": "恢复" + "resume": "恢复", + "bulkSelected": "已选择 {{count}} 个监控", + "selectAll": "选择所有监控", + "selectMonitor": "选择 {{name}}" }, "statBoxes": { "activeFor": "已活跃", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index ff6e9f6dd..91fc7766e 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -421,7 +421,10 @@ "inviteMember": "邀請成員", "openSite": "開啟網站", "pause": "暫停", - "resume": "恢復" + "resume": "恢復", + "bulkSelected": "已選擇 {{count}} 個監控", + "selectAll": "選擇所有監控", + "selectMonitor": "選擇 {{name}}" }, "statBoxes": { "activeFor": "已啟用", From 4e284c146893e9a1bfdd4b87eaa7deeee565d028 Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:44:07 +0530 Subject: [PATCH 03/42] feat(monitors): integrate bulk actions into uptime and infrastructure tables - Mounts selection checkboxes in UptimeMonitorsTable and MonitorsTable - Enhances global Table.tsx to cleanly render checkboxes on mobile cards via mobileLabel - Injects BulkActionsBar into main list views Fixes #3602 --- .../src/Components/design-elements/Table.tsx | 23 ++++- .../Monitors/Components/MonitorsTable.tsx | 91 ++++++++++++------ .../Pages/Infrastructure/Monitors/index.tsx | 29 +++++- .../Components/UptimeMonitorsTable.tsx | 95 +++++++++++++------ client/src/Pages/Uptime/Monitors/index.tsx | 29 +++++- 5 files changed, 208 insertions(+), 59 deletions(-) diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 91bb69615..121d890d1 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -35,6 +35,7 @@ import useMediaQuery from "@mui/material/useMediaQuery"; export type Header = { id: number | string; content: React.ReactNode; + mobileLabel?: React.ReactNode; onClick?: (event: React.MouseEvent, row: T) => void; render: (row: T) => React.ReactNode; }; @@ -109,6 +110,24 @@ export function DataTable< key={key} > {headers.map((header) => { + if (header.mobileLabel === null) { + return ( + + + {header.render(row)} + + + ); + } + return ( - {header.content} + {header.mobileLabel !== undefined + ? header.mobileLabel + : header.content} void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const InfraMonitorsTable = ({ monitors, @@ -26,29 +45,22 @@ export const InfraMonitorsTable = ({ setPage, rowsPerPage, setRowsPerPage, -}: { - monitors: Monitor[]; - refetch: Function; - setSelectedMonitor: Function; - sortField: string; - setSortField: (field: string) => void; - sortOrder: "asc" | "desc"; - setSortOrder: (order: "asc" | "desc") => void; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { + selectedRows = [], + onSelectionChange, +}: InfraMonitorsTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const navigate = useNavigate(); + const { post } = usePost, Monitor>(); + const { - post, - // loading: isPatching, - // error: postError, - } = usePost(); + isAllSelected, + isSomeSelected, + handleSelectAll, + handleSelectRow, + isRowSelected, + } = useTableSelection(monitors, selectedRows, onSelectionChange); const handlePageChange = ( _e: React.MouseEvent | null, @@ -65,7 +77,7 @@ export const InfraMonitorsTable = ({ setRowsPerPage(value); }; - const handleSort = (e: any, field: string) => { + const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); e.stopPropagation(); if (sortField === field) { @@ -101,13 +113,6 @@ export const InfraMonitorsTable = ({ navigate(`/infrastructure/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -115,7 +120,7 @@ export const InfraMonitorsTable = ({ ? t("common.buttons.resume") : t("common.buttons.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {}); + await post(`/monitors/pause/${monitor.id}`, {} as Record); refetch(); }, closeMenu: true, @@ -152,8 +157,34 @@ export const InfraMonitorsTable = ({ ); const headers: Header[] = [ + { + id: "selection", + content: ( + handleSelectAll(e.target.checked)} + /> + ), + mobileLabel: null, + render: (row) => ( + { + handleSelectRow(row.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + ), + onClick: (e) => e?.stopPropagation(), + }, { id: "name", + mobileLabel: t("common.table.headers.name"), content: ( { navigate(`/infrastructure/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -37,6 +42,7 @@ const InfrastructureMonitors = () => { const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); const debouncedSearch = useDebounce(search, 300); @@ -97,6 +103,15 @@ const InfrastructureMonitors = () => { const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 }; const isLoading = monitorsWithChecksLoading; + // Bulk actions + const { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + } = useBulkMonitorActions(refetch, page); + // Check if any filters are active const hasActiveFilters = Boolean(selectedStatus || selectedState || search); @@ -159,6 +174,16 @@ const InfrastructureMonitors = () => { }} /> + + {!isLoading && ( + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const MonitorTable = ({ monitors, @@ -34,31 +53,24 @@ export const MonitorTable = ({ setPage, rowsPerPage, setRowsPerPage, -}: { - monitors: Monitor[]; - refetch: Function; - setSelectedMonitor: (monitor: Monitor | null) => void; - sortField: string; - setSortField: (field: string) => void; - sortOrder: "asc" | "desc"; - setSortOrder: (order: "asc" | "desc") => void; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { + selectedRows = [], + onSelectionChange, +}: MonitorTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram"); - const { - post, - // loading: isPosting, - // error: postError, - } = usePost(); + const { post } = usePost, Monitor>(); - const handleSort = (e: any, field: string) => { + const { + isAllSelected, + isSomeSelected, + handleSelectAll, + handleSelectRow, + isRowSelected, + } = useTableSelection(monitors, selectedRows, onSelectionChange); + + const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); e.stopPropagation(); if (sortField === field) { @@ -110,13 +122,6 @@ export const MonitorTable = ({ navigate(`/uptime/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -124,7 +129,7 @@ export const MonitorTable = ({ ? t("pages.common.monitors.actions.resume") : t("pages.common.monitors.actions.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {}); + await post(`/monitors/pause/${monitor.id}`, {} as Record); refetch(); }, closeMenu: true, @@ -160,8 +165,34 @@ export const MonitorTable = ({ ); const headers: Header[] = [ + { + id: "selection", + content: ( + handleSelectAll(e.target.checked)} + /> + ), + mobileLabel: null, + render: (row) => ( + { + handleSelectRow(row.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + ), + onClick: (e) => e?.stopPropagation(), + }, { id: "name", + mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} @@ -212,6 +243,7 @@ export const MonitorTable = ({ }, { id: "type", + mobileLabel: t("common.table.headers.type"), content: ( { navigate(`/uptime/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -37,6 +42,7 @@ const UptimeMonitorsPage = () => { const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); const debouncedSearch = useDebounce(search, 300); @@ -102,6 +108,15 @@ const UptimeMonitorsPage = () => { count, } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 }; + // Bulk actions + const { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + } = useBulkMonitorActions(refetch, page); + // Delete hook const { deleteFn, loading: isDeleting } = useDelete(); @@ -177,6 +192,16 @@ const UptimeMonitorsPage = () => { }} /> + + {!isLoading && ( + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> Date: Sat, 2 May 2026 14:28:12 +0530 Subject: [PATCH 04/42] refactor(ui): convert BulkActionsBar to modular bottom pill using children pattern Resolves maintainer feedback regarding UI/UX layout and prevents code collisions with other PRs by extracting action logic out of the bar component. Fixes #3602 --- .../Components/monitors/BulkActionsBar.tsx | 116 +++++++++++------- .../Pages/Infrastructure/Monitors/index.tsx | 22 +++- client/src/Pages/Uptime/Monitors/index.tsx | 22 +++- 3 files changed, 106 insertions(+), 54 deletions(-) diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx index aef5e4871..aa520745f 100644 --- a/client/src/Components/monitors/BulkActionsBar.tsx +++ b/client/src/Components/monitors/BulkActionsBar.tsx @@ -1,78 +1,102 @@ +import React from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import Collapse from "@mui/material/Collapse"; -import { Play, Pause } from "lucide-react"; +import Paper from "@mui/material/Paper"; +import Slide from "@mui/material/Slide"; +import IconButton from "@mui/material/IconButton"; +import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { useSidebar } from "@/Hooks/useSidebar"; interface BulkActionsBarProps { selectedCount: number; - onResume: () => void; - onPause: () => void; onCancel: () => void; + children?: React.ReactNode; } export const BulkActionsBar = ({ selectedCount, - onResume, - onPause, onCancel, + children, }: BulkActionsBarProps) => { const { t } = useTranslation(); const theme = useTheme(); + const isOpen = selectedCount > 0; + + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const { width, collapsedWidth, transition } = useSidebar(); return ( - 0}> - + - - - {t("pages.common.monitors.actions.bulkSelected", { - count: selectedCount, - })} - - - - + + - - - + + {children} + + + + ); }; diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 33e6d7f02..63031f41e 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -7,7 +7,8 @@ import { HeaderMonitorsSummary, BulkActionsBar, } from "@/Components/monitors"; -import { TextField, Dialog } from "@/Components/inputs"; +import { TextField, Dialog, Button } from "@/Components/inputs"; +import { Play, Pause } from "lucide-react"; import { useGet, useDelete } from "@/Hooks/UseApi"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; @@ -178,10 +179,23 @@ const InfrastructureMonitors = () => { {!isLoading && ( + > + + + )} { {!isLoading && ( + > + + + )} Date: Sat, 2 May 2026 14:28:13 +0530 Subject: [PATCH 05/42] fix(ui): correct table header alignment glitch caused by checkbox injection Adds explicit 'align' support to Table headers to override strict index-based alignment. Restores exact center-alignment spacer boxes to Uptime and Infrastructure tables to match develop branch styling. Fixes #3602 --- client/src/Components/design-elements/Table.tsx | 5 +++-- .../Monitors/Components/MonitorsTable.tsx | 13 ++++++++----- .../Monitors/Components/UptimeMonitorsTable.tsx | 2 ++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 121d890d1..c6628a611 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -36,6 +36,7 @@ export type Header = { id: number | string; content: React.ReactNode; mobileLabel?: React.ReactNode; + align?: "left" | "center" | "right" | "justify" | "inherit"; onClick?: (event: React.MouseEvent, row: T) => void; render: (row: T) => React.ReactNode; }; @@ -214,7 +215,7 @@ export function DataTable< {headers.map((header, idx) => { return ( {header.content} @@ -243,7 +244,7 @@ export function DataTable< {headers.map((header, index) => { return ( header.onClick!(e, row) : undefined diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index 1cf5c6ec6..dbae1b1e9 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -1,4 +1,5 @@ import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements"; import type { Header } from "@/Components/design-elements/Table"; @@ -184,6 +185,7 @@ export const InfraMonitorsTable = ({ }, { id: "name", + align: "left", mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} > + {t("common.table.headers.status")} {renderSortIcon(sortField === "status")} - + ), render: (row) => { return ; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 4aee90fa5..e4d870cc8 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -192,6 +192,7 @@ export const MonitorTable = ({ }, { id: "name", + align: "left", mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} From 657a35e948aafae880dfbfb9e0ca66e0be6afcfe Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Sat, 2 May 2026 14:28:15 +0530 Subject: [PATCH 06/42] feat(i18n): implement strict pluralization for bulk action notifications Removes generic '(s)' syntax from backend responses. Adds proper _one and _other pluralization keys to all 16 locales with native grammar translations, resolving 0-count edge cases. Fixes #3602 --- client/src/Hooks/useBulkMonitorActions.ts | 60 ++++++++++++++------- client/src/locales/ar.json | 17 ++++-- client/src/locales/cs.json | 15 +++++- client/src/locales/de.json | 15 +++++- client/src/locales/en.json | 13 ++++- client/src/locales/es.json | 15 +++++- client/src/locales/fi.json | 17 ++++-- client/src/locales/fr.json | 15 +++++- client/src/locales/ja.json | 15 +++++- client/src/locales/pt-BR.json | 15 +++++- client/src/locales/ru.json | 15 +++++- client/src/locales/th.json | 17 ++++-- client/src/locales/tr.json | 15 +++++- client/src/locales/uk.json | 15 +++++- client/src/locales/vi.json | 17 ++++-- client/src/locales/zh-CN.json | 17 ++++-- client/src/locales/zh-TW.json | 17 ++++-- server/src/controllers/monitorController.ts | 3 +- 18 files changed, 256 insertions(+), 57 deletions(-) diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts index 3bb520aa1..ddf96fedb 100644 --- a/client/src/Hooks/useBulkMonitorActions.ts +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -1,9 +1,14 @@ import { useState, useEffect } from "react"; -import { usePost } from "@/Hooks/UseApi"; +import { post } from "@/Utils/ApiClient"; +import { useToast } from "@/Hooks/UseToast"; +import { useTranslation } from "react-i18next"; +import { logger } from "@/Utils/logger"; +import type { Monitor } from "@/Types/Monitor"; -interface BulkPauseBody { - monitorIds: string[]; - pause: boolean; +interface ApiResponse { + success: boolean; + msg: string; + data: Monitor[]; } interface UseBulkMonitorActionsReturn { @@ -19,33 +24,50 @@ export const useBulkMonitorActions = ( page?: number ): UseBulkMonitorActionsReturn => { const [selectedRows, setSelectedRows] = useState([]); - const { post: bulkPost } = usePost(); + const { toastSuccess, toastError, toastInfo } = useToast(); + const { t } = useTranslation(); // Clear selection when page changes useEffect(() => { setSelectedRows([]); }, [page]); - const handleBulkPause = async () => { - const result = await bulkPost("/monitors/bulk/pause", { - monitorIds: selectedRows, - pause: true, - }); - if (result) { + const executeBulkAction = async (pause: boolean) => { + try { + const res = await post("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause, + }); + + const affectedCount = res.data?.data?.length ?? 0; + + if (affectedCount === 0) { + const key = pause + ? "pages.common.monitors.bulkPause.alreadyPaused" + : "pages.common.monitors.bulkPause.alreadyRunning"; + toastInfo(t(key, { count: selectedRows.length })); + } else { + const key = pause + ? "pages.common.monitors.bulkPause.paused" + : "pages.common.monitors.bulkPause.resumed"; + toastSuccess(t(key, { count: affectedCount })); + } + setSelectedRows([]); refetch(); + } catch (err: any) { + const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; + logger.error("Bulk pause/resume failed", err, { pause }); + toastError(errMsg); } }; + const handleBulkPause = async () => { + await executeBulkAction(true); + }; + const handleBulkResume = async () => { - const result = await bulkPost("/monitors/bulk/pause", { - monitorIds: selectedRows, - pause: false, - }); - if (result) { - setSelectedRows([]); - refetch(); - } + await executeBulkAction(false); }; const handleCancelSelection = () => { diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 70509feed..5b21acd8e 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -422,9 +422,10 @@ "openSite": "فتح الموقع", "pause": "إيقاف مؤقت", "resume": "استئناف", - "bulkSelected": "تم تحديد {{count}} جهاز(أجهزة) مراقبة", - "selectAll": "تحديد جميع أجهزة المراقبة", - "selectMonitor": "تحديد {{name}}" + "selectAll": "تحديد جميع الشاشات", + "selectMonitor": "تحديد {{name}}", + "bulkSelected_one": "{{count}} شاشة محددة", + "bulkSelected_other": "{{count}} شاشات محددة" }, "statBoxes": { "activeFor": "نشط منذ", @@ -452,6 +453,16 @@ "optionHardware": "البنية التحتية", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "تم إيقاف {{count}} شاشة مؤقتاً", + "paused_other": "تم إيقاف {{count}} شاشات مؤقتاً", + "resumed_one": "تم استئناف {{count}} شاشة", + "resumed_other": "تم استئناف {{count}} شاشات", + "alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل", + "alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل", + "alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل", + "alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل" } } }, diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index e86677c29..e440b3b12 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -422,9 +422,10 @@ "openSite": "Otevřít stránku", "pause": "Pozastavit", "resume": "Obnovit", - "bulkSelected": "{{count}} monitor(ů) vybráno", "selectAll": "Vybrat všechny monitory", - "selectMonitor": "Vybrat {{name}}" + "selectMonitor": "Vybrat {{name}}", + "bulkSelected_one": "Vybrán {{count}} monitor", + "bulkSelected_other": "Vybráno {{count}} monitorů" }, "statBoxes": { "activeFor": "Aktivní po dobu", @@ -452,6 +453,16 @@ "optionHardware": "Infrastruktura", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pozastaven", + "paused_other": "{{count}} monitorů pozastaveno", + "resumed_one": "{{count}} monitor obnoven", + "resumed_other": "{{count}} monitorů obnoveno", + "alreadyPaused_one": "Vybraný monitor je již pozastaven", + "alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny", + "alreadyRunning_one": "Vybraný monitor již běží", + "alreadyRunning_other": "Všechny vybrané monitory již běží" } } }, diff --git a/client/src/locales/de.json b/client/src/locales/de.json index cd93c4810..c3bc35011 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -422,9 +422,10 @@ "openSite": "Seite öffnen", "pause": "Pausieren", "resume": "Fortsetzen", - "bulkSelected": "{{count}} Monitor(e) ausgewählt", "selectAll": "Alle Monitore auswählen", - "selectMonitor": "{{name}} auswählen" + "selectMonitor": "{{name}} auswählen", + "bulkSelected_one": "{{count}} Monitor ausgewählt", + "bulkSelected_other": "{{count}} Monitore ausgewählt" }, "statBoxes": { "activeFor": "Aktiv seit", @@ -452,6 +453,16 @@ "optionHardware": "Infrastruktur", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} Monitor pausiert", + "paused_other": "{{count}} Monitore pausiert", + "resumed_one": "{{count}} Monitor fortgesetzt", + "resumed_other": "{{count}} Monitore fortgesetzt", + "alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert", + "alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert", + "alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt", + "alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt" } } }, diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2e22baa07..c505d0213 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -422,10 +422,21 @@ "openSite": "Open site", "pause": "Pause", "resume": "Resume", - "bulkSelected": "{{count}} Monitor(s) selected", + "bulkSelected_one": "{{count}} monitor selected", + "bulkSelected_other": "{{count}} monitors selected", "selectAll": "Select all monitors", "selectMonitor": "Select {{name}}" }, + "bulkPause": { + "alreadyPaused_one": "The selected monitor is already paused", + "alreadyPaused_other": "All selected monitors are already paused", + "alreadyRunning_one": "The selected monitor is already running", + "alreadyRunning_other": "All selected monitors are already running", + "paused_one": "{{count}} monitor paused", + "paused_other": "{{count}} monitors paused", + "resumed_one": "{{count}} monitor resumed", + "resumed_other": "{{count}} monitors resumed" + }, "statBoxes": { "activeFor": "Active for", "certificateExpiry": "Certificate expiry", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 04f447e40..f7c719f53 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -429,9 +429,10 @@ "openSite": "Abrir sitio", "pause": "Pausar", "resume": "Reanudar", - "bulkSelected": "{{count}} monitor(es) seleccionado(s)", "selectAll": "Seleccionar todos los monitores", - "selectMonitor": "Seleccionar {{name}}" + "selectMonitor": "Seleccionar {{name}}", + "bulkSelected_one": "{{count}} monitor seleccionado", + "bulkSelected_other": "{{count}} monitores seleccionados" }, "statBoxes": { "activeFor": "Activo desde hace", @@ -459,6 +460,16 @@ "optionHardware": "Infraestructura", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor reanudado", + "resumed_other": "{{count}} monitores reanudados", + "alreadyPaused_one": "El monitor seleccionado ya está pausado", + "alreadyPaused_other": "Todos los monitores seleccionados ya están pausados", + "alreadyRunning_one": "El monitor seleccionado ya se está ejecutando", + "alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando" } } }, diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 74afedd84..f74e9f58f 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -422,9 +422,10 @@ "openSite": "Avaa sivusto", "pause": "Keskeytä", "resume": "Jatka", - "bulkSelected": "{{count}} monitori(a) valittu", - "selectAll": "Valitse kaikki monitorit", - "selectMonitor": "Valitse {{name}}" + "selectAll": "Valitse kaikki näytöt", + "selectMonitor": "Valitse {{name}}", + "bulkSelected_one": "{{count}} näyttö valittu", + "bulkSelected_other": "{{count}} näyttöä valittu" }, "statBoxes": { "activeFor": "Aktiivinen", @@ -452,6 +453,16 @@ "optionHardware": "Infrastruktuuri", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} näyttö keskeytetty", + "paused_other": "{{count}} näyttöä keskeytetty", + "resumed_one": "{{count}} näyttö jatkettu", + "resumed_other": "{{count}} näyttöä jatkettu", + "alreadyPaused_one": "Valittu näyttö on jo keskeytetty", + "alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty", + "alreadyRunning_one": "Valittu näyttö on jo käynnissä", + "alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä" } } }, diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 8e3c78b85..003f64118 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -422,9 +422,10 @@ "openSite": "Ouvrir le site", "pause": "Pause", "resume": "Reprendre", - "bulkSelected": "{{count}} moniteur(s) sélectionné(s)", "selectAll": "Sélectionner tous les moniteurs", - "selectMonitor": "Sélectionner {{name}}" + "selectMonitor": "Sélectionner {{name}}", + "bulkSelected_one": "{{count}} moniteur sélectionné", + "bulkSelected_other": "{{count}} moniteurs sélectionnés" }, "statBoxes": { "activeFor": "Actif depuis", @@ -452,6 +453,16 @@ "optionHardware": "Infrastructure", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} moniteur en pause", + "paused_other": "{{count}} moniteurs en pause", + "resumed_one": "{{count}} moniteur repris", + "resumed_other": "{{count}} moniteurs repris", + "alreadyPaused_one": "Le moniteur sélectionné est déjà en pause", + "alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause", + "alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution", + "alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution" } } }, diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 09db6971f..1cf614379 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -422,9 +422,10 @@ "openSite": "サイトを開く", "pause": "一時停止", "resume": "再開", - "bulkSelected": "{{count}} 件のモニターを選択中", "selectAll": "すべてのモニターを選択", - "selectMonitor": "{{name}} を選択" + "selectMonitor": "{{name}} を選択", + "bulkSelected_one": "{{count}} 個のモニターが選択されました", + "bulkSelected_other": "{{count}} 個のモニターが選択されました" }, "statBoxes": { "activeFor": "稼働期間", @@ -452,6 +453,16 @@ "optionHardware": "インフラストラクチャ", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} 個のモニターを一時停止しました", + "paused_other": "{{count}} 個のモニターを一時停止しました", + "resumed_one": "{{count}} 個のモニターを再開しました", + "resumed_other": "{{count}} 個のモニターを再開しました", + "alreadyPaused_one": "選択したモニターはすでに一時停止されています", + "alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています", + "alreadyRunning_one": "選択したモニターはすでに実行中です", + "alreadyRunning_other": "選択したすべてのモニターはすでに実行中です" } } }, diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 6c97a9869..6cc241777 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -422,9 +422,10 @@ "openSite": "Abrir site", "pause": "Pausar", "resume": "Retomar", - "bulkSelected": "{{count}} monitor(es) selecionado(s)", "selectAll": "Selecionar todos os monitores", - "selectMonitor": "Selecionar {{name}}" + "selectMonitor": "Selecionar {{name}}", + "bulkSelected_one": "{{count}} monitor selecionado", + "bulkSelected_other": "{{count}} monitores selecionados" }, "statBoxes": { "activeFor": "Ativo há", @@ -452,6 +453,16 @@ "optionHardware": "Infraestrutura", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor retomado", + "resumed_other": "{{count}} monitores retomados", + "alreadyPaused_one": "O monitor selecionado já está pausado", + "alreadyPaused_other": "Todos os monitores selecionados já estão pausados", + "alreadyRunning_one": "O monitor selecionado já está em execução", + "alreadyRunning_other": "Todos os monitores selecionados já estão em execução" } } }, diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 778d481f9..171a596dd 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -422,9 +422,10 @@ "openSite": "Открыть сайт", "pause": "Пауза", "resume": "Продолжить", - "bulkSelected": "Выбрано мониторов: {{count}}", "selectAll": "Выбрать все мониторы", - "selectMonitor": "Выбрать {{name}}" + "selectMonitor": "Выбрать {{name}}", + "bulkSelected_one": "Выбран {{count}} монитор", + "bulkSelected_other": "Выбрано {{count}} мониторов" }, "statBoxes": { "activeFor": "Активен", @@ -452,6 +453,16 @@ "optionHardware": "Инфраструктура", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} монитор приостановлен", + "paused_other": "{{count}} мониторов приостановлено", + "resumed_one": "{{count}} монитор возобновлен", + "resumed_other": "{{count}} мониторов возобновлено", + "alreadyPaused_one": "Выбранный монитор уже приостановлен", + "alreadyPaused_other": "Все выбранные мониторы уже приостановлены", + "alreadyRunning_one": "Выбранный монитор уже запущен", + "alreadyRunning_other": "Все выбранные мониторы уже запущены" } } }, diff --git a/client/src/locales/th.json b/client/src/locales/th.json index aab97dc93..d39a20a6f 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -422,9 +422,10 @@ "openSite": "เปิดเว็บไซต์", "pause": "หยุดชั่วคราว", "resume": "ดำเนินการต่อ", - "bulkSelected": "เลือก {{count}} มอนิเตอร์แล้ว", - "selectAll": "เลือกมอนิเตอร์ทั้งหมด", - "selectMonitor": "เลือก {{name}}" + "selectAll": "เลือกจอภาพทั้งหมด", + "selectMonitor": "เลือก {{name}}", + "bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ", + "bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ" }, "statBoxes": { "activeFor": "ทำงานมาแล้ว", @@ -452,6 +453,16 @@ "optionHardware": "โครงสร้างพื้นฐาน", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ", + "paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ", + "resumed_one": "กลับมาทำงานต่อ {{count}} จอ", + "resumed_other": "กลับมาทำงานต่อ {{count}} จอ", + "alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว", + "alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว", + "alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว", + "alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว" } } }, diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 6e6aaf365..dd5fcfa80 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -422,9 +422,10 @@ "openSite": "Siteyi aç", "pause": "Duraklat", "resume": "Devam et", - "bulkSelected": "{{count}} monitör seçildi", "selectAll": "Tüm monitörleri seç", - "selectMonitor": "{{name}} seç" + "selectMonitor": "{{name}}'i seç", + "bulkSelected_one": "{{count}} monitör seçildi", + "bulkSelected_other": "{{count}} monitör seçildi" }, "statBoxes": { "activeFor": "Aktif süresi", @@ -452,6 +453,16 @@ "optionHardware": "Altyapı", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitör duraklatıldı", + "paused_other": "{{count}} monitör duraklatıldı", + "resumed_one": "{{count}} monitör sürdürüldü", + "resumed_other": "{{count}} monitör sürdürüldü", + "alreadyPaused_one": "Seçili monitör zaten duraklatıldı", + "alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı", + "alreadyRunning_one": "Seçili monitör zaten çalışıyor", + "alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor" } } }, diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index f7ff24647..9b893d56a 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -422,9 +422,10 @@ "openSite": "Відкрити сайт", "pause": "Призупинити", "resume": "Відновити", - "bulkSelected": "Вибрано моніторів: {{count}}", "selectAll": "Вибрати всі монітори", - "selectMonitor": "Вибрати {{name}}" + "selectMonitor": "Вибрати {{name}}", + "bulkSelected_one": "Вибрано {{count}} монітор", + "bulkSelected_other": "Вибрано {{count}} моніторів" }, "statBoxes": { "activeFor": "Активний протягом", @@ -452,6 +453,16 @@ "optionHardware": "Інфраструктура", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} монітор призупинено", + "paused_other": "{{count}} моніторів призупинено", + "resumed_one": "{{count}} монітор відновлено", + "resumed_other": "{{count}} моніторів відновлено", + "alreadyPaused_one": "Вибраний монітор уже призупинено", + "alreadyPaused_other": "Всі вибрані монітори уже призупинено", + "alreadyRunning_one": "Вибраний монітор уже запущено", + "alreadyRunning_other": "Всі вибрані монітори уже запущено" } } }, diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 90d8afd04..a7363cea5 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -422,9 +422,10 @@ "openSite": "Mở trang web", "pause": "Tạm dừng", "resume": "Tiếp tục", - "bulkSelected": "Đã chọn {{count}} monitor", - "selectAll": "Chọn tất cả monitor", - "selectMonitor": "Chọn {{name}}" + "selectAll": "Chọn tất cả màn hình", + "selectMonitor": "Chọn {{name}}", + "bulkSelected_one": "Đã chọn {{count}} màn hình", + "bulkSelected_other": "Đã chọn {{count}} màn hình" }, "statBoxes": { "activeFor": "Hoạt động từ", @@ -452,6 +453,16 @@ "optionHardware": "Hạ tầng", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "Đã tạm dừng {{count}} màn hình", + "paused_other": "Đã tạm dừng {{count}} màn hình", + "resumed_one": "Đã tiếp tục {{count}} màn hình", + "resumed_other": "Đã tiếp tục {{count}} màn hình", + "alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng", + "alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng", + "alreadyRunning_one": "Màn hình đã chọn đã đang chạy", + "alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy" } } }, diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 73a4ce166..861f0dd8b 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -422,9 +422,10 @@ "openSite": "打开站点", "pause": "暂停", "resume": "恢复", - "bulkSelected": "已选择 {{count}} 个监控", - "selectAll": "选择所有监控", - "selectMonitor": "选择 {{name}}" + "selectAll": "选择所有监视器", + "selectMonitor": "选择 {{name}}", + "bulkSelected_one": "已选择 {{count}} 个监视器", + "bulkSelected_other": "已选择 {{count}} 个监视器" }, "statBoxes": { "activeFor": "已活跃", @@ -452,6 +453,16 @@ "optionHardware": "基础设施", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "已暂停 {{count}} 个监视器", + "paused_other": "已暂停 {{count}} 个监视器", + "resumed_one": "已恢复 {{count}} 个监视器", + "resumed_other": "已恢复 {{count}} 个监视器", + "alreadyPaused_one": "所选监视器已暂停", + "alreadyPaused_other": "所有选定的监视器均已暂停", + "alreadyRunning_one": "所选监视器已经在运行", + "alreadyRunning_other": "所有选定的监视器均已在运行" } } }, diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 91fc7766e..c5992e2b4 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -422,9 +422,10 @@ "openSite": "開啟網站", "pause": "暫停", "resume": "恢復", - "bulkSelected": "已選擇 {{count}} 個監控", - "selectAll": "選擇所有監控", - "selectMonitor": "選擇 {{name}}" + "selectAll": "選擇所有監視器", + "selectMonitor": "選擇 {{name}}", + "bulkSelected_one": "已選擇 {{count}} 個監視器", + "bulkSelected_other": "已選擇 {{count}} 個監視器" }, "statBoxes": { "activeFor": "已啟用", @@ -452,6 +453,16 @@ "optionHardware": "基礎架構", "optionGrpc": "gRPC", "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "已暫停 {{count}} 個監視器", + "paused_other": "已暫停 {{count}} 個監視器", + "resumed_one": "已恢復 {{count}} 個監視器", + "resumed_other": "已恢復 {{count}} 個監視器", + "alreadyPaused_one": "所選監視器已暫停", + "alreadyPaused_other": "所有選定的監視器均已暫停", + "alreadyRunning_one": "所選監視器已經在運行", + "alreadyRunning_other": "所有選定的監視器均已在運行" } } }, diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 4529f972d..7422d5abb 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -318,10 +318,11 @@ class MonitorController implements IMonitorController { const monitors = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); const action = pause ? "paused" : "resumed"; + const monitorStr = monitors.length === 1 ? "monitor" : "monitors"; return res.status(200).json({ success: true, - msg: `${monitors.length} monitor(s) ${action} successfully`, + msg: `${monitors.length} ${monitorStr} ${action} successfully`, data: monitors, }); } catch (error) { From cb218a46a9b21e6382a92c39d2fe2871737d3c51 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 10:21:28 -0700 Subject: [PATCH 07/42] fix pause/resume logic --- .../infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 7260c82c7..d64a8932b 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -224,7 +224,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { throw new Error("Failed to pause monitor"); } const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); + if (geoJob) await this.scheduler.pauseJob(`${monitor.id}-geo`); this.logger.debug({ message: `Paused monitor ${monitor.id}`, @@ -239,8 +239,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { throw new Error("Failed to resume monitor"); } - await this.scheduler.resumeJob(`${monitor.id}-geo`); - + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + await this.scheduler.resumeJob(`${monitor.id}-geo`); + } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, service: SERVICE_NAME, From 6f0cc307dc85e7ce5250a2001e7fc56c9d2bb3cf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 10:51:53 -0700 Subject: [PATCH 08/42] log error on geo check pause/resume fail, update tests --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 20 ++++++- .../unit/services/superSimpleQueue.test.ts | 54 ++++++++++++++++--- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index d64a8932b..1d50d720c 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -224,7 +224,16 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { throw new Error("Failed to pause monitor"); } const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to pause geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + } + } this.logger.debug({ message: `Paused monitor ${monitor.id}`, @@ -241,7 +250,14 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); if (geoJob) { - await this.scheduler.resumeJob(`${monitor.id}-geo`); + const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to resume geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + } } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 93563dd00..95367cb5a 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -291,34 +291,46 @@ describe("SuperSimpleQueue", () => { // ── pauseJob ───────────────────────────────────────────────────────────── describe("pauseJob", () => { - it("pauses monitor and removes geo job if it exists", async () => { + it("pauses monitor and pauses geo job when geo exists", async () => { const { queue, scheduler, logger } = createQueue(); (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); await queue.pauseJob(makeMonitor()); expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); }); - it("does not remove geo job if it does not exist", async () => { + it("does not pause geo job when it does not exist", async () => { const { queue, scheduler } = createQueue(); await queue.pauseJob(makeMonitor()); - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); }); - it("throws when scheduler.pauseJob returns false", async () => { + it("throws when scheduler.pauseJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); }); + + it("logs error but does not throw when geo pauseJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" })); + }); }); // ── resumeJob ──────────────────────────────────────────────────────────── describe("resumeJob", () => { - it("resumes monitor and geo job", async () => { + it("resumes monitor and geo job when geo exists", async () => { const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); await queue.resumeJob(makeMonitor()); expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); @@ -326,11 +338,39 @@ describe("SuperSimpleQueue", () => { expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); }); - it("throws when scheduler.resumeJob returns false", async () => { + it("does not resume geo job when it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.resumeJob(makeMonitor()); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("throws when scheduler.resumeJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); }); + + it("logs error but does not throw when geo resumeJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" })); + }); + + it("preserves geo job through a pause/resume cycle", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + + await queue.pauseJob(makeMonitor()); + await queue.resumeJob(makeMonitor()); + + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); + }); }); // ── updateJob ──────────────────────────────────────────────────────────── From f648ab1b243a11e2bff9e9276c5249c088f75e40 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:52:45 -0700 Subject: [PATCH 09/42] add geoInterval, monitorActive, and repeat to jobs data --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 1d50d720c..58e7a9585 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -28,6 +28,8 @@ type QueueJobSummary = { monitorUrl: string | null; monitorType: string | null; monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; active: boolean; lockedAt: number | null; runCount: number; @@ -37,6 +39,7 @@ type QueueJobSummary = { lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; + repeat: number | null; }; export interface ISuperSimpleQueue { @@ -223,6 +226,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to pause monitor"); } + this.scheduler.updateJob(monitor.id, { data: monitor }); + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); if (geoJob) { const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); @@ -233,6 +238,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { method: "pauseJob", }); } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); } this.logger.debug({ @@ -247,6 +253,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to resume monitor"); } + this.scheduler.updateJob(monitor.id, { data: monitor }); const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); if (geoJob) { @@ -258,6 +265,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { method: "resumeJob", }); } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, @@ -359,7 +367,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { monitorUrl: job.data?.url || null, monitorType: job.data?.type || null, monitorInterval: job.data?.interval || null, + monitorGeoInterval: job.data?.geoCheckInterval || null, + monitorActive: job.data?.isActive ?? null, active: job.active, + repeat: job.repeat ?? null, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, From 22230b8a08f4882eb91b88cc2fb3734b54b47df4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:53:12 -0700 Subject: [PATCH 10/42] UI fixes for job queue table --- client/src/Pages/Logs/TabQueue.tsx | 2 +- .../src/Pages/Logs/components/TableJobs.tsx | 47 ++++++++++++++++--- client/src/Types/Queue.ts | 3 ++ client/src/locales/en.json | 5 +- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 84573e8f0..51c9693c0 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -19,7 +19,7 @@ export const TabQueue = () => { isLoading, // error, refetch, - } = useGet("/queue/all-metrics", {}, { refreshInterval: 5000 }); + } = useGet("/queue/all-metrics", {}, { refreshInterval: 1000 }); const { post, loading: isFlushing } = usePost(); diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx index f690bd417..1f1da1003 100644 --- a/client/src/Pages/Logs/components/TableJobs.tsx +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -30,6 +30,22 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { }; const headers: Header[] = [ + { + id: "state", + content: t("pages.logs.table.headers.state"), + render: (row) => { + if (row.lockedAt) { + return t("common.labels.running"); + } + if (row.monitorActive === true) { + return t("common.labels.active"); + } + if (row.monitorActive === false) { + return t("common.labels.paused"); + } + return t("common.labels.idle"); + }, + }, { id: "id", content: t("common.table.headers.monitorId"), @@ -47,7 +63,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { id: "url", content: t("common.table.headers.url"), render: (row) => ( - + {row.monitorUrl} ), @@ -60,9 +79,15 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { { id: "interval", content: t("common.table.headers.interval"), - render: (row) => ( - {prettyMilliseconds(row.monitorInterval ?? 0)} - ), + render: (row) => { + let interval; + if (row.monitorId.toString().includes("geo")) { + interval = row.monitorGeoInterval ?? 0; + } else { + interval = row.repeat ?? 0; + } + return {prettyMilliseconds(interval)}; + }, }, { id: "lastRun", @@ -70,7 +95,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { render: (row) => { const v = formatTimestamp(row.lastRunAt) ?? "-"; return ( - + {v} ); @@ -90,7 +118,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { render: (row) => { const v = formatTimestamp(row.lockedAt) ?? "-"; return ( - + {v} ); @@ -100,6 +131,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { const isDark = theme.palette.mode === "dark"; const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2"; + const pausedBg = isDark ? "rgba(255, 165, 0, 0.18)" : "#FFF4E5"; return ( { ...(row.lockedAt && { "& td": { backgroundColor: runningBg }, }), + ...(row.monitorActive === false && { + "& td": { backgroundColor: pausedBg }, + }), })} /> ); diff --git a/client/src/Types/Queue.ts b/client/src/Types/Queue.ts index 77a770aeb..bbb3ca9a4 100644 --- a/client/src/Types/Queue.ts +++ b/client/src/Types/Queue.ts @@ -23,6 +23,8 @@ export interface QueueJobSummary { monitorUrl: string | null; monitorType: MonitorType | null; monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; active: boolean; lockedAt: number | null; runCount: number; @@ -32,6 +34,7 @@ export interface QueueJobSummary { lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; + repeat: number | null; } export interface QueueData { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b5fe0c1cb..a08cdfe8a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -70,7 +70,9 @@ }, "labels": { "active": "Active", - "paused": "paused", + "running": "Running", + "paused": "Paused", + "idle": "Idle", "na": "N/A", "resolved": "Resolved", "responseTime": "Response time" @@ -887,6 +889,7 @@ }, "table": { "headers": { + "state": "State", "timestamp": "Timestamp", "level": "Level", "service": "Service", From 1fd56610eaca3e1b55f95e4f013a93551de56b5c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:55:39 -0700 Subject: [PATCH 11/42] update tests --- .../unit/services/superSimpleQueue.test.ts | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 95367cb5a..685dd71c7 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -302,6 +302,17 @@ describe("SuperSimpleQueue", () => { expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); }); + it("refreshes scheduler data with current monitor on pause", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ isActive: false, status: "paused" }); + + await queue.pauseJob(monitor); + + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); + }); + it("does not pause geo job when it does not exist", async () => { const { queue, scheduler } = createQueue(); await queue.pauseJob(makeMonitor()); @@ -309,12 +320,26 @@ describe("SuperSimpleQueue", () => { expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); }); + it("does not refresh geo data when geo job does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.pauseJob(makeMonitor()); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); + expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); + }); + it("throws when scheduler.pauseJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); }); + it("does not refresh data when main pauseJob throws", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); + await expect(queue.pauseJob(makeMonitor())).rejects.toThrow(); + expect(scheduler.updateJob).not.toHaveBeenCalled(); + }); + it("logs error but does not throw when geo pauseJob returns false", async () => { const { queue, scheduler, logger } = createQueue(); (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); @@ -338,6 +363,17 @@ describe("SuperSimpleQueue", () => { expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); }); + it("refreshes scheduler data with current monitor on resume", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ isActive: true, status: "initializing" }); + + await queue.resumeJob(monitor); + + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); + }); + it("does not resume geo job when it does not exist", async () => { const { queue, scheduler } = createQueue(); await queue.resumeJob(makeMonitor()); @@ -345,12 +381,26 @@ describe("SuperSimpleQueue", () => { expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); }); + it("does not refresh geo data when geo job does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.resumeJob(makeMonitor()); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); + expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); + }); + it("throws when scheduler.resumeJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); }); + it("does not refresh data when main resumeJob throws", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); + await expect(queue.resumeJob(makeMonitor())).rejects.toThrow(); + expect(scheduler.updateJob).not.toHaveBeenCalled(); + }); + it("logs error but does not throw when geo resumeJob returns false", async () => { const { queue, scheduler, logger } = createQueue(); (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); @@ -507,6 +557,7 @@ describe("SuperSimpleQueue", () => { { id: "m1", active: true, + repeat: 60000, lockedAt: null, runCount: 5, failCount: 1, @@ -514,7 +565,7 @@ describe("SuperSimpleQueue", () => { lastRunAt: 1000, lastFinishedAt: 1100, lastFailedAt: 900, - data: { url: "http://a.com", type: "http", interval: 60000 }, + data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true }, }, ]); const jobs = await queue.getJobs(); @@ -525,7 +576,10 @@ describe("SuperSimpleQueue", () => { monitorUrl: "http://a.com", monitorType: "http", monitorInterval: 60000, + monitorGeoInterval: 300000, + monitorActive: true, active: true, + repeat: 60000, lockedAt: null, runCount: 5, failCount: 1, @@ -553,6 +607,30 @@ describe("SuperSimpleQueue", () => { expect(jobs[0].monitorUrl).toBeNull(); expect(jobs[0].monitorType).toBeNull(); expect(jobs[0].monitorInterval).toBeNull(); + expect(jobs[0].monitorGeoInterval).toBeNull(); + expect(jobs[0].monitorActive).toBeNull(); + expect(jobs[0].repeat).toBeNull(); + }); + + it("exposes monitor.isActive as monitorActive in the summary", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, data: { url: "a", type: "http", interval: 60000, isActive: true } }, + { id: "m2", active: false, data: { url: "b", type: "http", interval: 60000, isActive: false } }, + ]); + const jobs = await queue.getJobs(); + expect(jobs[0].monitorActive).toBe(true); + expect(jobs[1].monitorActive).toBe(false); + }); + + it("exposes geo check interval and repeat", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, repeat: 120000, data: { url: "a", type: "http", interval: 60000, geoCheckInterval: 600000 } }, + ]); + const jobs = await queue.getJobs(); + expect(jobs[0].repeat).toBe(120000); + expect(jobs[0].monitorGeoInterval).toBe(600000); }); }); From 2e9b87ba32ee75406f43ea7195a5a8ff58e2d9ca Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 11:59:02 -0700 Subject: [PATCH 12/42] normalize tabqueue --- client/src/Pages/Logs/TabQueue.tsx | 38 +++++++++++------------------ client/src/Utils/Theme/constants.ts | 1 + 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 51c9693c0..87d658635 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -10,6 +10,7 @@ import type { QueueData } from "@/Types/Queue"; import { Metrics } from "@/Pages/Logs/components/Metrics"; import { Button } from "@/Components/inputs"; import { EmptyState } from "@/Components/design-elements"; +import { SPACING } from "@/Utils/Theme/constants"; export const TabQueue = () => { const theme = useTheme(); @@ -49,32 +50,26 @@ export const TabQueue = () => { {t("pages.logs.jobQueue")} - + ), }} @@ -87,16 +82,11 @@ export const TabQueue = () => { {t("pages.logs.failedJobs")} - + {t("pages.logs.failedJobsExplainer")} diff --git a/client/src/Utils/Theme/constants.ts b/client/src/Utils/Theme/constants.ts index d718d61dc..518657183 100644 --- a/client/src/Utils/Theme/constants.ts +++ b/client/src/Utils/Theme/constants.ts @@ -1,4 +1,5 @@ export const SPACING = { + XXS: 0.25, XS: 0.5, SM: 1, MD: 1.5, From 3365a8f7da3f0d540da1ea521706f94a10360720 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 4 May 2026 12:11:57 -0700 Subject: [PATCH 13/42] extract colors to theme --- client/src/Pages/Logs/TabQueue.tsx | 6 +----- client/src/Pages/Logs/components/TableJobs.tsx | 8 ++------ client/src/Utils/Theme/Palette.ts | 13 +++++++++++++ client/src/Utils/Theme/Theme.ts | 2 ++ 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 87d658635..a39250be0 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -61,11 +61,7 @@ export const TabQueue = () => { highlight: ( { }, ]; - const isDark = theme.palette.mode === "dark"; - const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2"; - const pausedBg = isDark ? "rgba(255, 165, 0, 0.18)" : "#FFF4E5"; - return (
({ ...(row.lockedAt && { - "& td": { backgroundColor: runningBg }, + "& td": { backgroundColor: theme.palette.rowStatus.running }, }), ...(row.monitorActive === false && { - "& td": { backgroundColor: pausedBg }, + "& td": { backgroundColor: theme.palette.rowStatus.paused }, }), })} /> diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 92c08cd2f..55dc1e26e 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -1,3 +1,5 @@ +import { alpha } from "@mui/material/styles"; + const typographyBase = 13; export const typographyLevels = { @@ -17,6 +19,9 @@ export const colors = { gray850: "#1c1c21", brandGreen: "#13715B", brandGreenLight: "#4DAF94", + green200: "#ECF7F2", + yellow200: "#FFF4E5", + orange500: "#FFA500", }; export const lightPalette = { @@ -29,6 +34,10 @@ export const lightPalette = { sidebar: { accent: colors.brandGreen, }, + rowStatus: { + running: colors.green200, + paused: colors.yellow200, + }, }; export const darkPalette = { @@ -45,4 +54,8 @@ export const darkPalette = { sidebar: { accent: colors.brandGreenLight, }, + rowStatus: { + running: alpha(colors.brandGreen, 0.18), + paused: alpha(colors.orange500, 0.18), + }, }; diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index 7c712d66d..04f4c94eb 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -15,9 +15,11 @@ declare module "@mui/material/styles" { } interface Palette { sidebar: { accent: string }; + rowStatus: { running: string; paused: string }; } interface PaletteOptions { sidebar?: { accent: string }; + rowStatus?: { running: string; paused: string }; } } From 387cb35494406414979db3ce29d984fa2df03d61 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Tue, 5 May 2026 17:06:48 +0530 Subject: [PATCH 14/42] refactor(ui): simplify table selection and clarify mobile label rendering Addresses PR feedback: - Removes over-abstracted useTableSelection hook, inlines selection logic - Replaces triple-condition with explicit hideMobileLabel bool flag - Removes unsafe Record casting Fixes #3602 --- .../src/Components/design-elements/Table.tsx | 7 +- client/src/Hooks/useTableSelection.ts | 74 ------------------- .../Monitors/Components/MonitorsTable.tsx | 29 +++++--- .../Components/UptimeMonitorsTable.tsx | 29 +++++--- 4 files changed, 41 insertions(+), 98 deletions(-) delete mode 100644 client/src/Hooks/useTableSelection.ts diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index c6628a611..a02f9be8f 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -36,6 +36,7 @@ export type Header = { id: number | string; content: React.ReactNode; mobileLabel?: React.ReactNode; + hideMobileLabel?: boolean; align?: "left" | "center" | "right" | "justify" | "inherit"; onClick?: (event: React.MouseEvent, row: T) => void; render: (row: T) => React.ReactNode; @@ -111,7 +112,7 @@ export function DataTable< key={key} > {headers.map((header) => { - if (header.mobileLabel === null) { + if (header.hideMobileLabel) { return ( - {header.mobileLabel !== undefined - ? header.mobileLabel - : header.content} + {header.mobileLabel ?? header.content} void; - handleSelectRow: (itemId: string, checked: boolean) => void; - isRowSelected: (itemId: string) => boolean; -} - -export const useTableSelection = ( - items: SelectableItem[], - selectedIds: string[], - onSelectionChange?: (selected: string[]) => void -): UseTableSelectionReturn => { - const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); - - const isAllSelected = useMemo( - () => - items.length > 0 && - items.every((item) => selectedSet.has(item.id)) && - selectedIds.length >= items.length, - [items, selectedSet, selectedIds.length] - ); - - const isSomeSelected = useMemo( - () => selectedIds.length > 0 && !isAllSelected, - [selectedIds.length, isAllSelected] - ); - - const handleSelectAll = useCallback( - (checked: boolean) => { - if (onSelectionChange) { - if (checked) { - const allIds = items.map((item) => item.id); - onSelectionChange(allIds); - } else { - onSelectionChange([]); - } - } - }, - [items, onSelectionChange] - ); - - const handleSelectRow = useCallback( - (itemId: string, checked: boolean) => { - if (onSelectionChange) { - if (checked) { - onSelectionChange([...selectedIds, itemId]); - } else { - onSelectionChange(selectedIds.filter((id) => id !== itemId)); - } - } - }, - [selectedIds, onSelectionChange] - ); - - const isRowSelected = useCallback( - (itemId: string) => selectedSet.has(itemId), - [selectedSet] - ); - - return { - isAllSelected, - isSomeSelected, - handleSelectAll, - handleSelectRow, - isRowSelected, - }; -}; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index dbae1b1e9..e6458ea34 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -14,7 +14,6 @@ import { usePost } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import { Checkbox } from "@/Components/inputs"; -import { useTableSelection } from "@/Hooks/useTableSelection"; interface InfraMonitorsTableProps { monitors: Monitor[]; @@ -55,13 +54,23 @@ export const InfraMonitorsTable = ({ const navigate = useNavigate(); const { post } = usePost, Monitor>(); - const { - isAllSelected, - isSomeSelected, - handleSelectAll, - handleSelectRow, - isRowSelected, - } = useTableSelection(monitors, selectedRows, onSelectionChange); + const selectedSet = new Set(selectedRows); + const isAllSelected = + monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id)); + const isSomeSelected = selectedRows.length > 0 && !isAllSelected; + + const handleSelectAll = (checked: boolean) => { + onSelectionChange?.(checked ? monitors.map((m) => m.id) : []); + }; + + const handleSelectRow = (id: string, checked: boolean) => { + if (!onSelectionChange) return; + onSelectionChange( + checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id) + ); + }; + + const isRowSelected = (id: string) => selectedSet.has(id); const handlePageChange = ( _e: React.MouseEvent | null, @@ -121,7 +130,7 @@ export const InfraMonitorsTable = ({ ? t("common.buttons.resume") : t("common.buttons.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {} as Record); + await post(`/monitors/pause/${monitor.id}`, {}); refetch(); }, closeMenu: true, @@ -168,7 +177,7 @@ export const InfraMonitorsTable = ({ onChange={(e) => handleSelectAll(e.target.checked)} /> ), - mobileLabel: null, + hideMobileLabel: true, render: (row) => ( state.ui?.chartType ?? "histogram"); const { post } = usePost, Monitor>(); - const { - isAllSelected, - isSomeSelected, - handleSelectAll, - handleSelectRow, - isRowSelected, - } = useTableSelection(monitors, selectedRows, onSelectionChange); + const selectedSet = new Set(selectedRows); + const isAllSelected = + monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id)); + const isSomeSelected = selectedRows.length > 0 && !isAllSelected; + + const handleSelectAll = (checked: boolean) => { + onSelectionChange?.(checked ? monitors.map((m) => m.id) : []); + }; + + const handleSelectRow = (id: string, checked: boolean) => { + if (!onSelectionChange) return; + onSelectionChange( + checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id) + ); + }; + + const isRowSelected = (id: string) => selectedSet.has(id); const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); @@ -129,7 +138,7 @@ export const MonitorTable = ({ ? t("pages.common.monitors.actions.resume") : t("pages.common.monitors.actions.pause"), action: async () => { - await post(`/monitors/pause/${monitor.id}`, {} as Record); + await post(`/monitors/pause/${monitor.id}`, {}); refetch(); }, closeMenu: true, @@ -175,7 +184,7 @@ export const MonitorTable = ({ onChange={(e) => handleSelectAll(e.target.checked)} /> ), - mobileLabel: null, + hideMobileLabel: true, render: (row) => ( Date: Tue, 5 May 2026 17:07:13 +0530 Subject: [PATCH 15/42] fix(ui): enforce strict type safety in bulk action error handling Addresses PR feedback: - Replaces forbidden any type in catch block with unknown - Adds type discrimination using axios.isAxiosError and instanceof Error - Narrows error type for logger.error compatibility Fixes #3602 --- client/src/Hooks/useBulkMonitorActions.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts index ddf96fedb..268137392 100644 --- a/client/src/Hooks/useBulkMonitorActions.ts +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import axios from "axios"; import { post } from "@/Utils/ApiClient"; import { useToast } from "@/Hooks/UseToast"; import { useTranslation } from "react-i18next"; @@ -55,9 +56,18 @@ export const useBulkMonitorActions = ( setSelectedRows([]); refetch(); - } catch (err: any) { - const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; - logger.error("Bulk pause/resume failed", err, { pause }); + } catch (err: unknown) { + let errMsg = "An error occurred"; + + if (axios.isAxiosError(err)) { + errMsg = err.response?.data?.msg || err.message || errMsg; + } else if (err instanceof Error) { + errMsg = err.message; + } + + logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, { + pause, + }); toastError(errMsg); } }; From e725128b10c040d0d9f5864b532c2a9fd953e076 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Tue, 5 May 2026 17:08:25 +0530 Subject: [PATCH 16/42] feat(monitors): track and report bulk action queue failures Addresses PR feedback: - Upgrades bulk pausing from Promise.all to Promise.allSettled - Logs individual job queue sync failures with monitor ID context - Surfaces failedCount to the frontend via controller response - Adds unit tests to verify partial failure handling Fixes #3602 --- server/src/controllers/monitorController.ts | 9 ++- server/src/service/business/monitorService.ts | 29 ++++++++-- .../test/unit/services/monitorService.test.ts | 55 +++++++++++++++++-- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 7422d5abb..5cb6f32be 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -315,14 +315,19 @@ class MonitorController implements IMonitorController { const { monitorIds, pause } = validatedBody; const teamId = requireTeamId(req.user?.teamId); - const monitors = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); + const { monitors, failedCount } = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); const action = pause ? "paused" : "resumed"; const monitorStr = monitors.length === 1 ? "monitor" : "monitors"; + let msg = `${monitors.length} ${monitorStr} ${action} successfully`; + if (failedCount > 0) { + msg = `${monitors.length} ${monitorStr} ${action} in database, but ${failedCount} failed to sync with the job queue. Please check logs.`; + } + return res.status(200).json({ success: true, - msg: `${monitors.length} ${monitorStr} ${action} successfully`, + msg, data: monitors, }); } catch (error) { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index e2430420d..d0dc8ca46 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -73,7 +73,7 @@ export interface IMonitorService { // update editMonitor(args: { teamId: string; monitorId: string; body: Partial }): Promise; pauseMonitor(args: { teamId: string; monitorId: string }): Promise; - bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise; + bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>; // delete deleteMonitor(args: { teamId: string; monitorId: string }): Promise; @@ -463,10 +463,18 @@ export class MonitorService implements IMonitorService { return monitor; }; - bulkPauseMonitors = async ({ teamId, monitorIds, pause }: { teamId: string; monitorIds: string[]; pause: boolean }): Promise => { + bulkPauseMonitors = async ({ + teamId, + monitorIds, + pause, + }: { + teamId: string; + monitorIds: string[]; + pause: boolean; + }): Promise<{ monitors: Monitor[]; failedCount: number }> => { const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause); - await Promise.all( + const results = await Promise.allSettled( monitors.map(async (monitor) => { if (monitor.isActive) { await this.jobQueue.resumeJob(monitor); @@ -476,7 +484,20 @@ export class MonitorService implements IMonitorService { }) ); - return monitors; + let failedCount = 0; + results.forEach((result, index) => { + if (result.status === "rejected") { + failedCount++; + this.logger.error({ + message: `Failed to sync job queue for monitor ${monitors[index].id} during bulk ${pause ? "pause" : "resume"}`, + service: SERVICE_NAME, + method: "bulkPauseMonitors", + stack: result.reason instanceof Error ? result.reason.stack : undefined, + }); + } + }); + + return { monitors, failedCount }; }; deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 74a0efa98..d2ee164bc 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -853,7 +853,8 @@ describe("MonitorService", () => { pause: false, }); - expect(result).toHaveLength(2); + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false); expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2); expect(jobQueue.pauseJob).not.toHaveBeenCalled(); @@ -871,7 +872,8 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(2); + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true); expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2); expect(jobQueue.resumeJob).not.toHaveBeenCalled(); @@ -890,7 +892,8 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(2); + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1); expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor); expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1); @@ -908,7 +911,8 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(0); + expect(result.monitors).toHaveLength(0); + expect(result.failedCount).toBe(0); expect(jobQueue.pauseJob).not.toHaveBeenCalled(); expect(jobQueue.resumeJob).not.toHaveBeenCalled(); }); @@ -925,9 +929,50 @@ describe("MonitorService", () => { pause: true, }); - expect(result).toHaveLength(1); + expect(result.monitors).toHaveLength(1); + expect(result.failedCount).toBe(0); expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor); }); + + it("returns failedCount and logs errors when multiple job operations reject", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [ + makeMonitor({ id: "m1", isActive: false }), + makeMonitor({ id: "m2", isActive: false }), + makeMonitor({ id: "m3", isActive: false }), + ]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const jobQueue = createJobQueueMock(); + (jobQueue.pauseJob as jest.Mock) + .mockRejectedValueOnce(new Error("Queue failure 1")) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Queue failure 2")); + + const logger = createMockLogger(); + const { service } = createService({ monitorsRepository, jobQueue, logger }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2", "m3"], + pause: true, + }); + + expect(result.monitors).toHaveLength(3); + expect(result.failedCount).toBe(2); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to sync job queue for monitor m1"), + method: "bulkPauseMonitors", + }) + ); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to sync job queue for monitor m3"), + method: "bulkPauseMonitors", + }) + ); + }); }); describe("deleteMonitor", () => { From ba2d23fd822a0aaa6c57eca95291e42080a250cb Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Tue, 5 May 2026 23:43:06 +0530 Subject: [PATCH 17/42] refactor(monitors): optimize bulkTogglePause from 3 to 2 DB queries Use find + updateMany with timestamps:false instead of find IDs + updateMany + fetch updated. Manually set updatedAt and edit in-memory documents to avoid a third DB round trip. Fixes #3602 --- .../monitors/MongoMonitorsRepository.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 9f5efecfe..3e7de17eb 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -241,22 +241,25 @@ class MongoMonitorsRepository implements IMonitorsRepository { teamId: new mongoose.Types.ObjectId(teamId), isActive: pause, // Only pause if active (true), only resume if inactive (false) }; + const nextStatus = pause ? "paused" : "initializing"; - const eligibleIds = (await MonitorModel.find(filter).select("_id").lean()).map((doc) => doc._id); - if (eligibleIds.length === 0) return []; + const eligible = await MonitorModel.find(filter); + if (eligible.length === 0) return []; + const now = new Date(); await MonitorModel.updateMany( - { _id: { $in: eligibleIds } }, - { - $set: { - isActive: !pause, - status: pause ? "paused" : "initializing", - }, - } + { _id: { $in: eligible.map((doc) => doc._id) } }, + { $set: { isActive: !pause, status: nextStatus, updatedAt: now } }, + { timestamps: false } ); - const updatedMonitors = await MonitorModel.find({ _id: { $in: eligibleIds } }); - return this.mapDocuments(updatedMonitors); + eligible.forEach((doc) => { + doc.isActive = !pause; + doc.status = nextStatus; + doc.updatedAt = now; + }); + + return this.mapDocuments(eligible); }; deleteById = async (monitorId: string, teamId: string) => { From c72287cb2181c29cb346f8b0fa38b6ace6dea2e5 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Wed, 6 May 2026 00:09:29 +0530 Subject: [PATCH 18/42] fix(monitor): Resolve build fail. Fixes #3602 --- server/src/service/business/monitorService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index d0dc8ca46..742e0200a 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -489,7 +489,7 @@ export class MonitorService implements IMonitorService { if (result.status === "rejected") { failedCount++; this.logger.error({ - message: `Failed to sync job queue for monitor ${monitors[index].id} during bulk ${pause ? "pause" : "resume"}`, + message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`, service: SERVICE_NAME, method: "bulkPauseMonitors", stack: result.reason instanceof Error ? result.reason.stack : undefined, From 940fc38056214f969e10da76227f3737e7d275c2 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 6 May 2026 13:55:58 -0400 Subject: [PATCH 19/42] docs: rename frontend-conventions to coding-conventions, add backend rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conventions doc grew up as an FE-only thing. After the DNS provider PR series highlighted real backend patterns that get repeatedly missed in code review (DI, AppError envelope, schema enum, test setup style, centralized Zod enums, OpenAPI response schemas), it makes sense to have a single doc that covers both halves of the codebase. Changes: - Rename docs/frontend-conventions.md → docs/coding-conventions.md. - Add a new top-level rule 0: look at peer implementations and follow the established pattern. Applies to both FE and BE. Includes a 4-step "how to apply" and a self-check for PR authors. - Keep the 5 frontend rules unchanged (rules 1-5). - Add 6 backend rules (rules 6-11): - Controller → Service → Repository → Mongoose layering. - Provider skeleton (DI, SERVICE_NAME / TIMEOUT_MS, timeRequest, inline setTimeout race, outer AppError catch). - Mongoose enum reusing types/* const tuples. - Provider test conventions (file location, @jest/globals, contract test, inline setup() per test). - Centralize Zod enums in types/* — don't duplicate. - New entity fields must land in *ResponseSchema for OpenAPI. - Pre-PR checklist split into Universal / Frontend / Backend. - CLAUDE.md updated to reference the new file and summarize all 11 rules + rule 0. --- CLAUDE.md | 16 +- docs/coding-conventions.md | 404 +++++++++++++++++++++++++++++++++++ docs/frontend-conventions.md | 211 ------------------ 3 files changed, 418 insertions(+), 213 deletions(-) create mode 100644 docs/coding-conventions.md delete mode 100644 docs/frontend-conventions.md diff --git a/CLAUDE.md b/CLAUDE.md index c5f16d3a9..63a349ae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,14 +159,26 @@ When working on anything related to check scheduling, incident lifecycle, or not ## Code Conventions -### Frontend conventions (mandatory for `client/src`) -Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review: +### Coding conventions (mandatory) +Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review. + +**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes. + +**Frontend (`client/src`):** 1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`). 2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability). 3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`. 4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`. 5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`. +**Backend (`server/src`):** +6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories. +7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`. +8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple. +9. Provider tests at `server/test/unit/providers/network/.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`). +10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once. +11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them. + ### Internationalization All user-facing strings must use the translation function: ```javascript diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md new file mode 100644 index 000000000..61f4a5b9c --- /dev/null +++ b/docs/coding-conventions.md @@ -0,0 +1,404 @@ +# Coding conventions + +Style and structural rules the codebase follows. Frontend rules are extracted from review feedback on PR series #3591–#3596; backend rules are extracted from the provider/repository/service layering used across `server/src/service/infrastructure/network/*` and the patterns enforced in code review. These are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent. + +> If you're touching a file in `client/src` the **frontend** rules apply. +> If you're touching a file in `server/src` the **backend** rules apply. +> Rule 0 applies everywhere. + +--- + +## 0. Look at existing implementations and follow the established pattern + +Before writing new code, find one or two files that already do something similar — a peer provider for a new monitor type, a peer page for a new admin screen, a peer repository method for a new query — and follow the same shape. Match imports, helper-function placement, naming, error handling, mock-construction style, and assertion idioms. If you're tempted to do it differently, the burden is on you to justify the divergence. + +**Why:** consistency is more valuable than locally-optimal cleverness. A reader who has internalized the pattern of one peer file should be able to read the new one with no extra mental load. New shapes also make refactors more expensive — when someone wants to change the convention later, every variant has to be visited and reasoned about separately. + +**How to apply:** before writing a new component, page, provider, repository, or test file: + +1. Grep for the closest existing peers (e.g. `WebSocketProvider.ts` if you're writing a new monitor provider). +2. Read at least two of them end-to-end. +3. Mirror their structure: same constants at the top (`SERVICE_NAME`, `TIMEOUT_MS`), same import order, same DI shape, same try/catch envelope, same test-file location and helper imports. +4. If your new code needs a shape no peer has, name it explicitly in the PR description and link to the peer you diverged from. Don't sneak novelty in. + +**Concrete check before opening a PR:** can a reader open a peer file side-by-side with yours and trace each section in the same order? If not, restructure. + +--- + +# Part 1 — Frontend (`client/src`) + +## 1. Prefer MUI native (system) props over `sx` + +MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS). + +**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives. + +**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it. + +### Canonical mappings + +| `sx` key | Native prop | +|---|---| +| `color` | `color` | +| `backgroundColor` | `bgcolor` | +| `borderRadius` | `borderRadius` | +| `border`, `borderTop`, … | `border`, `borderTop`, … | +| `width`, `height`, `minHeight`, `maxWidth` | same names as native | +| `p`, `pt`, `pb`, `px`, `py` | same names as native | +| `m`, `mt`, `mb`, `mx`, `my` | same names as native | +| `display` | `display` | +| `textAlign` | `textAlign` | +| `fontWeight`, `fontSize`, `lineHeight` | same names as native | +| `gap` (on Stack) | `gap` | + +### Examples + +```tsx +// BAD — everything inside sx + + +// GOOD — every key that has a native prop is hoisted + +``` + +```tsx +// BAD + + +// GOOD — hoist what you can; drop fontSize:13 because it's the body default + +``` + +### When `sx` is still correct + +Keep `sx` for things that don't have a native prop: + +```tsx + +``` + +Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`. + +--- + +## 2. Use the full theme path for colors, never the shorthand string + +Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**. + +**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable. + +**How to apply:** always destructure or reference the theme value explicitly. + +```tsx +// BAD +… +… + +// GOOD +const theme = useTheme(); +… +… +``` + +This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there. + +--- + +## 3. No hardcoded numeric or string literals — use tokens + +The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first). + +**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name. + +**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand). + +### Token sources + +- **Spacing / layout** — `client/src/Utils/Theme/constants.ts` + - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16` + - `SPACING` for `theme.spacing(...)` multipliers +- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels` + - `xs / s / m / l / xl / xxl` (rem values) +- **Hover / interaction** — `HOVER.DARKEN` etc. +- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`) +- **Colors** — `theme.palette.*` (rule 2 above) + +### Examples + +```tsx +// BAD + + + + + // 13 is the body default — drop the prop entirely + +// GOOD + + + + + // inherits 13px from theme +``` + +### Token naming gotcha + +`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way. + +--- + +## 4. Use `useTheme()` inside components, don't import the theme module + +The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit). + +**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`. + +**How to apply:** inside any component or component-helper that needs theme values: + +```tsx +// BAD +import { theme } from "@/Utils/Theme/Theme"; +const SectionHeading = ({ children }) => ( + {children} +); + +// GOOD +import { useTheme } from "@mui/material/styles"; +const SectionHeading = ({ children }) => { + const theme = useTheme(); + return {children}; +}; +``` + +Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it. + +--- + +## 5. Discriminated unions: pair the runtime list with the type + +When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift. + +**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable. + +```tsx +// BAD +type Severity = "info" | "warning" | "error"; + +// GOOD +export const Severities = ["info", "warning", "error"] as const; +export type Severity = (typeof Severities)[number]; +``` + +Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown). + +--- + +# Part 2 — Backend (`server/src`) + +## 6. Layering: Controller → Service → Repository → Mongoose + +The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access. Every new feature follows this path, no shortcuts. + +``` +Request → Controller → Service → Repository → MongoDB (Mongoose) +``` + +**Why:** this is what makes each layer independently testable. Mongoose calls in services force services to mock the database in tests; raw DB queries in controllers couple HTTP shape to schema shape and turn every refactor into a multi-file rewrite. + +**How to apply:** when adding a new feature, the pattern is — **add a repository method** for any new DB query, **call it from a service**, **expose it via a controller**. If a service constructs a `MonitorModel.findOne({...})`, you've broken the rule; the query belongs in the repository. + +- **Controllers** (`server/src/controllers/`) handle HTTP concerns only: parse params, call the service, return via the `responseHandler` middleware. No business logic. +- **Services** (`server/src/service/business/`) contain all business logic. They throw `AppError` on validation/auth failures; they do not write directly to MongoDB. +- **Repositories** (`server/src/repositories/`) are the sole layer that talks to MongoDB. They expose clean query methods (`findByMonitorId`, `createCheck`, `updateStatusWindowAndChecks`) and return entities — never Mongoose documents — via a `toEntity` private helper. + +--- + +## 7. Provider conventions for `service/infrastructure/network/*` + +Every monitor-type provider implements `IStatusProvider` and follows the same skeleton. + +**Why:** the registry in `services.ts` builds providers identically (`new XxxProvider(deps)`) and `NetworkService` invokes them identically (`provider.handle(monitor)`). If your provider's shape diverges, the registry/dispatch becomes asymmetric and `NetworkService` grows special cases. + +**How to apply:** copy the structure of a recent peer (`WebSocketProvider`, `GrpcProvider`, `PortProvider`). The skeleton: + +```ts +const SERVICE_NAME = "XxxProvider"; +const TIMEOUT_MS = 10000; + +type ClientCtor = typeof SomeNodeStdlibClass; + +export class XxxProvider implements IStatusProvider { + readonly type = "xxx"; + + constructor(private Client: ClientCtor) {} + + supports(type: MonitorType): boolean { + return type === "xxx"; + } + + async handle(monitor: Monitor): Promise> { + try { + // 1. validate inputs → throw AppError(400) on bad input + // 2. set up the client (use this.Client, never imported singleton) + // 3. perform the call inside timeRequest(...), with a setTimeout race + // in the Promise body to enforce TIMEOUT_MS + // 4. return MonitorStatusResponse with payload + } catch (err: unknown) { + if (err instanceof AppError) throw err; + const originalMessage = err instanceof Error ? err.message : String(err); + throw new AppError({ + message: originalMessage || "Error performing xxx check", + status: 500, + service: SERVICE_NAME, + method: "handle", + details: { /* monitor-shaped context */ }, + }); + } + } +} +``` + +### Hard rules + +- **DI for all external dependencies.** `typeof WebSocket`, `typeof net` — pass the constructor in via the provider's constructor. Never `import { Foo } from "some/library"` and call it inside `handle()`. This is the single thing that makes the provider unit-testable without `jest.unstable_mockModule`. +- **`SERVICE_NAME` and `TIMEOUT_MS`** as module-level constants near the top, not magic strings/numbers in the body. +- **Use the shared `timeRequest()` helper** from `service/infrastructure/network/utils.ts` to measure latency. Don't roll your own `process.hrtime()`. +- **Enforce a hard timeout.** Every peer provider inlines a `setTimeout` race in the Promise body around the underlying client call (see `WebSocketProvider.ts`); sockets, DNS resolvers, and external clients can hang indefinitely without it. There is no shared `withTimeout` helper today — match the inline pattern. +- **Outer try/catch** that converts unknown errors to `AppError({ service, method, details })` while letting your own `AppError` pass through. + +--- + +## 8. Mongoose schema fields with closed value sets + +If a field's TypeScript type is a closed union (status, type, record type), the Mongoose schema must declare the same set as `enum`. + +**Why:** the validator and the schema are independent gates. A service-layer Zod check can be bypassed (imports, migrations, direct repo writes). A schema `enum` is the last line of defense and surfaces invalid data as a Mongoose validation error rather than silently accepting `"badtype"` and breaking downstream consumers. + +**How to apply:** when you add a string field whose values are constrained by a const tuple in `types/`, reuse the same const for the schema enum: + +```ts +// types/monitor.ts (existing) +export const MonitorMatchMethods = ["equal", "include", "regex"] as const; + +// db/models/Monitor.ts +{ + matchMethod: { + type: String, + enum: MonitorMatchMethods, + }, +} +``` + +The same applies to `MonitorTypes`, `MonitorStatuses`, and any new closed-set field. + +--- + +## 9. Test conventions for `server/test/unit/providers/network/*` + +Each provider gets a unit test file at `server/test/unit/providers/network/.test.ts`. Follow the structure used by `webSocketProvider.test.ts`, `grpcProvider.test.ts`, `portProvider.test.ts`. + +**Why:** consistency lets reviewers diff a new test file against any peer and only see the actual logic differences, not framework/import noise. It also lets the `testStatusProviderContract` helper guarantee every provider satisfies the `IStatusProvider` contract. + +**How to apply:** + +- **Imports**: `from "@jest/globals"` (not the global `jest`), and `.ts` extensions on every relative import. +- **Contract test**: call `testStatusProviderContract("XxxProvider", { create, supportedType, unsupportedType, makeMonitor })` before the per-test `describe` block. +- **Mock construction**: build the constructor mock as `jest.fn().mockImplementation(() => mock) as unknown as typeof Client` (typed cast, not `as any`). +- **`makeMonitor` factory**: takes `Partial` overrides, spreads onto a baseline, returns `as Monitor`. +- **Inline `setup()` per test** rather than `beforeEach` shared state. Each test should construct its own mock + provider, so a single test can be read in isolation: + + ```ts + const setup = () => { + const mock = createMockClient(); + const Ctor = jest.fn().mockImplementation(() => mock) as unknown as typeof Client; + const provider = new XxxProvider(Ctor); + return { mock, provider }; + }; + + it("does X", async () => { + const { mock, provider } = setup(); + // ... + }); + ``` + +- **Assertion style**: individual `expect(result.field).toBe(...)` lines or `expect(result).toEqual(expect.objectContaining({...}))`. Both are used; pick what reads cleanest for the case. +- **Test naming**: lowercase imperative ("returns success when connection opens", "uses the configured timeout", "throws AppError when host is missing"). + +--- + +## 10. Validation: centralize, don't inline + +Whenever the same string-set or shape constraint appears in two or more validators, lift it into `types/` as a const tuple and `import` it into both validators. Don't inline `z.enum(["A", "AAAA", ...])` in three places. + +**Why:** drift. Three inline copies will eventually disagree — someone adds a new value to one and forgets the other two. The validator silently accepts it on `POST /monitors` and rejects it on `PATCH`, leading to bug reports the author can't reproduce. + +**How to apply:** keep validation source-of-truth in `server/src/types/*.ts` as paired const tuples + derived types (rule 5). The validators in `server/src/validation/*.ts` and the Mongoose schemas in `server/src/db/models/*.ts` import from `types/` and reuse the same tuple. + +--- + +## 11. OpenAPI: response schemas live in the validator file + +The OpenAPI `monitorObject` (and friends) are generated from a `monitorResponseSchema` Zod object exported from the validator file, not hand-built in `server/openapi/routes/`. When you add a field to the data model, add it to the response schema in the same edit. + +**Why:** the spec is auto-generated. If the response schema doesn't list a field, SDK consumers don't see it, even though the server returns it. That's a silent contract gap. + +**How to apply:** when you add an entity field, update three files in the same PR: + +1. `types/.ts` — add the field to the TS interface. +2. `db/models/.ts` — add the Mongoose schema entry (with `enum` if the value set is closed; rule 8). +3. `validation/Validation.ts` — add the field to the response schema (and create/edit body schemas if it's user-settable). + +The repository's `toEntity` helper also needs to surface the new field; that's part of the change, not a separate one. + +--- + +# Pre-PR checklist + +Before opening a PR, grep your diff for the patterns below. Each item describes the **anti-pattern** to find and fix; if your diff has none, that line passes. Frontend section applies to `client/src`; backend to `server/src`. + +## Universal +- [ ] I can name the peer file I used as a template, and my file's structure mirrors it (rule 0). +- [ ] `npm run build` and `npm run format-check` are clean in both `client/` and `server/`. + +## Frontend (`client/src`) +- [ ] No `sx={{ … }}` block containing keys with a native-prop equivalent (rule 1). +- [ ] No `color="text.X"`, `bgcolor="background.X"`, or other short-string palette paths (rule 2). +- [ ] No raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3). +- [ ] No `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4). +- [ ] No `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5). + +## Backend (`server/src`) +- [ ] No `MonitorModel.findOne` (or similar Mongoose call) outside `repositories/` (rule 6). +- [ ] No provider that imports its underlying client at module top instead of receiving it via constructor DI (rule 7). +- [ ] No provider missing `SERVICE_NAME` / `TIMEOUT_MS` / `AppError` outer catch / `timeRequest()` / inline timeout (rule 7). +- [ ] No closed-set string field on a Mongoose schema without an `enum` (rule 8). +- [ ] No provider test that uses `beforeEach` instead of inline `setup()` (rule 9). +- [ ] No duplicated `z.enum([...])` literal that should reference a `types/*` const tuple (rule 10). +- [ ] No new entity field missing from `monitorResponseSchema` (or peer response schema) for OpenAPI (rule 11). + +A clean PR on these axes is a PR that ships without a normalize follow-up. diff --git a/docs/frontend-conventions.md b/docs/frontend-conventions.md deleted file mode 100644 index 4f14c555b..000000000 --- a/docs/frontend-conventions.md +++ /dev/null @@ -1,211 +0,0 @@ -# Frontend conventions - -Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme. - -> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases. - ---- - -## 1. Prefer MUI native (system) props over `sx` - -MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS). - -**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives. - -**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it. - -### Canonical mappings - -| `sx` key | Native prop | -|---|---| -| `color` | `color` | -| `backgroundColor` | `bgcolor` | -| `borderRadius` | `borderRadius` | -| `border`, `borderTop`, … | `border`, `borderTop`, … | -| `width`, `height`, `minHeight`, `maxWidth` | same names as native | -| `p`, `pt`, `pb`, `px`, `py` | same names as native | -| `m`, `mt`, `mb`, `mx`, `my` | same names as native | -| `display` | `display` | -| `textAlign` | `textAlign` | -| `fontWeight`, `fontSize`, `lineHeight` | same names as native | -| `gap` (on Stack) | `gap` | - -### Examples - -```tsx -// BAD — everything inside sx - - -// GOOD — every key that has a native prop is hoisted - -``` - -```tsx -// BAD - - -// GOOD — hoist what you can; drop fontSize:13 because it's the body default - -``` - -### When `sx` is still correct - -Keep `sx` for things that don't have a native prop: - -```tsx - -``` - -Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`. - ---- - -## 2. Use the full theme path for colors, never the shorthand string - -Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**. - -**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable. - -**How to apply:** always destructure or reference the theme value explicitly. - -```tsx -// BAD -… -… - -// GOOD -const theme = useTheme(); -… -… -``` - -This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there. - ---- - -## 3. No hardcoded numeric or string literals — use tokens - -The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first). - -**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name. - -**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand). - -### Token sources - -- **Spacing / layout** — `client/src/Utils/Theme/constants.ts` - - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16` - - `SPACING` for `theme.spacing(...)` multipliers -- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels` - - `xs / s / m / l / xl / xxl` (rem values) -- **Hover / interaction** — `HOVER.DARKEN` etc. -- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`) -- **Colors** — `theme.palette.*` (rule 2 above) - -### Examples - -```tsx -// BAD - - - - - // 13 is the body default — drop the prop entirely - -// GOOD - - - - - // inherits 13px from theme -``` - -### Token naming gotcha - -`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way. - ---- - -## 4. Use `useTheme()` inside components, don't import the theme module - -The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit). - -**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`. - -**How to apply:** inside any component or component-helper that needs theme values: - -```tsx -// BAD -import { theme } from "@/Utils/Theme/Theme"; -const SectionHeading = ({ children }) => ( - {children} -); - -// GOOD -import { useTheme } from "@mui/material/styles"; -const SectionHeading = ({ children }) => { - const theme = useTheme(); - return {children}; -}; -``` - -Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it. - ---- - -## 5. Discriminated unions: pair the runtime list with the type - -When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift. - -**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable. - -```tsx -// BAD -type Severity = "info" | "warning" | "error"; - -// GOOD -export const Severities = ["info", "warning", "error"] as const; -export type Severity = (typeof Severities)[number]; -``` - -Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown). - ---- - -## Pre-PR checklist - -Before you open a PR, scan your diff for these: - -- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1) -- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2) -- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3) -- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4) -- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5) -- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions) - -A clean PR on these axes is a PR that ships without a normalize follow-up. From 602769b3e7c04e313ad9c137a44c90ce45e6a9af Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 01:13:09 -0400 Subject: [PATCH 20/42] feat(checks): render status code 5000 as "Down" with subtype tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status code 5000 is Checkmate's internal sentinel for "no HTTP response received" (DNS failure, connection refused, timeout, TLS error, etc.). Showing the raw number to admins is confusing — the value isn't a real HTTP code and means nothing without context. This change replaces "5000" with "Down" across all check/incident surfaces, and adds a hover tooltip that explains the underlying cause based on the stored error message. Real HTTP codes (2xx/3xx/4xx/5xx) are unchanged. ## Changes - Add client/src/Utils/statusCode.ts with formatStatusCode, getStatusCodeTooltip, getStatusCodeValueType, extractErrorCode, and a message-to-subtype parser (timeout / dns / refused / tls / reset). - Wire into 6 render sites: Checks table, Uptime details Checks table, GeoChecks table + map popup, Incidents table, Incident details dialog. - Add common.statusCode.* i18n keys in en.json with subtype-specific descriptions. Tooltip appends the raw error code (ECONNREFUSED, ETIMEDOUT, CERT_HAS_EXPIRED, etc.) when present in the message. ## Tooltip examples - "queryA ECONNREFUSED www.example.com" → "The server refused the connection (port closed or service not listening). (ECONNREFUSED)" - "connect ETIMEDOUT 1.2.3.4:443" → "The server didn't respond in time (request timed out). (ETIMEDOUT)" ## Notes - No DB or server changes; statusCode field stays 5000 in storage. - Other locales fall back to English keys until translated. --- .../src/Components/monitors/GeoChecksMap.tsx | 5 +- .../Pages/Checks/Components/ChecksTable.tsx | 31 ++++++-- .../Incidents/Components/CardDetails.tsx | 21 ++++- .../Incidents/Components/IncidentTable.tsx | 27 ++++--- .../Uptime/Details/Components/ChecksTable.tsx | 16 +++- .../Details/Components/GeoChecksTable.tsx | 16 +++- client/src/Utils/statusCode.ts | 78 +++++++++++++++++++ client/src/locales/en.json | 11 +++ 8 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 client/src/Utils/statusCode.ts diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx index 3f9429853..e013f2235 100644 --- a/client/src/Components/monitors/GeoChecksMap.tsx +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material"; import Map, { Marker, Popup } from "react-map-gl/maplibre"; import type { MapRef } from "react-map-gl/maplibre"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; +import { useTranslation } from "react-i18next"; +import { formatStatusCode } from "@/Utils/statusCode"; import "maplibre-gl/dist/maplibre-gl.css"; interface GeoChecksMapProps { @@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { const [selectedCheck, setSelectedCheck] = useState(null); const theme = useTheme(); const isDarkMode = theme.palette.mode === "dark"; + const { t } = useTranslation(); const mapPopupStyles = ( { > Status Code: {" "} - {selectedCheck.statusCode} + {formatStatusCode(selectedCheck.statusCode, t)} state.ui.timezone); - const getHeaders = (t: Function, uiTimezone: string) => { + const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "monitorName", @@ -65,13 +76,21 @@ export const ChecksTable = ({ render: (row) => { const code = row.statusCode; if (!code) return "N/A"; - const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative"; - return ( + const text = formatStatusCode(code, t); + const tooltip = getStatusCodeTooltip(code, row.message, t); + const label = ( ); + return tooltip ? ( + + {label} + + ) : ( + label + ); }, }, { diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index b6c1288dd..2cd8fbde4 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Grid from "@mui/material/Grid"; import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; -import { BaseBox, ValueLabel } from "@/Components/design-elements"; +import { BaseBox, ValueLabel, Tooltip } from "@/Components/design-elements"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useTranslation } from "react-i18next"; @@ -13,6 +13,7 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { getIncidentsDuration } from "@/Pages/Incidents/utils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; interface CardDetailsProps { incident: Incident | null; @@ -46,6 +47,14 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { if (!incident) { return null; } + + const statusCodeText = formatStatusCode(incident.statusCode, t); + const statusCodeTooltip = getStatusCodeTooltip( + incident.statusCode, + incident.message, + t + ); + return ( { {t("pages.incidents.dialog.details.statusCode")} - {incident.statusCode ?? "N/A"} + + {statusCodeTooltip ? ( + + {statusCodeText} + + ) : ( + statusCodeText + )} + {t("pages.incidents.dialog.details.downtime")} diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index bb1759dcd..c66cdb0ed 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -1,8 +1,6 @@ -import { Table } from "@/Components/design-elements"; +import { Table, ValueLabel, Tooltip } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table"; -import { ValueLabel } from "@/Components/design-elements"; -import type { ValueType } from "@/Components/design-elements/StatusLabel"; import { ActionsMenu } from "@/Components/actions-menu"; import type { Incident } from "@/Types/Incident"; import type { Monitor } from "@/Types/Monitor"; @@ -16,6 +14,11 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; import { getMonitorPath } from "@/Utils/MonitorUtils"; +import { + formatStatusCode, + getStatusCodeTooltip, + getStatusCodeValueType, +} from "@/Utils/statusCode"; interface IncidentsTableProps { title?: string; @@ -157,15 +160,21 @@ export const IncidentsTable = ({ render: (row) => { const code = row.statusCode; if (!code) return "N/A"; - let value: ValueType = "neutral"; - if (code < 300) value = "positive"; - else if (code >= 400) value = "negative"; - return ( + const text = formatStatusCode(code, t); + const tooltip = getStatusCodeTooltip(code, row.message, t); + const label = ( ); + return tooltip ? ( + + {label} + + ) : ( + label + ); }, }, { diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 0aaede394..0ab9a7a7c 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -1,4 +1,4 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -6,10 +6,11 @@ import type { Check } from "@/Types/Check"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -const getHeaders = (t: Function, uiTimezone: string) => { +const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "status", @@ -36,7 +37,16 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + if (!row.statusCode) return "N/A"; + const text = formatStatusCode(row.statusCode, t); + const tooltip = getStatusCodeTooltip(row.statusCode, row.message, t); + return tooltip ? ( + + {text} + + ) : ( + text + ); }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index 31ad6a3dd..ae56e037e 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -1,14 +1,15 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; import prettyMilliseconds from "pretty-ms"; -const getHeaders = (t: Function, uiTimezone: string) => { +const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "status", @@ -29,7 +30,16 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + if (!row.statusCode) return "N/A"; + const text = formatStatusCode(row.statusCode, t); + const tooltip = getStatusCodeTooltip(row.statusCode, undefined, t); + return tooltip ? ( + + {text} + + ) : ( + text + ); }, }, { diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts new file mode 100644 index 000000000..75e4a9b19 --- /dev/null +++ b/client/src/Utils/statusCode.ts @@ -0,0 +1,78 @@ +import type { ValueType } from "@/Components/design-elements/StatusLabel"; + +export const NETWORK_ERROR_CODE = 5000; + +export const NETWORK_ERROR_SUBTYPES = [ + "timeout", + "dns", + "refused", + "tls", + "reset", + "unknown", +] as const; +export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number]; + +type TranslateFn = (key: string) => string; + +export const isNetworkError = (code?: number | null): boolean => + code === NETWORK_ERROR_CODE; + +const ERROR_CODE_PATTERN = + /\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/; + +export const extractErrorCode = (message?: string | null): string | null => { + if (!message) return null; + const match = message.match(ERROR_CODE_PATTERN); + return match ? match[1] : null; +}; + +export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => { + if (!message) return "unknown"; + const m = message.toUpperCase(); + if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT")) + return "timeout"; + if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS")) + return "dns"; + if (m.includes("ECONNREFUSED")) return "refused"; + if ( + m.includes("CERT_") || + m.includes("UNABLE_TO_VERIFY") || + m.includes("DEPTH_ZERO_SELF_SIGNED") || + m.includes("SELF_SIGNED") || + m.includes("TLS") || + m.includes("SSL") + ) + return "tls"; + if (m.includes("ECONNRESET")) return "reset"; + return "unknown"; +}; + +export const formatStatusCode = ( + code: number | null | undefined, + t: TranslateFn, + fallback: string = "N/A" +): string => { + if (code == null) return fallback; + if (isNetworkError(code)) return t("common.statusCode.down"); + return String(code); +}; + +export const getStatusCodeTooltip = ( + code: number | null | undefined, + message: string | null | undefined, + t: TranslateFn +): string | null => { + if (!isNetworkError(code)) return null; + const description = t( + `common.statusCode.networkError.${getNetworkErrorSubtype(message)}` + ); + const errorCode = extractErrorCode(message); + return errorCode ? `${description} (${errorCode})` : description; +}; + +export const getStatusCodeValueType = (code: number): ValueType => { + if (isNetworkError(code)) return "negative"; + if (code < 300) return "positive"; + if (code < 400) return "neutral"; + return "negative"; +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 211021276..587e25df1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,6 +14,17 @@ } }, "appName": "Checkmate", + "statusCode": { + "down": "Down", + "networkError": { + "timeout": "The server didn't respond in time (request timed out).", + "dns": "Couldn't resolve the hostname (DNS lookup failed).", + "refused": "The server refused the connection (port closed or service not listening).", + "tls": "TLS or SSL handshake failed (certificate or encryption issue).", + "reset": "The connection was reset before a response was received.", + "unknown": "No HTTP response was received from the target." + } + }, "breadcrumbs": { "details": "Details", "home": "Home" From 4db95cfa449baa0ad7622630364ee693fc991f95 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 12:29:26 -0400 Subject: [PATCH 21/42] fix(maintenance): stop silent deletion of one-time maintenance windows Maintenance windows could disappear seconds after creation when the API received a payload with end dates already in the past. The repository auto-set expiry = end for one-time windows (repeat = 0), and Mongo's TTL index on the expiry field then deleted the row on its next sweep. This was particularly visible with automation tools sending payloads in non-UTC timezones. ## Changes ### Validator hardening - Add .strict() to create + edit body schemas so unknown keys return 400 instead of being silently dropped. - Refine end > start on create. - Refine end > now for one-time create payloads (repeat = 0). - Refine end > start on edit when both are provided. - Remove the accepted expiry field from both schemas. ### Stop the auto-delete behaviour - Repository.create no longer copies end into expiry. - Schema's expiry field + TTL index removed. - Migration 0007 drops the live TTL index and unsets stale expiry values on existing rows so legacy data is no longer at risk. ### Client - Form submission no longer includes expiry. --- client/src/Pages/Maintenance/create/index.tsx | 4 -- .../0007_dropMaintenanceWindowExpiryIndex.ts | 48 +++++++++++++++ server/src/db/migration/index.ts | 2 + server/src/db/models/MaintenanceWindow.ts | 5 -- .../MongoMaintenanceWindowsRepository.ts | 5 -- .../validation/maintenanceWindowValidation.ts | 58 ++++++++++++------- 6 files changed, 86 insertions(+), 36 deletions(-) create mode 100644 server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index d130ee7c7..44a4b9a81 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => { repeat, }; - if (repeat === 0) { - payload.expiry = endDateTime.toISOString(); - } - return payload; }; diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts new file mode 100644 index 000000000..850ab19ef --- /dev/null +++ b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts @@ -0,0 +1,48 @@ +import { MaintenanceWindowModel } from "../models/MaintenanceWindow.js"; +import { logger } from "@/utils/logger.js"; + +/** + * Drops the TTL index on MaintenanceWindow.expiry and unsets the expiry field. + * + * Earlier versions auto-set expiry = end for one-time maintenance windows. Combined with the + * TTL index ({ expires: "0s" }), this caused Mongo to silently delete completed one-time + * windows shortly after their end-date passed — including windows whose end-date was already + * in the past at creation time (e.g. timezone-mismatched payloads from automation tools). + * The fix stops writing expiry on create, and this migration removes the index and stale field + * so legacy rows are no longer at risk of being deleted by the TTL monitor. + */ +export async function dropMaintenanceWindowExpiryIndex(): Promise { + const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex"; + + try { + const collection = MaintenanceWindowModel.collection; + const indexes = await collection.indexes(); + const expiryIndex = indexes.find((idx) => idx.key && idx.key.expiry === 1); + + if (expiryIndex?.name) { + await collection.dropIndex(expiryIndex.name); + logger.info({ + service: SERVICE_NAME, + message: `Dropped TTL index ${expiryIndex.name} on MaintenanceWindow.expiry`, + }); + } else { + logger.info({ + service: SERVICE_NAME, + message: "No TTL index found on MaintenanceWindow.expiry", + }); + } + + const result = await collection.updateMany({ expiry: { $exists: true } }, { $unset: { expiry: "" } }); + logger.info({ + service: SERVICE_NAME, + message: `Unset expiry on ${result.modifiedCount} maintenance windows`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ + service: SERVICE_NAME, + message: `Error dropping expiry index: ${errorMessage}`, + }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 5b74a96fc..fcf0cf1fb 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -5,6 +5,7 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds. import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; +import { dropMaintenanceWindowExpiryIndex } from "./0007_dropMaintenanceWindowExpiryIndex.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -19,6 +20,7 @@ const migrations: MigrationEntry[] = [ { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, + { name: "0007_dropMaintenanceWindowExpiryIndex", execute: dropMaintenanceWindowExpiryIndex }, ]; const runMigrations = async (logger?: ILogger) => { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 311cf97bf..e3287ce7c 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -6,7 +6,6 @@ type MaintenanceWindowDocumentBase = Omit( end: { type: Date, }, - expiry: { - type: Date, - index: { expires: "0s" }, - }, }, { timestamps: true, diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index ad96328c5..0cdda805e 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -45,11 +45,6 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository create = async (data: Partial): Promise => { const maintenanceWindow = new MaintenanceWindowModel(data); - - // If the maintenance window is a one time window, set the expiry to the end date - if (maintenanceWindow.repeat === 0) { - maintenanceWindow.expiry = maintenanceWindow.end; - } const result = await maintenanceWindow.save(); return this.toEntity(result); }; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index bee06e7bc..5d83b3fed 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -7,17 +7,26 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString()); // Maintenance Window Validations //**************************************** -export const createMaintenanceWindowBodyValidation = z.object({ - monitors: z.array(z.string()).min(1, "At least one monitor is required"), - name: z.string().min(1, "Name is required"), - active: z.boolean().optional(), - duration: z.number().min(1, "Duration is required"), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), - start: dateToString, - end: dateToString, - repeat: z.number().min(0, "Repeat must be a non-negative number"), - expiry: dateToString.optional(), -}); +export const createMaintenanceWindowBodyValidation = z + .object({ + monitors: z.array(z.string()).min(1, "At least one monitor is required"), + name: z.string().min(1, "Name is required"), + active: z.boolean().optional(), + duration: z.number().min(1, "Duration is required"), + durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), + start: dateToString, + end: dateToString, + repeat: z.number().min(0, "Repeat must be a non-negative number"), + }) + .strict() + .refine((data) => new Date(data.end).getTime() > new Date(data.start).getTime(), { + message: "End must be after start", + path: ["end"], + }) + .refine((data) => data.repeat > 0 || new Date(data.end).getTime() > Date.now(), { + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }); export const getMaintenanceWindowByIdParamValidation = z.object({ id: z.string().min(1, "ID is required"), @@ -43,14 +52,19 @@ export const editMaintenanceWindowByIdParamValidation = z.object({ id: z.string().min(1, "ID is required"), }); -export const editMaintenanceByIdWindowBodyValidation = z.object({ - active: z.boolean().optional(), - name: z.string().optional(), - repeat: z.number().optional(), - start: dateToString.optional(), - end: dateToString.optional(), - expiry: dateToString.optional(), - monitors: z.array(z.string()).optional(), - duration: z.number().optional(), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), -}); +export const editMaintenanceByIdWindowBodyValidation = z + .object({ + active: z.boolean().optional(), + name: z.string().optional(), + repeat: z.number().optional(), + start: dateToString.optional(), + end: dateToString.optional(), + monitors: z.array(z.string()).optional(), + duration: z.number().optional(), + durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), + }) + .strict() + .refine((data) => !data.start || !data.end || new Date(data.end).getTime() > new Date(data.start).getTime(), { + message: "End must be after start", + path: ["end"], + }); From bd059da94a3027d7a196a5483067c398107224be Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 12:33:03 -0400 Subject: [PATCH 22/42] refactor(maintenance): tighten validator + migration per /simplify - Collapse two refines into a single superRefine in createMaintenanceWindowBodyValidation; same on edit. - Match peer migration convention: use mongoose.connection.db directly, not Model.collection. - Tighten TTL index detection: require expireAfterSeconds + single-field key. - Add Starting log at migration top to match peers 0003-0006. --- .../0007_dropMaintenanceWindowExpiryIndex.ts | 30 +++++++++----- .../validation/maintenanceWindowValidation.ts | 39 ++++++++++++++----- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts index 850ab19ef..38e55c0d7 100644 --- a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts +++ b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts @@ -1,23 +1,33 @@ -import { MaintenanceWindowModel } from "../models/MaintenanceWindow.js"; +import mongoose from "mongoose"; import { logger } from "@/utils/logger.js"; /** - * Drops the TTL index on MaintenanceWindow.expiry and unsets the expiry field. + * Drops the TTL index on MaintenanceWindow.expiry and unsets the field on existing rows. * - * Earlier versions auto-set expiry = end for one-time maintenance windows. Combined with the - * TTL index ({ expires: "0s" }), this caused Mongo to silently delete completed one-time - * windows shortly after their end-date passed — including windows whose end-date was already - * in the past at creation time (e.g. timezone-mismatched payloads from automation tools). - * The fix stops writing expiry on create, and this migration removes the index and stale field - * so legacy rows are no longer at risk of being deleted by the TTL monitor. + * Why: a previous version copied `end` into `expiry` for one-time windows. Combined with the + * TTL index on `expiry`, this caused Mongo to silently delete rows whose `end` was in the past + * at creation time (a common symptom for automation tools sending non-UTC timestamps). The + * fix stops writing `expiry`; this migration removes the live exposure on existing installs. */ export async function dropMaintenanceWindowExpiryIndex(): Promise { const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex"; try { - const collection = MaintenanceWindowModel.collection; + logger.info({ + service: SERVICE_NAME, + message: "Starting drop of MaintenanceWindow.expiry TTL index", + }); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Mongo connection not initialized"); + } + const collection = db.collection("maintenancewindows"); + const indexes = await collection.indexes(); - const expiryIndex = indexes.find((idx) => idx.key && idx.key.expiry === 1); + const expiryIndex = indexes.find( + (idx) => idx.expireAfterSeconds !== undefined && idx.key && Object.keys(idx.key).length === 1 && idx.key.expiry === 1 + ); if (expiryIndex?.name) { await collection.dropIndex(expiryIndex.name); diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index 5d83b3fed..9bf775596 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -19,13 +19,23 @@ export const createMaintenanceWindowBodyValidation = z repeat: z.number().min(0, "Repeat must be a non-negative number"), }) .strict() - .refine((data) => new Date(data.end).getTime() > new Date(data.start).getTime(), { - message: "End must be after start", - path: ["end"], - }) - .refine((data) => data.repeat > 0 || new Date(data.end).getTime() > Date.now(), { - message: "End must be in the future for one-time maintenance windows", - path: ["end"], + .superRefine((data, ctx) => { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + if (data.repeat === 0 && end <= Date.now()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }); + } }); export const getMaintenanceWindowByIdParamValidation = z.object({ @@ -64,7 +74,16 @@ export const editMaintenanceByIdWindowBodyValidation = z durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), }) .strict() - .refine((data) => !data.start || !data.end || new Date(data.end).getTime() > new Date(data.start).getTime(), { - message: "End must be after start", - path: ["end"], + .superRefine((data, ctx) => { + if (data.start && data.end) { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + } }); From b462ba756272fbab3364fdf67c32d24db283b2c5 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 12:38:54 -0400 Subject: [PATCH 23/42] refactor(maintenance): centralize DurationUnit + sync OpenAPI spec Convention pass per docs/coding-conventions.md (rules 5/8/10/11): - Promote DurationUnit from a raw union type to a const tuple + derived type. - Mongoose schema enum imports DurationUnits instead of inlining the values. - Both validators import DurationUnits instead of inlining z.enum twice. - Drop stale 'expiry' entries from openapi.json (spec is now strict-mode-correct). --- server/openapi.json | 10 ---------- server/src/db/models/MaintenanceWindow.ts | 4 ++-- server/src/types/maintenanceWindow.ts | 3 ++- server/src/validation/maintenanceWindowValidation.ts | 5 +++-- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index f62062332..058e934b7 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -4657,11 +4657,6 @@ "repeat": { "type": "number", "minimum": 0 - }, - "expiry": { - "type": "string", - "nullable": true, - "format": "date-time" } }, "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"] @@ -5190,11 +5185,6 @@ "nullable": true, "format": "date-time" }, - "expiry": { - "type": "string", - "nullable": true, - "format": "date-time" - }, "monitors": { "type": "array", "items": { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index e3287ce7c..1fc9b5f4a 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { MaintenanceWindow } from "@/types/maintenanceWindow.js"; +import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js"; type MaintenanceWindowDocumentBase = Omit & { monitorId: Types.ObjectId; @@ -38,7 +38,7 @@ const MaintenanceWindowSchema = new Schema( }, durationUnit: { type: String, - enum: ["seconds", "minutes", "hours", "days"], + enum: DurationUnits, }, repeat: { type: Number, diff --git a/server/src/types/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts index bcbd075a1..950dc1713 100644 --- a/server/src/types/maintenanceWindow.ts +++ b/server/src/types/maintenanceWindow.ts @@ -1,4 +1,5 @@ -export type DurationUnit = "seconds" | "minutes" | "hours" | "days"; +export const DurationUnits = ["seconds", "minutes", "hours", "days"] as const; +export type DurationUnit = (typeof DurationUnits)[number]; export interface MaintenanceWindow { id: string; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index 9bf775596..bdd11df5f 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; +import { DurationUnits } from "@/types/maintenanceWindow.js"; const dateToString = z.coerce.date().transform((d) => d.toISOString()); @@ -13,7 +14,7 @@ export const createMaintenanceWindowBodyValidation = z name: z.string().min(1, "Name is required"), active: z.boolean().optional(), duration: z.number().min(1, "Duration is required"), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), + durationUnit: z.enum(DurationUnits), start: dateToString, end: dateToString, repeat: z.number().min(0, "Repeat must be a non-negative number"), @@ -71,7 +72,7 @@ export const editMaintenanceByIdWindowBodyValidation = z end: dateToString.optional(), monitors: z.array(z.string()).optional(), duration: z.number().optional(), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), + durationUnit: z.enum(DurationUnits).optional(), }) .strict() .superRefine((data, ctx) => { From 7906f2f615a8f28cabce0d573ae9b57fb04c7cf8 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:03 -0700 Subject: [PATCH 24/42] add stauts code label component --- .../design-elements/StatusCodeLabel.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 client/src/Components/design-elements/StatusCodeLabel.tsx diff --git a/client/src/Components/design-elements/StatusCodeLabel.tsx b/client/src/Components/design-elements/StatusCodeLabel.tsx new file mode 100644 index 000000000..b1500d42b --- /dev/null +++ b/client/src/Components/design-elements/StatusCodeLabel.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { + formatStatusCode, + getStatusCodeTooltip, + getStatusCodeValueType, +} from "@/Utils/statusCode"; +import { ValueLabel } from "./StatusLabel"; +import { Tooltip } from "./Tooltip"; + +export type StatusCodeLabelProps = { + statusCode: number | null | undefined; + message?: string | null; + fallback?: string; +}; + +export const StatusCodeLabel = ({ + statusCode, + message, + fallback = "N/A", +}: StatusCodeLabelProps) => { + const { t } = useTranslation(); + if (!statusCode) { + return <>{fallback}; + } + + const text = formatStatusCode(statusCode, t); + const tooltip = getStatusCodeTooltip(statusCode, message, t); + const body = ( + + ); + if (!tooltip) return body; + return {body}; +}; From 7c14d81a2428c99df99ac27517d5aa866bb165bf Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:22 -0700 Subject: [PATCH 25/42] refactor utils --- client/src/Utils/statusCode.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts index 75e4a9b19..33a5f3de1 100644 --- a/client/src/Utils/statusCode.ts +++ b/client/src/Utils/statusCode.ts @@ -49,10 +49,8 @@ export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSub export const formatStatusCode = ( code: number | null | undefined, - t: TranslateFn, - fallback: string = "N/A" + t: TranslateFn ): string => { - if (code == null) return fallback; if (isNetworkError(code)) return t("common.statusCode.down"); return String(code); }; From 0827d4f5285abbd80231d155be347f02b91807f5 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:50 -0700 Subject: [PATCH 26/42] add new component to barrel export --- client/src/Components/design-elements/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index f6e2771ec..557f3bfc2 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -25,3 +25,4 @@ export * from "./TextLink"; export * from "./NoticeBanner"; export * from "./OfflineBanner"; export * from "./Avatar"; +export * from "./StatusCodeLabel"; From 1d90b8d8d9032ce98b0e9450a598bbee0163304a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:54:58 -0700 Subject: [PATCH 27/42] refactor all call sites --- .../Pages/Checks/Components/ChecksTable.tsx | 32 ++-------- .../Incidents/Components/CardDetails.tsx | 21 ++---- .../Incidents/Components/IncidentTable.tsx | 39 ++++------- .../Uptime/Details/Components/ChecksTable.tsx | 64 +++++++++---------- .../Details/Components/GeoChecksTable.tsx | 59 ++++++++--------- 5 files changed, 78 insertions(+), 137 deletions(-) diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index bd7170572..31737f4bb 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -1,21 +1,14 @@ import { Table, Pagination, - ValueLabel, StatusLabel, - Tooltip, + StatusCodeLabel, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; - import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { - formatStatusCode, - getStatusCodeTooltip, - getStatusCodeValueType, -} from "@/Utils/statusCode"; import type { Check } from "@/Types/Check"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; @@ -40,7 +33,7 @@ export const ChecksTable = ({ const { t } = useTranslation(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const getHeaders = (t: (key: string) => string, uiTimezone: string) => { + const getHeaders = () => { const headers: Header[] = [ { id: "monitorName", @@ -74,23 +67,12 @@ export const ChecksTable = ({ id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - const code = row.statusCode; - if (!code) return "N/A"; - const text = formatStatusCode(code, t); - const tooltip = getStatusCodeTooltip(code, row.message, t); - const label = ( - ); - return tooltip ? ( - - {label} - - ) : ( - label - ); }, }, { @@ -104,7 +86,7 @@ export const ChecksTable = ({ return headers; }; - const headers = getHeaders(t, uiTimezone); + const headers = getHeaders(); const handlePageChange = ( _e: React.MouseEvent | null, diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index 2cd8fbde4..b2d72d08c 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Grid from "@mui/material/Grid"; import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; -import { BaseBox, ValueLabel, Tooltip } from "@/Components/design-elements"; +import { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useTranslation } from "react-i18next"; @@ -13,7 +13,6 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { getIncidentsDuration } from "@/Pages/Incidents/utils"; -import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; interface CardDetailsProps { incident: Incident | null; @@ -48,13 +47,6 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { return null; } - const statusCodeText = formatStatusCode(incident.statusCode, t); - const statusCodeTooltip = getStatusCodeTooltip( - incident.statusCode, - incident.message, - t - ); - return ( { - {statusCodeTooltip ? ( - - {statusCodeText} - - ) : ( - statusCodeText - )} + diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index c66cdb0ed..399f4d8f8 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -1,4 +1,4 @@ -import { Table, ValueLabel, Tooltip } from "@/Components/design-elements"; +import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table"; import { ActionsMenu } from "@/Components/actions-menu"; @@ -14,11 +14,6 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; import { getMonitorPath } from "@/Utils/MonitorUtils"; -import { - formatStatusCode, - getStatusCodeTooltip, - getStatusCodeValueType, -} from "@/Utils/statusCode"; interface IncidentsTableProps { title?: string; @@ -139,13 +134,12 @@ export const IncidentsTable = ({ return ( {row.resolutionType} @@ -158,23 +152,12 @@ export const IncidentsTable = ({ id: "statusCode", content: t("pages.incidents.table.headers.statusCode"), render: (row) => { - const code = row.statusCode; - if (!code) return "N/A"; - const text = formatStatusCode(code, t); - const tooltip = getStatusCodeTooltip(code, row.message, t); - const label = ( - ); - return tooltip ? ( - - {label} - - ) : ( - label - ); }, }, { diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 0ab9a7a7c..b100c28c4 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -1,4 +1,9 @@ -import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + StatusCodeLabel, +} from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -6,11 +11,28 @@ import type { Check } from "@/Types/Check"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -const getHeaders = (t: (key: string) => string, uiTimezone: string) => { +export const ChecksTable = ({ + checks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + checks: Check[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const headers: Header[] = [ { id: "status", @@ -37,41 +59,15 @@ const getHeaders = (t: (key: string) => string, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - if (!row.statusCode) return "N/A"; - const text = formatStatusCode(row.statusCode, t); - const tooltip = getStatusCodeTooltip(row.statusCode, row.message, t); - return tooltip ? ( - - {text} - - ) : ( - text + return ( + ); }, }, ]; - return headers; -}; - -export const ChecksTable = ({ - checks, - count, - page, - setPage, - rowsPerPage, - setRowsPerPage, -}: { - checks: Check[]; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const headers = getHeaders(t, uiTimezone); const handlePageChange = ( _e: React.MouseEvent | null, diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index ae56e037e..373e09da2 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -1,15 +1,36 @@ -import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + StatusCodeLabel, +} from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; import prettyMilliseconds from "pretty-ms"; -const getHeaders = (t: (key: string) => string, uiTimezone: string) => { +export const GeoChecksTable = ({ + geoChecks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + geoChecks: FlatGeoCheck[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const headers: Header[] = [ { id: "status", @@ -30,16 +51,7 @@ const getHeaders = (t: (key: string) => string, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - if (!row.statusCode) return "N/A"; - const text = formatStatusCode(row.statusCode, t); - const tooltip = getStatusCodeTooltip(row.statusCode, undefined, t); - return tooltip ? ( - - {text} - - ) : ( - text - ); + return ; }, }, { @@ -61,27 +73,6 @@ const getHeaders = (t: (key: string) => string, uiTimezone: string) => { }, }, ]; - return headers; -}; - -export const GeoChecksTable = ({ - geoChecks, - count, - page, - setPage, - rowsPerPage, - setRowsPerPage, -}: { - geoChecks: FlatGeoCheck[]; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { - const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const headers = getHeaders(t, uiTimezone); const handlePageChange = ( _e: React.MouseEvent | null, From 713c3ea34fe6c7629af5db46a75200cee628b990 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 09:56:56 -0700 Subject: [PATCH 28/42] add span for ref --- client/src/Components/design-elements/StatusCodeLabel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/StatusCodeLabel.tsx b/client/src/Components/design-elements/StatusCodeLabel.tsx index b1500d42b..f088550f0 100644 --- a/client/src/Components/design-elements/StatusCodeLabel.tsx +++ b/client/src/Components/design-elements/StatusCodeLabel.tsx @@ -32,5 +32,9 @@ export const StatusCodeLabel = ({ /> ); if (!tooltip) return body; - return {body}; + return ( + + {body} + + ); }; From c45bcfaa2fdd323741e45ccdb3111078c2ff1675 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 13:38:10 -0400 Subject: [PATCH 29/42] revert(maintenance): keep one-time TTL cleanup, narrow PR to validator-only Per review feedback: auto-deleting completed one-time maintenance windows is intentional cleanup, not a bug. The actual bug is past-dated end values being accepted at creation time, which the validator refines now reject up front. Reverts: - Restore expiry field + TTL index on MaintenanceWindow schema - Restore repository's auto-set of expiry = end for repeat=0 - Drop migration 0007 (no longer needed) - Restore expiry properties in openapi.json - Restore expiry in create/edit validators (still optional) Kept from this PR: - .strict() on create/edit body schemas - end > start refine on create/edit - end > now refine on create when repeat=0 (the real fix) - DurationUnits const tuple + reuse (rules 5/8/10) - Client no longer sends redundant expiry (server still derives it) --- server/openapi.json | 10 ++++ .../0007_dropMaintenanceWindowExpiryIndex.ts | 58 ------------------- server/src/db/migration/index.ts | 2 - server/src/db/models/MaintenanceWindow.ts | 5 ++ .../MongoMaintenanceWindowsRepository.ts | 5 ++ .../validation/maintenanceWindowValidation.ts | 2 + 6 files changed, 22 insertions(+), 60 deletions(-) delete mode 100644 server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts diff --git a/server/openapi.json b/server/openapi.json index 058e934b7..f62062332 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -4657,6 +4657,11 @@ "repeat": { "type": "number", "minimum": 0 + }, + "expiry": { + "type": "string", + "nullable": true, + "format": "date-time" } }, "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"] @@ -5185,6 +5190,11 @@ "nullable": true, "format": "date-time" }, + "expiry": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "monitors": { "type": "array", "items": { diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts deleted file mode 100644 index 38e55c0d7..000000000 --- a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts +++ /dev/null @@ -1,58 +0,0 @@ -import mongoose from "mongoose"; -import { logger } from "@/utils/logger.js"; - -/** - * Drops the TTL index on MaintenanceWindow.expiry and unsets the field on existing rows. - * - * Why: a previous version copied `end` into `expiry` for one-time windows. Combined with the - * TTL index on `expiry`, this caused Mongo to silently delete rows whose `end` was in the past - * at creation time (a common symptom for automation tools sending non-UTC timestamps). The - * fix stops writing `expiry`; this migration removes the live exposure on existing installs. - */ -export async function dropMaintenanceWindowExpiryIndex(): Promise { - const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex"; - - try { - logger.info({ - service: SERVICE_NAME, - message: "Starting drop of MaintenanceWindow.expiry TTL index", - }); - - const db = mongoose.connection.db; - if (!db) { - throw new Error("Mongo connection not initialized"); - } - const collection = db.collection("maintenancewindows"); - - const indexes = await collection.indexes(); - const expiryIndex = indexes.find( - (idx) => idx.expireAfterSeconds !== undefined && idx.key && Object.keys(idx.key).length === 1 && idx.key.expiry === 1 - ); - - if (expiryIndex?.name) { - await collection.dropIndex(expiryIndex.name); - logger.info({ - service: SERVICE_NAME, - message: `Dropped TTL index ${expiryIndex.name} on MaintenanceWindow.expiry`, - }); - } else { - logger.info({ - service: SERVICE_NAME, - message: "No TTL index found on MaintenanceWindow.expiry", - }); - } - - const result = await collection.updateMany({ expiry: { $exists: true } }, { $unset: { expiry: "" } }); - logger.info({ - service: SERVICE_NAME, - message: `Unset expiry on ${result.modifiedCount} maintenance windows`, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error({ - service: SERVICE_NAME, - message: `Error dropping expiry index: ${errorMessage}`, - }); - throw error; - } -} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index fcf0cf1fb..5b74a96fc 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -5,7 +5,6 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds. import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; -import { dropMaintenanceWindowExpiryIndex } from "./0007_dropMaintenanceWindowExpiryIndex.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -20,7 +19,6 @@ const migrations: MigrationEntry[] = [ { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, - { name: "0007_dropMaintenanceWindowExpiryIndex", execute: dropMaintenanceWindowExpiryIndex }, ]; const runMigrations = async (logger?: ILogger) => { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 1fc9b5f4a..703c144c8 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -6,6 +6,7 @@ type MaintenanceWindowDocumentBase = Omit( end: { type: Date, }, + expiry: { + type: Date, + index: { expires: "0s" }, + }, }, { timestamps: true, diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index 0cdda805e..ad96328c5 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -45,6 +45,11 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository create = async (data: Partial): Promise => { const maintenanceWindow = new MaintenanceWindowModel(data); + + // If the maintenance window is a one time window, set the expiry to the end date + if (maintenanceWindow.repeat === 0) { + maintenanceWindow.expiry = maintenanceWindow.end; + } const result = await maintenanceWindow.save(); return this.toEntity(result); }; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index bdd11df5f..c53e39b20 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -18,6 +18,7 @@ export const createMaintenanceWindowBodyValidation = z start: dateToString, end: dateToString, repeat: z.number().min(0, "Repeat must be a non-negative number"), + expiry: dateToString.optional(), }) .strict() .superRefine((data, ctx) => { @@ -70,6 +71,7 @@ export const editMaintenanceByIdWindowBodyValidation = z repeat: z.number().optional(), start: dateToString.optional(), end: dateToString.optional(), + expiry: dateToString.optional(), monitors: z.array(z.string()).optional(), duration: z.number().optional(), durationUnit: z.enum(DurationUnits).optional(), From 60cded78a646505d1f9dc3bf7ba04a89dc341598 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 May 2026 11:43:00 -0700 Subject: [PATCH 30/42] validation tests --- server/jest.config.ts | 1 + .../maintenanceWindowValidation.test.ts | 171 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 server/test/unit/validation/maintenanceWindowValidation.test.ts diff --git a/server/jest.config.ts b/server/jest.config.ts index be7d359b1..1faf98181 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", + "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["/test/**/*.test.ts"], setupFilesAfterEnv: [], diff --git a/server/test/unit/validation/maintenanceWindowValidation.test.ts b/server/test/unit/validation/maintenanceWindowValidation.test.ts new file mode 100644 index 000000000..dfede3670 --- /dev/null +++ b/server/test/unit/validation/maintenanceWindowValidation.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, jest, afterEach } from "@jest/globals"; +import { + createMaintenanceWindowBodyValidation, + editMaintenanceByIdWindowBodyValidation, +} from "../../../src/validation/maintenanceWindowValidation.ts"; + +const FROZEN_NOW = new Date("2026-05-07T12:00:00Z").getTime(); + +const baseCreateBody = (overrides: Record = {}) => ({ + monitors: ["mon-1"], + name: "Scheduled Maintenance", + duration: 60, + durationUnit: "minutes", + start: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(), + repeat: 0, + ...overrides, +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe("createMaintenanceWindowBodyValidation", () => { + it("accepts a valid one-time window with end in the future", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody()); + expect(result.success).toBe(true); + }); + + it("accepts a recurring window even when end is in the past", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + repeat: 1, + start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(true); + }); + + it("rejects when end is before start", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("rejects when end equals start", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const sameInstant = new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ start: sameInstant, end: sameInstant })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("rejects a one-time window with end in the past", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }), + ]) + ); + } + }); + + it("rejects a one-time window with end exactly equal to now", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }), + ]) + ); + } + }); + + it("rejects unknown fields under .strict()", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ teamId: "team-1" })); + expect(result.success).toBe(false); + }); + + it("rejects an invalid durationUnit value", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ durationUnit: "weeks" })); + expect(result.success).toBe(false); + }); + + it("rejects a missing required field", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const { name: _name, ...withoutName } = baseCreateBody(); + const result = createMaintenanceWindowBodyValidation.safeParse(withoutName); + expect(result.success).toBe(false); + }); +}); + +describe("editMaintenanceByIdWindowBodyValidation", () => { + it("accepts an empty body (all fields optional)", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({}); + expect(result.success).toBe(true); + }); + + it("accepts a partial update with only active toggled", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ active: false }); + expect(result.success).toBe(true); + }); + + it("rejects unknown fields under .strict()", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + active: false, + id: "mw-1", + }); + expect(result.success).toBe(false); + }); + + it("rejects when start and end are both present and end <= start", () => { + const start = new Date("2026-05-08T10:00:00Z").toISOString(); + const end = new Date("2026-05-08T09:00:00Z").toISOString(); + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ start, end }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("does not apply the start/end ordering rule when only end is provided", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + end: new Date("2020-01-01T00:00:00Z").toISOString(), + }); + expect(result.success).toBe(true); + }); + + it("does not apply the start/end ordering rule when only start is provided", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + start: new Date("2099-01-01T00:00:00Z").toISOString(), + }); + expect(result.success).toBe(true); + }); +}); From 0d720710194a2fe3d1cb13ba5bbac49fbf5c0808 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:11 -0700 Subject: [PATCH 31/42] add dns types --- server/src/types/monitor.ts | 4 +++- server/src/types/network.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 2c2f9c08f..6e2747e1a 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -3,7 +3,7 @@ export type { CheckSnapshot } from "@/types/check.js"; import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js"; export type { GeoContinent } from "@/types/geoCheck.js"; -export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const; +export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; @@ -54,6 +54,8 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dnsServer?: string; + dnsRecordType?: string; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 6c7172724..d87b62395 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -116,6 +116,14 @@ export interface WebSocketStatusPayload { connected: boolean; } +export interface DNSStatusPayload { + hostname: string; + dnsServer: string; + recordType: string; + resolved: boolean; + results: unknown; +} + export interface MonitorPayloadMap { ping: PingStatusPayload; http: HttpStatusPayload; From c6d86d78b852bc20f01d9577031e74dfcceae1d1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:25 -0700 Subject: [PATCH 32/42] Add DNS provider --- .../infrastructure/network/DNSProvider.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 server/src/service/infrastructure/network/DNSProvider.ts diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts new file mode 100644 index 000000000..c2cf21705 --- /dev/null +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -0,0 +1,96 @@ +import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { Resolver } from "dns/promises"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { AppError } from "@/utils/AppError.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; + +const SERVICE_NAME = "DNSProvider"; + +export class DNSProvider implements IStatusProvider { + readonly type = "dns"; + constructor(private resolver: Resolver) {} + supports(type: MonitorType) { + return type === "dns"; + } + + async handle(monitor: Monitor): Promise> { + try { + const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor; + if (!dnsServer) { + throw new Error("DNS server is required for DNS monitoring"); + } + + const resolver = new Resolver(); + resolver.setServers([dnsServer]); + + const { + response: results, + responseTime, + error, + } = await timeRequest(() => { + switch (dnsRecordType.toUpperCase()) { + case "A": + return resolver.resolve4(hostname); + case "AAAA": + return resolver.resolve6(hostname); + case "CNAME": + return resolver.resolveCname(hostname); + case "MX": + return resolver.resolveMx(hostname); + case "TXT": + return resolver.resolveTxt(hostname); + case "NS": + return resolver.resolveNs(hostname); + default: + return resolver.resolve(hostname, dnsRecordType); + } + }); + + if (error) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: error instanceof Error ? error.message : String(error), + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: false, + results, + }, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: true, + code: 200, + message: "Success", + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: true, + results, + }, + }; + } catch (error: unknown) { + const originalMessage = error instanceof Error ? error.message : String(error); + throw new AppError({ + message: originalMessage || "Error performing DNS check", + status: 500, + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url }, + }); + } + } +} From fda9d9edfe2f4a6c58305beb80d0af7b6fb3d533 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 8 May 2026 08:59:31 -0700 Subject: [PATCH 33/42] add tests --- .../providers/network/dnsProvider.test.ts | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 server/test/unit/providers/network/dnsProvider.test.ts diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts new file mode 100644 index 000000000..92c874836 --- /dev/null +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockResolverInstance = { + setServers: jest.fn(), + resolve4: jest.fn<() => Promise>(), + resolve6: jest.fn<() => Promise>(), + resolveCname: jest.fn<() => Promise>(), + resolveMx: jest.fn<() => Promise>(), + resolveTxt: jest.fn<() => Promise>(), + resolveNs: jest.fn<() => Promise>(), + resolve: jest.fn<() => Promise>(), +}; + +const ResolverMock = jest.fn().mockImplementation(() => mockResolverInstance); + +jest.unstable_mockModule("dns/promises", () => ({ + Resolver: ResolverMock, +})); + +const { DNSProvider } = await import("../../../../src/service/infrastructure/network/DNSProvider.ts"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "dns", + url: "example.com", + dnsServer: "8.8.8.8", + dnsRecordType: "A", + ...overrides, + }) as Monitor; + +beforeEach(() => { + ResolverMock.mockClear(); + mockResolverInstance.setServers.mockClear(); + mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]); + mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]); + mockResolverInstance.resolveCname.mockReset().mockResolvedValue(["alias.example.com"]); + mockResolverInstance.resolveMx.mockReset().mockResolvedValue([{ exchange: "mx.example.com", priority: 10 }]); + mockResolverInstance.resolveTxt.mockReset().mockResolvedValue([["v=spf1"]]); + mockResolverInstance.resolveNs.mockReset().mockResolvedValue(["ns.example.com"]); + mockResolverInstance.resolve.mockReset().mockResolvedValue(["something"]); +}); + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("DNSProvider", { + create: () => new DNSProvider({} as any), + supportedType: "dns", + unsupportedType: "ping", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DNSProvider", () => { + it("returns success with results and payload on resolve", async () => { + mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: true, + code: 200, + message: "Success", + payload: { + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: true, + results: ["1.2.3.4"], + }, + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("calls setServers with the configured dnsServer", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); + expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); + }); + + it("defaults to A record when dnsRecordType is not provided", async () => { + const provider = new DNSProvider({} as any); + const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + expect(result.payload?.recordType).toBe("A"); + }); + + it("dispatches AAAA records to resolve6", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); + expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches CNAME records to resolveCname", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); + expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches MX records to resolveMx", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "MX" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches TXT records to resolveTxt", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); + expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches NS records to resolveNs", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "NS" })); + expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); + }); + + it("falls back to generic resolve for unrecognized record types", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); + expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); + }); + + it("treats record type case-insensitively", async () => { + const provider = new DNSProvider({} as any); + await provider.handle(makeMonitor({ dnsRecordType: "mx" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { + mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: false, + code: NETWORK_ERROR, + message: "ENOTFOUND", + payload: expect.objectContaining({ + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: false, + results: null, + }), + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("stringifies non-Error rejections in failure response", async () => { + mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); + const provider = new DNSProvider({} as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("DNS bombed"); + }); + + it("throws AppError when dnsServer is missing", async () => { + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); + }); + + it("falls back to default message when underlying error has empty message", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw new Error(""); + }); + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); + }); + + it("stringifies non-Error throws from outside timeRequest", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw 42; + }); + const provider = new DNSProvider({} as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); + }); +}); From 0797008ac9d9dc12f73f5a85aa1c268ca4ab8d42 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:43:02 -0700 Subject: [PATCH 34/42] add dns record type --- server/src/types/monitor.ts | 6 +++++- server/src/types/network.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 6e2747e1a..09797f2ca 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -14,6 +14,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number]; export const MonitorMatchMethods = ["equal", "include", "regex"] as const; export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; + +export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; +export type DnsRecordType = (typeof DnsRecordTypes)[number]; + export const MAX_RECENT_CHECKS = 50; export interface Monitor { @@ -55,7 +59,7 @@ export interface Monitor { geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; dnsServer?: string; - dnsRecordType?: string; + dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index d87b62395..3269806d8 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -134,6 +134,7 @@ export interface MonitorPayloadMap { game: GameStatusPayload; grpc: GrpcStatusPayload; websocket: WebSocketStatusPayload; + dns: DNSStatusPayload; unknown: unknown; } From 06ef850577779cb2bd5f917fc445b92624bab647 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:43:33 -0700 Subject: [PATCH 35/42] update validation to allow for dnsServer and dnsRecord --- server/src/validation/monitorValidation.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 57099e7ff..0960e8099 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -78,6 +78,8 @@ export const createMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), }); export const editMonitorBodyValidation = z.object({ @@ -107,6 +109,8 @@ export const editMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), }); export const pauseMonitorParamValidation = z.object({ @@ -160,6 +164,8 @@ const importedMonitorSchema = z.object({ geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), }); @@ -211,6 +217,8 @@ export const monitorResponseSchema = z geoCheckEnabled: z.boolean(), geoCheckLocations: z.array(z.enum(GeoContinents)), geoCheckInterval: z.number(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), teamId: z.string(), userId: z.string(), createdAt: z.string(), From cb9cc101c09a96a9ba9e95994c393f4bdf01b7ce Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:18 -0700 Subject: [PATCH 36/42] add dnsServer and dnsRecordType to monitor model --- server/src/db/models/Monitor.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad..e1a2f4f91 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; -import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; import type { CheckAudits, CheckCaptureInfo, @@ -351,6 +351,13 @@ const MonitorSchema = new Schema( type: Number, default: 300000, }, + dnsServer: { + type: String, + }, + dnsRecordType: { + type: String, + enum: DnsRecordTypes, + }, recentChecks: { type: [checkSnapshotSchema], default: [], From 2a7bde95749b4ba841d3dec4584ecbe492d26db1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:44 -0700 Subject: [PATCH 37/42] update toEntity methods to extract dnsServer and dnsRecord --- server/src/repositories/monitors/MongoMonitorsRepository.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index bd6d506d5..93846aeec 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -418,6 +418,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -477,6 +479,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; From 0c36c8e6251d1ea193ba407b12f6e9bfec205875 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:44:58 -0700 Subject: [PATCH 38/42] tests --- server/jest.config.ts | 1 + server/src/config/services.ts | 4 + .../unit/validation/monitorValidation.test.ts | 86 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 server/test/unit/validation/monitorValidation.test.ts diff --git a/server/jest.config.ts b/server/jest.config.ts index be7d359b1..1faf98181 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", + "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["/test/**/*.test.ts"], setupFilesAfterEnv: [], diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b6cbb3a30..c00f91c72 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -64,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js"; import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js"; +import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js"; // Third-party +import { Resolver } from "dns/promises"; import axios from "axios"; import got from "got"; import ping from "ping"; @@ -226,6 +228,7 @@ export const initializeServices = async ({ const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); const webSocketProvider = new WebSocketProvider(WebSocket); + const dnsProvider = new DNSProvider(new Resolver()); const networkService = new NetworkService(axios, logger, [ pingProvider, @@ -237,6 +240,7 @@ export const initializeServices = async ({ gameProvider, grpcProvider, webSocketProvider, + dnsProvider, ]); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts new file mode 100644 index 000000000..5bd178f51 --- /dev/null +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "@jest/globals"; +import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts"; + +const baseDnsBody = { + name: "DNS check", + type: "dns" as const, + url: "example.com", +}; + +describe("monitorValidation — DNS fields", () => { + describe("createMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on a DNS monitor", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "A", + }); + + expect(parsed.dnsServer).toBe("8.8.8.8"); + expect(parsed.dnsRecordType).toBe("A"); + }); + + it("accepts every supported DNS record type", () => { + for (const recordType of ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const) { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "1.1.1.1", + dnsRecordType: recordType, + }); + expect(parsed.dnsRecordType).toBe(recordType); + } + }); + + it("rejects unknown DNS record types", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "BOGUS", + }) + ).toThrow(); + }); + + it("treats DNS fields as optional (other monitor types still validate)", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com", + }); + + expect(parsed.dnsServer).toBeUndefined(); + expect(parsed.dnsRecordType).toBeUndefined(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + dnsServer: "1.1.1.1", + dnsRecordType: "MX", + }); + + expect(parsed.dnsServer).toBe("1.1.1.1"); + expect(parsed.dnsRecordType).toBe("MX"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("retains dnsServer and dnsRecordType on imported DNS monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported DNS", + type: "dns", + url: "example.com", + dnsServer: "8.8.4.4", + dnsRecordType: "TXT", + }, + ], + }); + + expect(parsed.monitors[0].dnsServer).toBe("8.8.4.4"); + expect(parsed.monitors[0].dnsRecordType).toBe("TXT"); + }); + }); +}); From 172d36ba1d9706849383788a9c2f4875088d8614 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:56:47 -0700 Subject: [PATCH 39/42] use injected Resolver --- .../infrastructure/network/DNSProvider.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index c2cf21705..4ad212e62 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -21,8 +21,7 @@ export class DNSProvider implements IStatusProvider { throw new Error("DNS server is required for DNS monitoring"); } - const resolver = new Resolver(); - resolver.setServers([dnsServer]); + this.resolver.setServers([dnsServer]); const { response: results, @@ -31,19 +30,19 @@ export class DNSProvider implements IStatusProvider { } = await timeRequest(() => { switch (dnsRecordType.toUpperCase()) { case "A": - return resolver.resolve4(hostname); + return this.resolver.resolve4(hostname); case "AAAA": - return resolver.resolve6(hostname); + return this.resolver.resolve6(hostname); case "CNAME": - return resolver.resolveCname(hostname); + return this.resolver.resolveCname(hostname); case "MX": - return resolver.resolveMx(hostname); + return this.resolver.resolveMx(hostname); case "TXT": - return resolver.resolveTxt(hostname); + return this.resolver.resolveTxt(hostname); case "NS": - return resolver.resolveNs(hostname); + return this.resolver.resolveNs(hostname); default: - return resolver.resolve(hostname, dnsRecordType); + return this.resolver.resolve(hostname, dnsRecordType); } }); From 4b4fae91c287efdab5d5054bd5919cf90a77c3b5 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 12:56:56 -0700 Subject: [PATCH 40/42] use enums for validation --- server/src/types/network.ts | 3 ++- server/src/validation/monitorValidation.ts | 14 ++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 3269806d8..19f063cc6 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -8,6 +8,7 @@ import type { CheckNetworkInterfaceInfo, GotTimings, ILighthouseAudit, + DnsRecordType, Monitor, MonitorMatchMethod, MonitorType, @@ -119,7 +120,7 @@ export interface WebSocketStatusPayload { export interface DNSStatusPayload { hostname: string; dnsServer: string; - recordType: string; + recordType: DnsRecordType; resolved: boolean; results: unknown; } diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 0960e8099..882c7353a 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -20,12 +20,7 @@ export const getMonitorByIdQueryValidation = z.object({ export const getMonitorsByTeamIdParamValidation = z.object({}); export const getMonitorsByTeamIdQueryValidation = z.object({ - type: z - .union([ - 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(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), filter: z.union([z.string(), z.literal("")]).optional(), }); @@ -36,12 +31,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({ filter: z.union([z.string(), z.literal("")]).optional(), field: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), - type: z - .union([ - 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(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), explain: booleanCoercion.optional(), }); From 0f662e1a65a213f5b1a82e94ba29cf4752bfdda3 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 13:04:18 -0700 Subject: [PATCH 41/42] update tests --- .../providers/network/dnsProvider.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index 92c874836..ba38fa5d2 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -52,7 +52,7 @@ beforeEach(() => { // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DNSProvider", { - create: () => new DNSProvider({} as any), + create: () => new DNSProvider(mockResolverInstance as any), supportedType: "dns", unsupportedType: "ping", makeMonitor: () => makeMonitor(), @@ -63,7 +63,7 @@ testStatusProviderContract("DNSProvider", { describe("DNSProvider", () => { it("returns success with results and payload on resolve", async () => { mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -88,63 +88,63 @@ describe("DNSProvider", () => { }); it("calls setServers with the configured dnsServer", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); }); it("defaults to A record when dnsRecordType is not provided", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); expect(result.payload?.recordType).toBe("A"); }); it("dispatches AAAA records to resolve6", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); }); it("dispatches CNAME records to resolveCname", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); }); it("dispatches MX records to resolveMx", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "MX" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("dispatches TXT records to resolveTxt", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); }); it("dispatches NS records to resolveNs", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "NS" })); expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); }); it("falls back to generic resolve for unrecognized record types", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); }); it("treats record type case-insensitively", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await provider.handle(makeMonitor({ dnsRecordType: "mx" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -170,7 +170,7 @@ describe("DNSProvider", () => { it("stringifies non-Error rejections in failure response", async () => { mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); const result = await provider.handle(makeMonitor()); @@ -179,7 +179,7 @@ describe("DNSProvider", () => { }); it("throws AppError when dnsServer is missing", async () => { - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); }); @@ -188,7 +188,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw new Error(""); }); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); @@ -197,7 +197,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; }); - const provider = new DNSProvider({} as any); + const provider = new DNSProvider(mockResolverInstance as any); await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); }); From fc711e8e24333dc272e0ceba11d50803f455c5ee Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 May 2026 13:15:45 -0700 Subject: [PATCH 42/42] per call resolver --- server/src/config/services.ts | 2 +- .../infrastructure/network/DNSProvider.ts | 21 +++++----- .../providers/network/dnsProvider.test.ts | 42 ++++++++----------- 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index c00f91c72..309f55242 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -228,7 +228,7 @@ export const initializeServices = async ({ const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); const webSocketProvider = new WebSocketProvider(WebSocket); - const dnsProvider = new DNSProvider(new Resolver()); + const dnsProvider = new DNSProvider(() => new Resolver()); const networkService = new NetworkService(axios, logger, [ pingProvider, diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index 4ad212e62..64e473e88 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -1,6 +1,6 @@ import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; -import { Resolver } from "dns/promises"; +import type { Resolver } from "dns/promises"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; @@ -9,7 +9,7 @@ const SERVICE_NAME = "DNSProvider"; export class DNSProvider implements IStatusProvider { readonly type = "dns"; - constructor(private resolver: Resolver) {} + constructor(private createResolver: () => Resolver) {} supports(type: MonitorType) { return type === "dns"; } @@ -21,7 +21,8 @@ export class DNSProvider implements IStatusProvider { throw new Error("DNS server is required for DNS monitoring"); } - this.resolver.setServers([dnsServer]); + const resolver = this.createResolver(); + resolver.setServers([dnsServer]); const { response: results, @@ -30,19 +31,19 @@ export class DNSProvider implements IStatusProvider { } = await timeRequest(() => { switch (dnsRecordType.toUpperCase()) { case "A": - return this.resolver.resolve4(hostname); + return resolver.resolve4(hostname); case "AAAA": - return this.resolver.resolve6(hostname); + return resolver.resolve6(hostname); case "CNAME": - return this.resolver.resolveCname(hostname); + return resolver.resolveCname(hostname); case "MX": - return this.resolver.resolveMx(hostname); + return resolver.resolveMx(hostname); case "TXT": - return this.resolver.resolveTxt(hostname); + return resolver.resolveTxt(hostname); case "NS": - return this.resolver.resolveNs(hostname); + return resolver.resolveNs(hostname); default: - return this.resolver.resolve(hostname, dnsRecordType); + return resolver.resolve(hostname, dnsRecordType); } }); diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index ba38fa5d2..3fcea5113 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; import type { Monitor } from "../../../../src/types/index.ts"; +import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts"; // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -16,13 +17,7 @@ const mockResolverInstance = { resolve: jest.fn<() => Promise>(), }; -const ResolverMock = jest.fn().mockImplementation(() => mockResolverInstance); - -jest.unstable_mockModule("dns/promises", () => ({ - Resolver: ResolverMock, -})); - -const { DNSProvider } = await import("../../../../src/service/infrastructure/network/DNSProvider.ts"); +const createResolver = () => mockResolverInstance as any; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -38,7 +33,6 @@ const makeMonitor = (overrides?: Partial): Monitor => }) as Monitor; beforeEach(() => { - ResolverMock.mockClear(); mockResolverInstance.setServers.mockClear(); mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]); mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]); @@ -52,7 +46,7 @@ beforeEach(() => { // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DNSProvider", { - create: () => new DNSProvider(mockResolverInstance as any), + create: () => new DNSProvider(createResolver), supportedType: "dns", unsupportedType: "ping", makeMonitor: () => makeMonitor(), @@ -63,7 +57,7 @@ testStatusProviderContract("DNSProvider", { describe("DNSProvider", () => { it("returns success with results and payload on resolve", async () => { mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -88,63 +82,63 @@ describe("DNSProvider", () => { }); it("calls setServers with the configured dnsServer", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); }); it("defaults to A record when dnsRecordType is not provided", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); expect(result.payload?.recordType).toBe("A"); }); it("dispatches AAAA records to resolve6", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); }); it("dispatches CNAME records to resolveCname", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); }); it("dispatches MX records to resolveMx", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "MX" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("dispatches TXT records to resolveTxt", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); }); it("dispatches NS records to resolveNs", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "NS" })); expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); }); it("falls back to generic resolve for unrecognized record types", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); }); it("treats record type case-insensitively", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await provider.handle(makeMonitor({ dnsRecordType: "mx" })); expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); }); it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -170,7 +164,7 @@ describe("DNSProvider", () => { it("stringifies non-Error rejections in failure response", async () => { mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); const result = await provider.handle(makeMonitor()); @@ -179,7 +173,7 @@ describe("DNSProvider", () => { }); it("throws AppError when dnsServer is missing", async () => { - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); }); @@ -188,7 +182,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw new Error(""); }); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); @@ -197,7 +191,7 @@ describe("DNSProvider", () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; }); - const provider = new DNSProvider(mockResolverInstance as any); + const provider = new DNSProvider(createResolver); await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); });