diff --git a/client/src/Components/inputs/Dialog.tsx b/client/src/Components/inputs/Dialog.tsx
index 02d164abd..1053f8059 100644
--- a/client/src/Components/inputs/Dialog.tsx
+++ b/client/src/Components/inputs/Dialog.tsx
@@ -21,6 +21,7 @@ export const DialogInput = ({
loading = false,
cancelText,
confirmText,
+ confirmDisabled = false,
children,
maxWidth,
fullWidth = false,
@@ -35,6 +36,7 @@ export const DialogInput = ({
loading?: boolean;
cancelText?: string;
confirmText?: string;
+ confirmDisabled?: boolean;
children?: ReactNode;
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
@@ -104,6 +106,7 @@ export const DialogInput = ({
variant="contained"
color={confirmColor}
onClick={onConfirm}
+ disabled={confirmDisabled}
>
{confirmText ?? t("common.buttons.confirm")}
diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx
index aa520745f..3cf3c2b26 100644
--- a/client/src/Components/monitors/BulkActionsBar.tsx
+++ b/client/src/Components/monitors/BulkActionsBar.tsx
@@ -17,32 +17,39 @@ interface BulkActionsBarProps {
selectedCount: number;
onCancel: () => void;
children?: React.ReactNode;
+ hidden?: boolean;
}
export const BulkActionsBar = ({
selectedCount,
onCancel,
children,
+ hidden = false,
}: BulkActionsBarProps) => {
const { t } = useTranslation();
const theme = useTheme();
- const isOpen = selectedCount > 0;
+ const isOpen = selectedCount > 0 && !hidden;
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { width, collapsedWidth, transition } = useSidebar();
+ const sidebarOffset = isSmall ? collapsedWidth : width;
+
return (
-
@@ -91,11 +102,13 @@ export const BulkActionsBar = ({
{children}
-
+
);
diff --git a/client/src/Components/monitors/BulkEditNotificationsModal.tsx b/client/src/Components/monitors/BulkEditNotificationsModal.tsx
new file mode 100644
index 000000000..3e33a6bae
--- /dev/null
+++ b/client/src/Components/monitors/BulkEditNotificationsModal.tsx
@@ -0,0 +1,153 @@
+import React, { useState, useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@mui/material/styles";
+import {
+ Dialog,
+ Autocomplete,
+ TextField,
+ RadioWithDescription,
+} from "@/Components/inputs";
+import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
+import Stack from "@mui/material/Stack";
+import RadioGroup from "@mui/material/RadioGroup";
+import Typography from "@mui/material/Typography";
+import Alert from "@mui/material/Alert";
+import { useGet, usePatch } from "@/Hooks/UseApi";
+import type { Notification } from "@/Types/Notification";
+
+interface BulkEditNotificationsModalProps {
+ open: boolean;
+ onClose: () => void;
+ selectedMonitors: string[];
+ onComplete: (success: boolean) => void;
+}
+
+export const BulkEditNotificationsModal: React.FC = ({
+ open,
+ onClose,
+ selectedMonitors,
+ onComplete,
+}) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ const [action, setAction] = useState<"add" | "remove" | "set">("add");
+ const [notificationIds, setNotificationIds] = useState([]);
+
+ // Fetch available notifications
+ const { data: notifications } = useGet(
+ "/notifications/team",
+ undefined,
+ {
+ keepPreviousData: true,
+ }
+ );
+
+ const { patch, loading: isPatching, error } = usePatch();
+
+ // Reset state when modal opens
+ useEffect(() => {
+ if (open) {
+ setAction("add");
+ setNotificationIds([]);
+ }
+ }, [open]);
+
+ const handleConfirm = async () => {
+ const res = await patch("/monitors/notifications", {
+ monitorIds: selectedMonitors,
+ notificationIds,
+ action,
+ });
+
+ onComplete(!!res);
+ };
+
+ // Prevent submitting empty arrays unless the action is 'set'
+ const isMissingSelection = action !== "set" && notificationIds.length === 0;
+
+ // Map notifications to the { id, name } structure expected by the Autocomplete component
+ const options = useMemo(() => {
+ return (notifications ?? []).map((n) => ({
+ id: n.id,
+ name: `${n.notificationName} (${n.type.toUpperCase()})`,
+ }));
+ }, [notifications]);
+
+ const selectedOptions = useMemo(() => {
+ return options.filter((opt) => notificationIds.includes(opt.id));
+ }, [options, notificationIds]);
+
+ return (
+
+ );
+};
diff --git a/client/src/Components/monitors/MonitorListPage.tsx b/client/src/Components/monitors/MonitorListPage.tsx
index 9ab187c2b..438641bc1 100644
--- a/client/src/Components/monitors/MonitorListPage.tsx
+++ b/client/src/Components/monitors/MonitorListPage.tsx
@@ -21,6 +21,8 @@ interface MonitorListPageProps {
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
+ bulkActionsExtra?: ReactNode;
+ bulkActionsHidden?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
@@ -37,6 +39,8 @@ export const MonitorListPage = ({
actionLink,
controller,
bulkActions,
+ bulkActionsExtra,
+ bulkActionsHidden,
showTypeFilter,
summaryProps,
priorityFallback,
@@ -125,6 +129,7 @@ export const MonitorListPage = ({
+ {bulkActionsExtra}
)}
{children}
diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx
index cf87eb074..0470ed02c 100644
--- a/client/src/Components/monitors/index.tsx
+++ b/client/src/Components/monitors/index.tsx
@@ -15,4 +15,5 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
+export * from "./BulkEditNotificationsModal";
export * from "./MonitorListPage";
diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
index 1d98ddc79..57e8d5ef6 100644
--- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
+++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
@@ -14,17 +14,17 @@ import { ArrowDown, ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import { useSelector } from "react-redux";
-import useMediaQuery from "@mui/material/useMediaQuery";
import type { Monitor } from "@/Types/Monitor";
import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag";
-import { SPACING } from "@/Utils/Theme/constants";
+import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
@@ -65,10 +65,10 @@ export const MonitorTable = ({
}: MonitorTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
const { post } = usePost, Monitor>();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const selectedSet = new Set(selectedRows);
const isAllSelected =
@@ -214,7 +214,7 @@ export const MonitorTable = ({
mobileLabel: t("common.table.headers.name"),
content: (
handleSort(e, "name")}
@@ -234,14 +234,14 @@ export const MonitorTable = ({
mobileLabel: t("common.table.headers.status"),
content: (
handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
-
+
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
@@ -311,14 +311,14 @@ export const MonitorTable = ({
mobileLabel: t("common.table.headers.type"),
content: (
handleSort(e, "type")}
sx={{ cursor: "pointer" }}
>
-
+
{t("common.table.headers.type")}
{renderSortIcon(sortField === "type")}
@@ -344,6 +344,30 @@ export const MonitorTable = ({
return (
+ {isSmall && (
+
+
+ handleSelectAll(e.target.checked)}
+ />
+
+ {t("pages.common.monitors.bulkEdit.selectAll")}
+
+
+
+ )}
{
+ const { t } = useTranslation();
const monitorListController = useMonitorListController({
types: "selectable",
checksLimit: 25,
@@ -12,6 +15,16 @@ const UptimeMonitorsPage = () => {
rowsPerPageDefault: 10,
});
+ const [isBulkEditModalOpen, setIsBulkEditModalOpen] = useState(false);
+
+ const handleBulkEditComplete = (success: boolean) => {
+ setIsBulkEditModalOpen(false);
+ if (success) {
+ monitorListController.setSelectedRows([]);
+ monitorListController.refetch();
+ }
+ };
+
return (
{
actionLink="/uptime/create"
controller={monitorListController}
bulkActions
+ bulkActionsHidden={isBulkEditModalOpen}
+ bulkActionsExtra={
+
+ }
>
{
selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows}
/>
+ setIsBulkEditModalOpen(false)}
+ selectedMonitors={monitorListController.selectedRows}
+ onComplete={handleBulkEditComplete}
+ />
);
};
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index cbebadded..e72552680 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -30,6 +30,7 @@
"home": "Home"
},
"buttons": {
+ "selected": "selected",
"addMember": "Add member",
"cancel": "Cancel",
"close": "Close",
@@ -517,6 +518,20 @@
},
"common": {
"monitors": {
+ "bulkEdit": {
+ "selectAll": "Select All",
+ "editButton": "Edit Notifications",
+ "title": "Edit Notifications",
+ "selectedText": "You are applying changes to {{count}} monitor(s).",
+ "actionAdd": "Add",
+ "actionAddDesc": "Adds the selected notifications to these monitors without removing existing ones.",
+ "actionRemove": "Remove",
+ "actionRemoveDesc": "Removes exclusively the selected notifications from these monitors.",
+ "actionSet": "Replace all",
+ "actionSetDesc": "Replaces all existing notifications. Leave empty to clear all notifications.",
+ "selectLabel": "Notification Channels",
+ "selectPlaceholder": "Select channels..."
+ },
"actions": {
"configure": "Configure",
"delete": "Delete",