From 094def117ad45c6b8b87df0d10588b5adc4d7f1e Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:42:51 +0530 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 387cb35494406414979db3ce29d984fa2df03d61 Mon Sep 17 00:00:00 2001 From: harsh-aghara Date: Tue, 5 May 2026 17:06:48 +0530 Subject: [PATCH 07/11] 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 08/11] 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 09/11] 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 10/11] 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 11/11] 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,