Skip to content

feat/bulk pause resume#3626

Merged
ajhollid merged 11 commits into
bluewave-labs:developfrom
harsh-aghara:feat/bulk-pause-resume
May 6, 2026
Merged

feat/bulk pause resume#3626
ajhollid merged 11 commits into
bluewave-labs:developfrom
harsh-aghara:feat/bulk-pause-resume

Conversation

@harsh-aghara
Copy link
Copy Markdown
Contributor

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:

  • Added POST /api/v1/monitors/bulk/pause endpoint with Zod validation (pause: boolean flag controls pause/resume)
  • Optimized bulkTogglePause repository method using $in/$ne queries to only update monitors that actually need a state change
  • Orchestrates BullMQ job pausing/resuming for each affected monitor
  • Added unit tests for the new service method

Frontend:

  • Created reusable useTableSelection hook for generic table row selection with select-all/indeterminate support
  • Created useBulkMonitorActions hook to handle bulk API calls and toast notifications
  • Created BulkActionsBar component with smooth Collapse animation
  • Integrated selection checkboxes and bulk actions bar into both Uptime and Infrastructure monitor tables
  • Enhanced Table.tsx to support mobileLabel prop for clean mobile card rendering of non-standard columns (e.g., checkboxes)
  • Added stopPropagation on checkbox clicks to prevent row navigation
  • Added i18n keys across all 16 locales

Write your issue number after "Fixes "

Fixes #3602

Please ensure all items are checked off before requesting a review.

  • I deployed the application locally.
  • I have performed a self-reviewing and testing of my code.
  • I have included the issue # in the PR.
  • I have added i18n support to visible strings (instead of <div>Add</div>, use):
const { t } = useTranslation();
<div>{t('add')}</div>
  • I have not included any files that are not related to my pull request, including package-lock and package-json if dependencies have not changed
  • I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application).
  • I made sure font sizes, color choices etc are all referenced from the theme. I don't have any hardcoded dimensions.
  • My PR is granular and targeted to one specific feature.
  • I ran npm run format in server and client directories, which automatically formats your code.
  • I took a screenshot or a video and attached to this PR if there is a UI change.
bulk-pause-resume-checkmate.mp4

- 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
Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@gorkem-bwl
Copy link
Copy Markdown
Contributor

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.

0 monitor(s) resumed successfully

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.

@harsh-aghara
Copy link
Copy Markdown
Contributor Author

Thanks @gorkem-bwl
I will convert the bar to a bottom pill and fix the toast strings. Will push and ping you soon.

…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
@harsh-aghara
Copy link
Copy Markdown
Contributor Author

Hey @ajhollid @gorkem-bwl, I just pushed an update:

  • Moved the actions to a floating bottom pill ( I didn't make it fully round because it didn't fit the UI well, but I centered it dynamically so it smoothly adjusts when the sidebar opens and closes ). I also made it modular so it won't collide with PR feat(ui): add bulk edit notifications modal and floating action bar to uptime monitors #3448.
  • Fixed the table alignments and swapped in our custom Checkbox components.
  • Cleaned up the translations to use proper pluralization across all locales instead of the generic (s).

Should be good to go now, let me know what you think!
image
image
image
image
image

@harsh-aghara harsh-aghara requested a review from ajhollid May 2, 2026 09:16
@ashu130698
Copy link
Copy Markdown
Contributor

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!

@gorkem-bwl
Copy link
Copy Markdown
Contributor

@ajhollid we should be good to merge this hopefully and move on to the other PR 👍

@ajhollid
Copy link
Copy Markdown
Collaborator

ajhollid commented May 3, 2026

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

@harsh-aghara
Copy link
Copy Markdown
Contributor Author

@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?

@ajhollid
Copy link
Copy Markdown
Collaborator

ajhollid commented May 3, 2026

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!

Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a decent start, but there are some issues that need to be addressed before this is ready to merge.

Please have a look at my comments in the code review and revise as needed.
Thank you!

Comment on lines +118 to +124
await post(`/monitors/pause/${monitor.id}`, {});
await post(`/monitors/pause/${monitor.id}`, {} as Record<string, never>);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No casting please, lets use correct types. Casting removes type safety and defeats the purpose of TypeScript really

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the type casting..

Comment on lines +146 to +148
{header.mobileLabel !== undefined
? header.mobileLabel
: header.content}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the null and undefined checks in with a hideMobileLabel bool flag.

Comment thread client/src/Hooks/useTableSelection.ts Outdated
isRowSelected: (itemId: string) => boolean;
}

export const useTableSelection = (
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors should be typed as unknown and their type discriminated in the catch block. The any type is disallowed in this project.

Comment on lines +238 to +246
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 [];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 [];  

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 🤔

Comment on lines +466 to +480
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;
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ 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
@harsh-aghara harsh-aghara requested a review from ajhollid May 5, 2026 13:49
Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 🚀

Comment on lines +238 to +246
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 [];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@harsh-aghara harsh-aghara requested a review from ajhollid May 5, 2026 18:23
Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for making the changes!

@harsh-aghara
Copy link
Copy Markdown
Contributor Author

Oops, server build fails let me fix it quickly

@harsh-aghara
Copy link
Copy Markdown
Contributor Author

@ajhollid Build issue is fixed.
Thanks for the approval!

@ajhollid ajhollid merged commit bf369aa into bluewave-labs:develop May 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Bulk Pause and Resume actions for Monitors

4 participants