feat/bulk pause resume#3626
Conversation
- 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 bluewave-labs#3602
- 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 bluewave-labs#3602
… 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 bluewave-labs#3602
ajhollid
left a comment
There was a problem hiding this comment.
Hey @harsh-aghara ,
Thanks for submitting this PR.
I think visually it looks great, however there is going to be a collision with this PR.
I suggest you collaborate with the author @ashu130698 and @gorkem-bwl to see how to resolve this with regard to both UI/UX and code.
Thanks for working on the project!
We need to convert this to a pill at the bottom of the page. Also can you rework on the strings? We need to be picky about the message.
This shouldn't read like this. If there are 0 monitors to be resumed, this line should read better. Also check singularity/plurality and refrain from using (s). @ajhollid when those fixes are there I believe this should go first as it's a bit more cleaner than the other PR, and 3448 should be rebased/worked on after letting this one ine. |
|
Thanks @gorkem-bwl |
…ldren 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 bluewave-labs#3602
…ection 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 bluewave-labs#3602
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 bluewave-labs#3602
|
Hey @ajhollid @gorkem-bwl, I just pushed an update:
|
|
Hey @harsh-aghara, the new modular BulkActionsBar and generic selection hooks look awesome! Really clean implementation. I'm tracking this PR and will rebase my bulk notifications UI (#3448) to hook directly into your new components as soon as this gets merged. Great work! |
|
@ajhollid we should be good to merge this hopefully and move on to the other PR 👍 |
|
This PR is very large, 1k line PRs are generally not accepted. I'll take a look tomorrow, but this should be properly rescoped into frontend and backend functionality PRs as per project standard I see some of the diff is due to translations, I'll take that into account during final review, but in general PRs should be much smaller and focused |
|
@ajhollid I understand this pr is too long. Should I split it into frontend and backend prs before your review, or would you prefer to review as it is? |
|
Let me take a look at it tomorrow, if I can reasonably review it I will 👌 In future though yes, backend functionality should be implemented first with tests. Then once functionality is confirmed a UI PR can be made and evaluated on its own merit. Will check in with you tomorrow! |
| await post(`/monitors/pause/${monitor.id}`, {}); | ||
| await post(`/monitors/pause/${monitor.id}`, {} as Record<string, never>); |
There was a problem hiding this comment.
No casting please, lets use correct types. Casting removes type safety and defeats the purpose of TypeScript really
There was a problem hiding this comment.
Removed the type casting..
| {header.mobileLabel !== undefined | ||
| ? header.mobileLabel | ||
| : header.content} |
There was a problem hiding this comment.
This is a triple condition that is very easy to miss if you aren't very careful. It is also not easy to grep if we are looking for this behaviour later.
This is equivalent to
if(null) {do A}
else if (!undefined) {do B}
else {do C}
An explicit boolean flag here makes more sense; we should be precise in our code rather than depending on side effects of something being defined or undefined.
There was a problem hiding this comment.
Replaced the null and undefined checks in with a hideMobileLabel bool flag.
| isRowSelected: (itemId: string) => boolean; | ||
| } | ||
|
|
||
| export const useTableSelection = ( |
There was a problem hiding this comment.
I don't think a hook is necessary for this functionality; the abstraction obfuscates more than it simplifies. It moves state away from the page where it is used for no real gain in my opinion.
All we're doing with this hook really is moving state out of the page it is used just to pipe it back in again.
|
|
||
| setSelectedRows([]); | ||
| refetch(); | ||
| } catch (err: any) { |
There was a problem hiding this comment.
Errors should be typed as unknown and their type discriminated in the catch block. The any type is disallowed in this project.
| 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 []; |
There was a problem hiding this comment.
This DB op is not necessary; the eligibility can be integrated into the updateMany call.
You should be able to just call something like
const filter = { _id: { $in: objectIds }, teamId, isActive: pause };
const { modifiedCount } = await MonitorModel.updateMany(filter, {
$set: { isActive: !pause, status: newStatus },
});
if (modifiedCount === 0) return [];
There was a problem hiding this comment.
I initially tried doing exactly this to save the DB round trips! The issue I ran into is that updateMany only returns the modifiedCount, not the actual updated data.
Because the service layer immediately needs the updated monitor objects to pass into the BullMQ job queue (and the frontend uses the returned array to count how many were actually changed) , I had to stick with the 3-query approach (find eligible -> update -> fetch updated).
I also tried a 2-query approach where I modified the old documents in-memory before returning them, but that caused stale updatedAt timestamps.
Let me know if there's a cleaner way around that!
There was a problem hiding this comment.
I initially tried doing exactly this to save the DB round trips! The issue I ran into is that updateMany only returns the modifiedCount, not the actual updated data.
Because the service layer immediately needs the updated monitor objects to pass into the BullMQ job queue (and the frontend uses the returned array to count how many were actually changed) , I had to stick with the 3-query approach (find eligible -> update -> fetch updated). I also tried a 2-query approach where I modified the old documents in-memory before returning them, but that caused stale updatedAt timestamps.
Let me know if there's a cleaner way around that!
Why not set the timestamps manually?
const now = new Date()
await MonitorModel.updateMany(
{ _id: { $in: eligible.map((doc) => doc._id) } },
{ $set: { isActive: !pause, status: nextStatus, updatedAt: now } },
{ timestamps: false }
);
eligible.forEach((doc) => {
doc.isActive = !pause;
doc.status = nextStatus;
doc.updatedAt = now;
});
return this.mapDocuments(eligible);
That should resolve the problem 🤔
| bulkPauseMonitors = async ({ teamId, monitorIds, pause }: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<Monitor[]> => { | ||
| 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; | ||
| }; |
There was a problem hiding this comment.
This is somewhat problematic. What happens if one op rejects here? All the other ops keep running, some may succeed, some may fail, we won't know, their success/rejection is unhandled.
The DB and Queue have also now drifted out of sync, the DB shows they are all paused/resumed, but the queue is possibly not correct.
This is not a trivial problem to solve so I won't ask you to look at this right now, but at the very least we should use promise.allSettled() here to log individual failures and report that to the user.
There was a problem hiding this comment.
I've updated the backend to use Promise.allSettled(). It now processes the entire batch safely, catching and logging individual queue failures to the console without crashing.
I deliberately left two things out of this PR to keep the scope manageable:
- Connecting the frontend partial-success warning toasts.
- The actual DB/Queue rollback logic when a failure occurs.
I think we should merge this allSettled() backend safety net for now, and I can open two follow-up issues to tackle the frontend warnings and state drift properly later.
Does that work?
There was a problem hiding this comment.
{ timestamps: false } was exactly what I needed! I didn't realize we could just bypass Mongoose auto-timestamp like that.
That perfectly solves the stale timestamp issue I was hitting.
Just pushed the update, thanks for the suggestion! 🙌
…ring Addresses PR feedback: - Removes over-abstracted useTableSelection hook, inlines selection logic - Replaces triple-condition with explicit hideMobileLabel bool flag - Removes unsafe Record<string, never> casting Fixes bluewave-labs#3602
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 bluewave-labs#3602
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 bluewave-labs#3602
ajhollid
left a comment
There was a problem hiding this comment.
Thanks for making those changes. I believe the multiple DB calls can be handled without too much headache, please see my example in this latest review.
As for the DB rollback issue, don't worry about that, that's not unique to or caused by your PR, so we can resolve that at a later date.
Handling the messaging can also be done in a separate PR, you can tackle that one if you'd like!
Thanks for working on it 🚀
| 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 []; |
There was a problem hiding this comment.
I initially tried doing exactly this to save the DB round trips! The issue I ran into is that updateMany only returns the modifiedCount, not the actual updated data.
Because the service layer immediately needs the updated monitor objects to pass into the BullMQ job queue (and the frontend uses the returned array to count how many were actually changed) , I had to stick with the 3-query approach (find eligible -> update -> fetch updated). I also tried a 2-query approach where I modified the old documents in-memory before returning them, but that caused stale updatedAt timestamps.
Let me know if there's a cleaner way around that!
Why not set the timestamps manually?
const now = new Date()
await MonitorModel.updateMany(
{ _id: { $in: eligible.map((doc) => doc._id) } },
{ $set: { isActive: !pause, status: nextStatus, updatedAt: now } },
{ timestamps: false }
);
eligible.forEach((doc) => {
doc.isActive = !pause;
doc.status = nextStatus;
doc.updatedAt = now;
});
return this.mapDocuments(eligible);
That should resolve the problem 🤔
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 bluewave-labs#3602
ajhollid
left a comment
There was a problem hiding this comment.
Thanks for making the changes!
|
Oops, server build fails let me fix it quickly |
|
@ajhollid Build issue is fixed. |





Describe your changes
Implements bulk pause/resume functionality for monitors, allowing users to select multiple monitors via checkboxes and pause or resume them in a single action.
Backend:
POST /api/v1/monitors/bulk/pauseendpoint with Zod validation (pause: booleanflag controls pause/resume)bulkTogglePauserepository method using$in/$nequeries to only update monitors that actually need a state changeFrontend:
useTableSelectionhook for generic table row selection with select-all/indeterminate supportuseBulkMonitorActionshook to handle bulk API calls and toast notificationsBulkActionsBarcomponent with smooth Collapse animationTable.tsxto supportmobileLabelprop for clean mobile card rendering of non-standard columns (e.g., checkboxes)stopPropagationon checkbox clicks to prevent row navigationWrite your issue number after "Fixes "
Fixes #3602
Please ensure all items are checked off before requesting a review.
<div>Add</div>, use):npm run formatin server and client directories, which automatically formats your code.bulk-pause-resume-checkmate.mp4