Merge pull request #3626 from harsh-aghara/feat/bulk-pause-resume
feat/bulk pause resume
This commit is contained in:
31 files changed
+944
-83
No files matched your search
@@ -35,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
export type Header<T> = {
|
||||
id: number | string;
|
||||
content: React.ReactNode;
|
||||
mobileLabel?: React.ReactNode;
|
||||
hideMobileLabel?: boolean;
|
||||
align?: "left" | "center" | "right" | "justify" | "inherit";
|
||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
||||
render: (row: T) => React.ReactNode;
|
||||
};
|
||||
@@ -109,6 +112,24 @@ export function DataTable<
|
||||
key={key}
|
||||
>
|
||||
{headers.map((header) => {
|
||||
if (header.hideMobileLabel) {
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
key={header.id}
|
||||
>
|
||||
<Grid2
|
||||
size={12}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
{header.render(row)}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
@@ -123,7 +144,7 @@ export function DataTable<
|
||||
component="div"
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{header.content}
|
||||
{header.mobileLabel ?? header.content}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2
|
||||
@@ -193,7 +214,7 @@ export function DataTable<
|
||||
{headers.map((header, idx) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={idx === 0 ? "left" : "center"}
|
||||
align={header.align ?? (idx === 0 ? "left" : "center")}
|
||||
key={header.id}
|
||||
>
|
||||
{header.content}
|
||||
@@ -222,7 +243,7 @@ export function DataTable<
|
||||
{headers.map((header, index) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={index === 0 ? "left" : "center"}
|
||||
align={header.align ?? (index === 0 ? "left" : "center")}
|
||||
key={header.id}
|
||||
onClick={
|
||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
||||
|
||||
@@ -0,0 +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 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;
|
||||
onCancel: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BulkActionsBar = ({
|
||||
selectedCount,
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: theme.spacing(LAYOUT.XS),
|
||||
left: isSmall ? collapsedWidth : width,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
transition: transition.replace("width", "left"),
|
||||
}}
|
||||
>
|
||||
<Slide
|
||||
direction="up"
|
||||
in={isOpen}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
px: LAYOUT.MD,
|
||||
py: LAYOUT.XS,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: LAYOUT.XS,
|
||||
boxShadow: theme.shadows[8],
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={600}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{t("pages.common.monitors.actions.bulkSelected", {
|
||||
count: selectedCount,
|
||||
})}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onCancel}
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Slide>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails";
|
||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
export * from "./charts/HistogramInfrastructure";
|
||||
export * from "./HeaderMonitorsSummary";
|
||||
export * from "./BulkActionsBar";
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
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 ApiResponse {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: Monitor[];
|
||||
}
|
||||
|
||||
interface UseBulkMonitorActionsReturn {
|
||||
selectedRows: string[];
|
||||
setSelectedRows: (rows: string[]) => void;
|
||||
handleBulkPause: () => Promise<void>;
|
||||
handleBulkResume: () => Promise<void>;
|
||||
handleCancelSelection: () => void;
|
||||
}
|
||||
|
||||
export const useBulkMonitorActions = (
|
||||
refetch: () => void,
|
||||
page?: number
|
||||
): UseBulkMonitorActionsReturn => {
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const { toastSuccess, toastError, toastInfo } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Clear selection when page changes
|
||||
useEffect(() => {
|
||||
setSelectedRows([]);
|
||||
}, [page]);
|
||||
|
||||
const executeBulkAction = async (pause: boolean) => {
|
||||
try {
|
||||
const res = await post<ApiResponse>("/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: 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPause = async () => {
|
||||
await executeBulkAction(true);
|
||||
};
|
||||
|
||||
const handleBulkResume = async () => {
|
||||
await executeBulkAction(false);
|
||||
};
|
||||
|
||||
const handleCancelSelection = () => {
|
||||
setSelectedRows([]);
|
||||
};
|
||||
|
||||
return {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
@@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface InfraMonitorsTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => 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,32 @@ 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,
|
||||
// loading: isPatching,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
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<HTMLButtonElement> | null,
|
||||
@@ -65,7 +87,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 +123,6 @@ export const InfraMonitorsTable = ({
|
||||
navigate(`/infrastructure/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -152,8 +167,35 @@ export const InfraMonitorsTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
@@ -173,18 +215,20 @@ export const InfraMonitorsTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
),
|
||||
render: (row) => {
|
||||
return <StatusLabel status={row.status} />;
|
||||
@@ -248,6 +292,11 @@ export const InfraMonitorsTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/infrastructure/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -2,8 +2,13 @@ import Stack from "@mui/material/Stack";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
@@ -16,6 +21,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { InfraMonitorsTable } from "./Components/MonitorsTable";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
|
||||
const InfrastructureMonitors = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,6 +43,7 @@ const InfrastructureMonitors = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -97,6 +104,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 +175,29 @@ const InfrastructureMonitors = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<InfraMonitorsTable
|
||||
monitors={monitorsWithChecksData?.monitors || []}
|
||||
refetch={refetch}
|
||||
@@ -180,6 +219,8 @@ const InfrastructureMonitors = () => {
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -20,6 +20,24 @@ import { useSelector } from "react-redux";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface MonitorTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => 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 +52,34 @@ 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<any, Monitor>();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const handleSort = (e: any, field: string) => {
|
||||
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();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
@@ -110,13 +131,6 @@ export const MonitorTable = ({
|
||||
navigate(`/uptime/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -160,8 +174,35 @@ export const MonitorTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -181,6 +222,7 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -212,6 +254,7 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
mobileLabel: t("common.table.headers.type"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -251,6 +294,11 @@ export const MonitorTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/uptime/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -16,6 +21,7 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTheme } from "@mui/material";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
|
||||
const UptimeMonitorsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,6 +43,7 @@ const UptimeMonitorsPage = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -102,6 +109,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 +193,29 @@ const UptimeMonitorsPage = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<MonitorTable
|
||||
monitors={monitorsWithChecks || []}
|
||||
refetch={refetch}
|
||||
@@ -198,6 +237,8 @@ const UptimeMonitorsPage = () => {
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "دعوة عضو",
|
||||
"openSite": "فتح الموقع",
|
||||
"pause": "إيقاف مؤقت",
|
||||
"resume": "استئناف"
|
||||
"resume": "استئناف",
|
||||
"selectAll": "تحديد جميع الشاشات",
|
||||
"selectMonitor": "تحديد {{name}}",
|
||||
"bulkSelected_one": "{{count}} شاشة محددة",
|
||||
"bulkSelected_other": "{{count}} شاشات محددة"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "نشط منذ",
|
||||
@@ -449,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": "جميع الشاشات المحددة قيد التشغيل بالفعل"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Pozvat člena",
|
||||
"openSite": "Otevřít stránku",
|
||||
"pause": "Pozastavit",
|
||||
"resume": "Obnovit"
|
||||
"resume": "Obnovit",
|
||||
"selectAll": "Vybrat všechny monitory",
|
||||
"selectMonitor": "Vybrat {{name}}",
|
||||
"bulkSelected_one": "Vybrán {{count}} monitor",
|
||||
"bulkSelected_other": "Vybráno {{count}} monitorů"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktivní po dobu",
|
||||
@@ -449,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ěží"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Mitglied einladen",
|
||||
"openSite": "Seite öffnen",
|
||||
"pause": "Pausieren",
|
||||
"resume": "Fortsetzen"
|
||||
"resume": "Fortsetzen",
|
||||
"selectAll": "Alle Monitore auswählen",
|
||||
"selectMonitor": "{{name}} auswählen",
|
||||
"bulkSelected_one": "{{count}} Monitor ausgewählt",
|
||||
"bulkSelected_other": "{{count}} Monitore ausgewählt"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiv seit",
|
||||
@@ -449,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -423,7 +423,21 @@
|
||||
"inviteMember": "Invite member",
|
||||
"openSite": "Open site",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume"
|
||||
"resume": "Resume",
|
||||
"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",
|
||||
|
||||
@@ -428,7 +428,11 @@
|
||||
"inviteMember": "Invitar miembro",
|
||||
"openSite": "Abrir sitio",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar"
|
||||
"resume": "Reanudar",
|
||||
"selectAll": "Seleccionar todos los monitores",
|
||||
"selectMonitor": "Seleccionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor seleccionado",
|
||||
"bulkSelected_other": "{{count}} monitores seleccionados"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Activo desde hace",
|
||||
@@ -456,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Kutsu jäsen",
|
||||
"openSite": "Avaa sivusto",
|
||||
"pause": "Keskeytä",
|
||||
"resume": "Jatka"
|
||||
"resume": "Jatka",
|
||||
"selectAll": "Valitse kaikki näytöt",
|
||||
"selectMonitor": "Valitse {{name}}",
|
||||
"bulkSelected_one": "{{count}} näyttö valittu",
|
||||
"bulkSelected_other": "{{count}} näyttöä valittu"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiivinen",
|
||||
@@ -449,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ä"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Inviter un membre",
|
||||
"openSite": "Ouvrir le site",
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre"
|
||||
"resume": "Reprendre",
|
||||
"selectAll": "Sélectionner tous les moniteurs",
|
||||
"selectMonitor": "Sélectionner {{name}}",
|
||||
"bulkSelected_one": "{{count}} moniteur sélectionné",
|
||||
"bulkSelected_other": "{{count}} moniteurs sélectionnés"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Actif depuis",
|
||||
@@ -449,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "メンバーを招待",
|
||||
"openSite": "サイトを開く",
|
||||
"pause": "一時停止",
|
||||
"resume": "再開"
|
||||
"resume": "再開",
|
||||
"selectAll": "すべてのモニターを選択",
|
||||
"selectMonitor": "{{name}} を選択",
|
||||
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
|
||||
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "稼働期間",
|
||||
@@ -449,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": "選択したすべてのモニターはすでに実行中です"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Convidar membro",
|
||||
"openSite": "Abrir site",
|
||||
"pause": "Pausar",
|
||||
"resume": "Retomar"
|
||||
"resume": "Retomar",
|
||||
"selectAll": "Selecionar todos os monitores",
|
||||
"selectMonitor": "Selecionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor selecionado",
|
||||
"bulkSelected_other": "{{count}} monitores selecionados"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Ativo há",
|
||||
@@ -449,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Пригласить участника",
|
||||
"openSite": "Открыть сайт",
|
||||
"pause": "Пауза",
|
||||
"resume": "Продолжить"
|
||||
"resume": "Продолжить",
|
||||
"selectAll": "Выбрать все мониторы",
|
||||
"selectMonitor": "Выбрать {{name}}",
|
||||
"bulkSelected_one": "Выбран {{count}} монитор",
|
||||
"bulkSelected_other": "Выбрано {{count}} мониторов"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активен",
|
||||
@@ -449,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": "Все выбранные мониторы уже запущены"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "เชิญสมาชิก",
|
||||
"openSite": "เปิดเว็บไซต์",
|
||||
"pause": "หยุดชั่วคราว",
|
||||
"resume": "ดำเนินการต่อ"
|
||||
"resume": "ดำเนินการต่อ",
|
||||
"selectAll": "เลือกจอภาพทั้งหมด",
|
||||
"selectMonitor": "เลือก {{name}}",
|
||||
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
|
||||
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "ทำงานมาแล้ว",
|
||||
@@ -449,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": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Üye davet et",
|
||||
"openSite": "Siteyi aç",
|
||||
"pause": "Duraklat",
|
||||
"resume": "Devam et"
|
||||
"resume": "Devam et",
|
||||
"selectAll": "Tüm monitörleri seç",
|
||||
"selectMonitor": "{{name}}'i seç",
|
||||
"bulkSelected_one": "{{count}} monitör seçildi",
|
||||
"bulkSelected_other": "{{count}} monitör seçildi"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktif süresi",
|
||||
@@ -449,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Запросити учасника",
|
||||
"openSite": "Відкрити сайт",
|
||||
"pause": "Призупинити",
|
||||
"resume": "Відновити"
|
||||
"resume": "Відновити",
|
||||
"selectAll": "Вибрати всі монітори",
|
||||
"selectMonitor": "Вибрати {{name}}",
|
||||
"bulkSelected_one": "Вибрано {{count}} монітор",
|
||||
"bulkSelected_other": "Вибрано {{count}} моніторів"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активний протягом",
|
||||
@@ -449,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": "Всі вибрані монітори уже запущено"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"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",
|
||||
"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ừ",
|
||||
@@ -449,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "邀请成员",
|
||||
"openSite": "打开站点",
|
||||
"pause": "暂停",
|
||||
"resume": "恢复"
|
||||
"resume": "恢复",
|
||||
"selectAll": "选择所有监视器",
|
||||
"selectMonitor": "选择 {{name}}",
|
||||
"bulkSelected_one": "已选择 {{count}} 个监视器",
|
||||
"bulkSelected_other": "已选择 {{count}} 个监视器"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已活跃",
|
||||
@@ -449,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": "所有选定的监视器均已在运行"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "邀請成員",
|
||||
"openSite": "開啟網站",
|
||||
"pause": "暫停",
|
||||
"resume": "恢復"
|
||||
"resume": "恢復",
|
||||
"selectAll": "選擇所有監視器",
|
||||
"selectMonitor": "選擇 {{name}}",
|
||||
"bulkSelected_one": "已選擇 {{count}} 個監視器",
|
||||
"bulkSelected_other": "已選擇 {{count}} 個監視器"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已啟用",
|
||||
@@ -449,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": "所有選定的監視器均已在運行"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<Response | void>;
|
||||
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
@@ -307,6 +308,33 @@ 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, 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,
|
||||
data: monitors,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = requireUserId(req.user?.id);
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface IMonitorsRepository {
|
||||
statusPatch?: Partial<Monitor>
|
||||
): Promise<Monitor>;
|
||||
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||
bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]>;
|
||||
// delete
|
||||
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
|
||||
|
||||
@@ -234,6 +234,34 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return this.toEntity(monitor);
|
||||
};
|
||||
|
||||
bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]> => {
|
||||
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 nextStatus = pause ? "paused" : "initializing";
|
||||
|
||||
const eligible = await MonitorModel.find(filter);
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
deleteById = async (monitorId: string, teamId: string) => {
|
||||
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface IMonitorService {
|
||||
// update
|
||||
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
|
||||
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>;
|
||||
|
||||
// delete
|
||||
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||
@@ -462,6 +463,43 @@ export class MonitorService implements IMonitorService {
|
||||
return monitor;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
monitors.map(async (monitor) => {
|
||||
if (monitor.isActive) {
|
||||
await this.jobQueue.resumeJob(monitor);
|
||||
} else {
|
||||
await this.jobQueue.pauseJob(monitor);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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 || "unknown"} 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<Monitor> => {
|
||||
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
|
||||
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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,141 @@ 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.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();
|
||||
});
|
||||
|
||||
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.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();
|
||||
});
|
||||
|
||||
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.monitors).toHaveLength(2);
|
||||
expect(result.failedCount).toBe(0);
|
||||
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.monitors).toHaveLength(0);
|
||||
expect(result.failedCount).toBe(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.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", () => {
|
||||
it("deletes monitor and all associated data", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
|
||||
Reference in new issue
Block a user