Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions client/src/Hooks/useBulkMonitorActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ import type { Monitor } from "@/Types/Monitor";
interface ApiResponse {
success: boolean;
msg: string;
data: Monitor[];
data: Monitor[] | number;
}

interface UseBulkMonitorActionsReturn {
selectedRows: string[];
setSelectedRows: (rows: string[]) => void;
handleBulkPause: () => Promise<void>;
handleBulkResume: () => Promise<void>;
handleBulkAddNotifications: (notificationIds: string[]) => Promise<void>;
handleBulkRemoveNotifications: (notificationIds: string[]) => Promise<void>;
handleCancelSelection: () => void;
}

Expand All @@ -40,7 +42,7 @@ export const useBulkMonitorActions = (
pause,
});

const affectedCount = res.data?.data?.length ?? 0;
const affectedCount = Array.isArray(res.data?.data) ? res.data.data.length : 0;

if (affectedCount === 0) {
const key = pause
Expand Down Expand Up @@ -84,11 +86,59 @@ export const useBulkMonitorActions = (
setSelectedRows([]);
};

const handleBulkNotifications = (action: "add" | "remove") => {
return async (notificationIds: string[]) => {
try {
const res = await post<ApiResponse>("/monitors/notifications", {
monitorIds: selectedRows,
notificationIds,
action,
});

const affectedCount = typeof res.data?.data === "number" ? res.data.data : 0;

if (affectedCount === 0) {
toastInfo(
t("pages.common.monitors.bulkNotifications.noChange", {
count: selectedRows.length,
})
);
} else {
const key =
action === "add"
? "pages.common.monitors.bulkNotifications.added"
: "pages.common.monitors.bulkNotifications.removed";
toastSuccess(t(key, { count: affectedCount }));
}

setSelectedRows([]);
refetch();
} 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 notification update failed",
err instanceof Error ? err : undefined,
{ action }
);
toastError(errMsg);
}
};
};

return {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleBulkAddNotifications: handleBulkNotifications("add"),
handleBulkRemoveNotifications: handleBulkNotifications("remove"),
handleCancelSelection,
};
};
12 changes: 8 additions & 4 deletions client/src/Hooks/useMonitorForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
data?: Monitor | null;
defaultType?: MonitorType;
defaultNotifications?: string[];
}

const getBaseDefaults = (data?: Monitor | null) => ({
const getBaseDefaults = (data?: Monitor | null, defaultNotifications?: string[]) => ({
name: data?.name || "",
description: data?.description || "",
interval: data?.interval || 60000,
notifications: data?.notifications || [],
notifications: data?.notifications?.length
? data.notifications
: (defaultNotifications ?? []),
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
geoCheckEnabled: data?.geoCheckEnabled ?? false,
Expand All @@ -23,10 +26,11 @@ const getBaseDefaults = (data?: Monitor | null) => ({
export const useMonitorForm = ({
data = null,
defaultType = "http",
defaultNotifications,
}: UseMonitorFormOptions = {}) => {
return useMemo(() => {
const type = data?.type || defaultType;
const base = getBaseDefaults(data);
const base = getBaseDefaults(data, defaultNotifications);

let defaults: MonitorFormData;

Expand Down Expand Up @@ -136,5 +140,5 @@ export const useMonitorForm = ({
}

return { schema: monitorSchema, defaults };
}, [data, defaultType]);
}, [data, defaultType, defaultNotifications]);
};
12 changes: 11 additions & 1 deletion client/src/Hooks/useNotificationForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ interface UseNotificationFormOptions {
}

function buildDefaults(data: Notification | null): NotificationFormData {
const isDefault = data?.isDefault ?? false;
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
isDefault,
};
}
if (data?.type === "telegram") {
Expand All @@ -23,41 +25,47 @@ function buildDefaults(data: Notification | null): NotificationFormData {
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
isDefault,
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
isDefault,
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
isDefault,
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
isDefault,
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
isDefault,
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
isDefault,
};
}
if (data?.type === "twilio") {
Expand All @@ -68,6 +76,7 @@ function buildDefaults(data: Notification | null): NotificationFormData {
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
isDefault,
};
}
if (data?.type === "pushover") {
Expand All @@ -76,13 +85,14 @@ function buildDefaults(data: Notification | null): NotificationFormData {
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
isDefault,
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
isDefault,
};
}

Expand Down
5 changes: 5 additions & 0 deletions client/src/Pages/CreateMonitor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,16 @@ const CreateMonitorPage = () => {
);

const { data: notifications } = useGet<Notification[]>("/notifications/team");
const { data: defaultNotifications } = useGet<Notification[]>(
"/notifications/defaults"
);
const defaultNotificationIds = defaultNotifications?.map((n) => n.id) ?? [];
const { data: games } = useGet<GamesMap>("/monitors/games");

const { schema, defaults } = useMonitorForm({
data: existingMonitor ?? null,
defaultType,
defaultNotifications: defaultNotificationIds,
});

const form = useForm<MonitorFormData>({
Expand Down
97 changes: 90 additions & 7 deletions client/src/Pages/Infrastructure/Monitors/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,24 @@ import {
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { TextField, Dialog, Button, Autocomplete } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
import { Play, Pause, Bell } from "lucide-react";

import { useTranslation } from "react-i18next";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import { useTheme } from "@mui/material";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import type { Notification } from "@/Types/Notification";

const InfrastructureMonitors = () => {
const { t } = useTranslation();
Expand All @@ -43,6 +46,9 @@ const InfrastructureMonitors = () => {
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const [notificationDialogOpen, setNotificationDialogOpen] = useState(false);
const [selectedNotificationIds, setSelectedNotificationIds] = useState<string[]>([]);
const [notificationAction, setNotificationAction] = useState<"add" | "remove">("add");

const isDialogOpen = Boolean(selectedMonitor);

Expand Down Expand Up @@ -104,12 +110,16 @@ const InfrastructureMonitors = () => {
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;

const { data: notifications } = useGet<Notification[]>("/notifications/team");

// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleBulkAddNotifications,
handleBulkRemoveNotifications,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);

Expand All @@ -136,6 +146,28 @@ const InfrastructureMonitors = () => {
setSelectedMonitor(null);
};

const openAddNotifications = () => {
setNotificationAction("add");
setSelectedNotificationIds([]);
setNotificationDialogOpen(true);
};

const openRemoveNotifications = () => {
setNotificationAction("remove");
setSelectedNotificationIds([]);
setNotificationDialogOpen(true);
};

const handleNotificationConfirm = async () => {
if (notificationAction === "add") {
await handleBulkAddNotifications(selectedNotificationIds);
} else {
await handleBulkRemoveNotifications(selectedNotificationIds);
}
setNotificationDialogOpen(false);
setSelectedNotificationIds([]);
};

return (
<MonitorBasePageWithStates
headerKey="infrastructure"
Expand Down Expand Up @@ -195,6 +227,21 @@ const InfrastructureMonitors = () => {
>
{t("common.buttons.pause")}
</Button>
<Button
size="small"
startIcon={<Bell size={16} />}
onClick={openAddNotifications}
>
{t("pages.uptime.monitors.bulkActions.addNotifications")}
</Button>
<Button
size="small"
color="error"
startIcon={<Bell size={16} />}
onClick={openRemoveNotifications}
>
{t("pages.uptime.monitors.bulkActions.removeNotifications")}
</Button>
</BulkActionsBar>
)}

Expand Down Expand Up @@ -230,6 +277,42 @@ const InfrastructureMonitors = () => {
onCancel={handleCancel}
loading={isDeleting}
/>
<Dialog
open={notificationDialogOpen}
title={
notificationAction === "add"
? t("pages.uptime.monitors.bulkActions.addNotifications")
: t("pages.uptime.monitors.bulkActions.removeNotifications")
}
onConfirm={handleNotificationConfirm}
onCancel={() => setNotificationDialogOpen(false)}
confirmText={t("common.buttons.confirm")}
>
<Box>
<Typography mb={2}>
{notificationAction === "add"
? t("pages.uptime.monitors.bulkActions.selectToAdd")
: t("pages.uptime.monitors.bulkActions.selectToRemove")}
</Typography>
<Autocomplete
multiple
options={notifications ?? []}
getOptionLabel={(option) => option.notificationName}
value={(notifications ?? []).filter((n) =>
selectedNotificationIds.includes(n.id)
)}
onChange={(_, newValue) =>
setSelectedNotificationIds(newValue.map((n: Notification) => n.id))
}
renderInput={(params) => (
<TextField
{...params}
placeholder={t("pages.uptime.monitors.bulkActions.selectPlaceholder")}
/>
)}
/>
</Box>
</Dialog>
</MonitorBasePageWithStates>
);
};
Expand Down
Loading
Loading