diff --git a/client/package-lock.json b/client/package-lock.json index 1a59d98906..d289d83129 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -6203,4 +6203,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index e5091091e2..4daf0cd163 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/v2/design-elements/BasePage.tsx @@ -121,7 +121,7 @@ export const BasePageWithStates = ({ }: BasePageWithStatesProps) => { const showLoading = loading && (!items || items.length === 0); - if (isEmpty(items)) { + if (!loading && isEmpty(items)) { return ( { - maintenanceWindow; - const [anchorEl, setAnchorEl] = useState(null); - const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const theme = useTheme(); - const { t } = useTranslation(); - - const handleRemove = async (event) => { - event.preventDefault(); - event.stopPropagation(); - try { - setIsLoading(true); - await networkService.deleteMaintenanceWindow({ - maintenanceWindowId: maintenanceWindow.id, - }); - updateCallback(); - createToast({ body: "Maintenance window deleted successfully." }); - } catch (error) { - createToast({ body: "Failed to delete maintenance window." }); - logger.error("Failed to delete maintenance window", error); - } finally { - setIsLoading(false); - } - setIsOpen(false); - }; - - const handlePause = async () => { - try { - setIsLoading(true); - const data = { - active: !maintenanceWindow.active, - }; - await networkService.editMaintenanceWindow({ - maintenanceWindowId: maintenanceWindow.id, - maintenanceWindow: data, - }); - updateCallback(); - } catch (error) { - logger.error(error); - createToast({ body: "Failed to pause maintenance window." }); - } finally { - setIsLoading(false); - } - }; - - const handleEdit = () => { - navigate(`/maintenance/create/${maintenanceWindow.id}`); - }; - - const openMenu = (event) => { - event.preventDefault(); - event.stopPropagation(); - setAnchorEl(event.currentTarget); - }; - - const openRemove = (e) => { - closeMenu(e); - setIsOpen(true); - }; - - const closeMenu = (e) => { - e.stopPropagation(); - setAnchorEl(null); - }; - - const navigate = useNavigate(); - - return ( - <> - { - event.stopPropagation(); - openMenu(event); - }} - sx={{ - "&:focus": { - outline: "none", - }, - "& svg path": { - stroke: theme.palette.primary.contrastTextTertiary, - }, - }} - > - - - - closeMenu(e)} - disableScrollLock - slotProps={{ - paper: { - sx: { - "& ul": { p: theme.spacing(2.5) }, - "& li": { m: 0 }, - "& li:last-of-type": { - color: theme.palette.error.main, - }, - }, - }, - }} - > - { - closeMenu(e); - e.stopPropagation(); - handleEdit(); - }} - > - {t("edit")} - - { - handlePause(); - closeMenu(e); - e.stopPropagation(); - }} - > - {`${maintenanceWindow.active === true ? t("pause") : t("resume")}`} - - - { - e.stopPropagation(); - openRemove(e); - }} - > - {t("remove")} - - - { - e.stopPropagation(); - setIsOpen(false); - }} - confirmationButtonLabel={t("delete")} - onConfirm={(e) => { - e.stopPropagation(e); - handleRemove(e); - }} - isLoading={isLoading} - /> - - ); -}; - -ActionsMenu.propTypes = { - maintenanceWindow: PropTypes.object, - isAdmin: PropTypes.bool, - updateCallback: PropTypes.func, -}; - -export default ActionsMenu; diff --git a/client/src/Pages/Maintenance/MaintenanceTable/index.jsx b/client/src/Pages/Maintenance/MaintenanceTable/index.jsx deleted file mode 100644 index 11875bb6ad..0000000000 --- a/client/src/Pages/Maintenance/MaintenanceTable/index.jsx +++ /dev/null @@ -1,237 +0,0 @@ -import PropTypes from "prop-types"; -import { Box } from "@mui/material"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import ActionsMenu from "./ActionsMenu/index.jsx"; -import { memo } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { formatDurationRounded } from "../../../Utils/timeUtilsLegacy.js"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import { setRowsPerPage } from "../../../Features/UI/uiSlice.js"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import dayjs from "dayjs"; -/** - * Component for pagination actions (first, previous, next, last). - * - * @component - * @param {Object} props - * @param {number} props.count - Total number of items. - * @param {number} props.page - Current page number. - * @param {number} props.rowsPerPage - Number of rows per page. - * @param {function} props.onPageChange - Callback function to handle page change. - * - * @returns {JSX.Element} Pagination actions component. - */ - -const MaintenanceTable = ({ - page, - setPage, - sort, - setSort, - maintenanceWindows, - maintenanceWindowCount, - updateCallback, -}) => { - const rowsPerPage = useSelector((state) => state?.ui?.maintenance?.rowsPerPage ?? 5); - const dispatch = useDispatch(); - const theme = useTheme(); - const navigate = useNavigate(); - const handleChangePage = (event, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - dispatch( - setRowsPerPage({ - value: parseInt(event.target.value, 10), - table: "maintenance", - }) - ); - setPage(0); - }; - - const { t } = useTranslation(); - - const headers = [ - { - id: "name", - content: ( - handleSort("name")}> - {t("maintenanceWindowName")} - - {sort.order === "asc" ? ( - - ) : ( - - )} - - - ), - render: (row) => row.name, - }, - { - id: "status", - content: ( - handleSort("status")}> - {" "} - {t("status")} - - {sort.order === "asc" ? ( - - ) : ( - - )} - - - ), - render: (row) => { - const status = row.active ? "up" : "paused"; - const text = row.active ? "active" : "paused"; - - return ( - - ); - }, - }, - { - id: "nextWindow", - content: t("nextWindow"), - render: (row) => { - return getTimeToNextWindow(row.start, row.end, row.repeat); - }, - }, - { - id: "repeat", - content: t("repeat"), - render: (row) => { - return row.repeat === 0 ? "N/A" : formatDurationRounded(row.repeat); - }, - }, - { - id: "actions", - content: t("actions"), - render: (row) => ( - - ), - }, - ]; - - const getTimeToNextWindow = (startTime, endTime, repeat) => { - //1. Advance time closest to next window as possible - const now = dayjs(); - let start = dayjs(startTime); - let end = dayjs(endTime); - if (repeat > 0) { - // Advance time closest to next window as possible - while (start.isBefore(now) && end.isBefore(now)) { - start = start.add(repeat, "milliseconds"); - end = end.add(repeat, "milliseconds"); - } - } - - //Check if we are in a window - if (now.isAfter(start) && now.isBefore(end)) { - return "In maintenance window"; - } - - if (start.isAfter(now)) { - const diffInMinutes = start.diff(now, "minutes"); - const diffInHours = start.diff(now, "hours"); - const diffInDays = start.diff(now, "days"); - - if (diffInMinutes < 60) { - return diffInMinutes + " minutes"; - } else if (diffInHours < 24) { - return diffInHours + " hours"; - } else if (diffInDays < 7) { - return diffInDays + " days"; - } else { - return diffInDays + " days"; - } - } - }; - - const handleSort = async (field) => { - let order = ""; - if (sort.field !== field) { - order = "desc"; - } else { - order = sort.order === "asc" ? "desc" : "asc"; - } - setSort({ field, order }); - }; - - return ( - <> - { - navigate(`/maintenance/create/${row.id}`); - }, - }} - headers={headers} - data={maintenanceWindows} - /> - - - ); -}; - -MaintenanceTable.propTypes = { - isAdmin: PropTypes.bool, - page: PropTypes.number, - setPage: PropTypes.func, - rowsPerPage: PropTypes.number, - setRowsPerPage: PropTypes.func, - sort: PropTypes.object, - setSort: PropTypes.func, - maintenanceWindows: PropTypes.array, - maintenanceWindowCount: PropTypes.number, - updateCallback: PropTypes.func, -}; - -const MemoizedMaintenanceTable = memo(MaintenanceTable); -export default MemoizedMaintenanceTable; diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx new file mode 100644 index 0000000000..9922c9cc6c --- /dev/null +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -0,0 +1,208 @@ +import Typography from "@mui/material/Typography"; +import prettyMilliseconds from "pretty-ms"; +import { Table, ValueLabel } from "@/Components/v2/design-elements"; +import { Pagination } from "@/Components/v2/design-elements/Table"; +import { ActionsMenu } from "@/Components/v2/actions-menu"; +import { DialogInput } from "@/Components/v2/inputs/Dialog"; + +import { useTheme } from "@mui/material"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import type { ActionMenuItem } from "@/Components/v2/actions-menu"; +import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useSelector, useDispatch } from "react-redux"; +import type { RootState } from "@/Types/state"; +import Box from "@mui/material/Box"; +import { setRowsPerPage } from "@/Features/UI/uiSlice"; +import { formatDurationRounded } from "@/Utils/timeUtilsLegacy"; +import dayjs from "dayjs"; +import { useState } from "react"; +import { useDelete, usePatch } from "@/Hooks/UseApi"; + +interface MaintenanceWindowTableProps { + maintenanceWindows: MaintenanceWindow[]; + maintenanceWindowCount: number; + page: number; + setPage: (page: number) => void; + refetch: () => void; +} + +const getTimeToNextWindow = ( + startTime: string, + endTime: string, + repeat: number +): string => { + const now = dayjs(); + let start = dayjs(startTime); + let end = dayjs(endTime); + + // For repeating windows, advance to next occurrence + if (repeat > 0) { + while (end.isBefore(now)) { + start = start.add(repeat, "milliseconds"); + end = end.add(repeat, "milliseconds"); + } + } + + // Currently in maintenance window + if (now.isAfter(start) && now.isBefore(end)) { + return "In maintenance window"; + } + + // Window is in the future + if (start.isAfter(now)) { + return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true }); + } + + return "N/A"; +}; + +export const MaintenanceWindowTable = ({ + maintenanceWindows, + maintenanceWindowCount, + page, + setPage, + refetch, +}: MaintenanceWindowTableProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const rowsPerPage = useSelector( + (state: RootState) => state?.ui?.maintenance?.rowsPerPage ?? 5 + ); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedWindow, setSelectedWindow] = useState(null); + + const { deleteFn, loading: deleteLoading } = useDelete(); + const { patch } = usePatch(); + + const handleDelete = async () => { + if (!selectedWindow) return; + const result = await deleteFn(`/maintenance-window/${selectedWindow.id}`); + if (result) { + refetch(); + setDeleteDialogOpen(false); + setSelectedWindow(null); + } + }; + + const handlePause = async (maintenanceWindow: MaintenanceWindow) => { + const result = await patch(`/maintenance-window/${maintenanceWindow.id}`, { + active: !maintenanceWindow.active, + }); + if (result) { + refetch(); + } + }; + + const getActions = (maintenanceWindow: MaintenanceWindow): ActionMenuItem[] => [ + { + id: "edit", + label: t("pages.common.monitors.actions.configure"), + action: () => navigate(`/maintenance/create/${maintenanceWindow.id}`), + closeMenu: true, + }, + { + id: "pause", + label: maintenanceWindow.active + ? t("pages.common.monitors.actions.pause") + : t("pages.common.monitors.actions.resume"), + action: () => handlePause(maintenanceWindow), + closeMenu: true, + }, + { + id: "remove", + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: () => { + setSelectedWindow(maintenanceWindow); + setDeleteDialogOpen(true); + }, + closeMenu: true, + }, + ]; + + const getHeaders = (): Header[] => [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => row.name, + }, + { + id: "status", + content: t("common.table.headers.status"), + render: (row) => ( + + ), + }, + { + id: "nextWindow", + content: t("pages.maintenanceWindow.table.headers.nextWindow"), + render: (row) => getTimeToNextWindow(row.start, row.end, row.repeat), + }, + { + id: "repeat", + content: t("pages.maintenanceWindow.table.headers.repeat"), + render: (row) => + row.repeat === 0 ? t("common.labels.na") : formatDurationRounded(row.repeat), + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => , + }, + ]; + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + dispatch(setRowsPerPage({ value: Number(e.target.value), table: "maintenance" })); + setPage(0); + }; + + return ( + + navigate(`/maintenance/create/${row.id}`)} + emptyViewText={t("common.table.empty")} + /> + + { + setDeleteDialogOpen(false); + setSelectedWindow(null); + }} + onConfirm={handleDelete} + confirmText={t("delete")} + loading={deleteLoading} + /> + + ); +}; diff --git a/client/src/Pages/Maintenance/index.css b/client/src/Pages/Maintenance/index.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/client/src/Pages/Maintenance/index.jsx b/client/src/Pages/Maintenance/index.jsx deleted file mode 100644 index b49cd9f6eb..0000000000 --- a/client/src/Pages/Maintenance/index.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Stack, Button } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useState, useEffect } from "react"; -import "./index.css"; -import MaintenanceTable from "./MaintenanceTable/index.jsx"; -import { useSelector } from "react-redux"; -import { networkService } from "../../main.jsx"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; - -const Maintenance = () => { - const theme = useTheme(); - const { t } = useTranslation(); - const navigate = useNavigate(); - const rowsPerPage = useSelector((state) => state?.ui?.maintenance?.rowsPerPage ?? 5); - const [maintenanceWindows, setMaintenanceWindows] = useState(undefined); - const [maintenanceWindowCount, setMaintenanceWindowCount] = useState(0); - const [page, setPage] = useState(0); - const [sort, setSort] = useState({}); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [networkError, setNetworkError] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - const handleActionMenuDelete = () => { - setUpdateTrigger((prev) => !prev); - }; - - useEffect(() => { - const fetchMaintenanceWindows = async () => { - try { - setNetworkError(false); - setIsLoading(true); - const response = await networkService.getMaintenanceWindowsByTeamId({ - page: page, - rowsPerPage: rowsPerPage, - }); - const { maintenanceWindows, maintenanceWindowCount } = response.data.data; - setMaintenanceWindows(maintenanceWindows ?? []); - setMaintenanceWindowCount(maintenanceWindowCount ?? 0); - } catch (error) { - setNetworkError(true); - } finally { - setIsLoading(false); - } - }; - fetchMaintenanceWindows(); - }, [page, rowsPerPage, updateTrigger]); - - return ( - <> - - - - - - - - - - - ); -}; - -export default Maintenance; diff --git a/client/src/Pages/Maintenance/index.tsx b/client/src/Pages/Maintenance/index.tsx new file mode 100644 index 0000000000..09b2a9ae7e --- /dev/null +++ b/client/src/Pages/Maintenance/index.tsx @@ -0,0 +1,61 @@ +import { BasePageWithStates } from "@/Components/v2/design-elements"; +import { HeaderCreate } from "@/Components/v2/common"; +import { useTranslation } from "react-i18next"; +import { useGet } from "@/Hooks/UseApi"; +import { MaintenanceWindowTable } from "./MaintenanceWindowTable"; +import { useState } from "react"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; +import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; + +interface MaintenanceWindowsResponse { + maintenanceWindows: MaintenanceWindow[]; + maintenanceWindowCount: number; +} + +const MaintenanceWindowPage = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + const [page, setPage] = useState(0); + const rowsPerPage = useSelector( + (state: RootState) => state?.ui?.maintenance?.rowsPerPage ?? 5 + ); + + const { data, isLoading, isValidating, error, refetch } = + useGet( + `/maintenance-window/team?page=${page}&rowsPerPage=${rowsPerPage}` + ); + + const maintenanceWindows = data?.maintenanceWindows ?? []; + const maintenanceWindowCount = data?.maintenanceWindowCount ?? 0; + + return ( + + + + + ); +}; + +export default MaintenanceWindowPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 908d7efe11..ee08dc581f 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -48,11 +48,11 @@ import Account from "../Pages/Account/index.jsx"; import EditUser from "../Pages/Account/EditUser/index.jsx"; import Settings from "../Pages/Settings"; -import Maintenance from "../Pages/Maintenance/index.jsx"; +import Maintenance from "../Pages/Maintenance"; +import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance/index.jsx"; import ProtectedRoute from "../Components/v1/ProtectedRoute"; import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute"; -import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance/index.jsx"; import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; import BulkImport from "../Pages/Uptime/BulkImport/index.jsx"; import Logs from "../Pages/Logs/index.jsx"; @@ -303,7 +303,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> } The response from the axios POST request. */ async updateAppSettings(config) { - return this.axiosInstance.put(`/settings`, config.settings, { + return this.axiosInstance.patch(`/settings`, config.settings, { headers: { "Content-Type": "application/json", }, @@ -775,7 +775,7 @@ class NetworkService { */ async editMaintenanceWindow(config) { - return this.axiosInstance.put( + return this.axiosInstance.patch( `/maintenance-window/${config.maintenanceWindowId}`, config.maintenanceWindow, { @@ -955,7 +955,7 @@ class NetworkService { return this.axiosInstance.post(`/status-page`, fd, {}); } - return this.axiosInstance.put(`/status-page/${id}`, fd, {}); + return this.axiosInstance.patch(`/status-page/${id}`, fd, {}); } async deleteStatusPage(config) { @@ -1125,7 +1125,7 @@ class NetworkService { async editUser(config) { const { userId, user } = config; - return this.axiosInstance.put(`auth/users/${userId}`, user, { + return this.axiosInstance.patch(`auth/users/${userId}`, user, { headers: { "Content-Type": "application/json", }, @@ -1134,7 +1134,7 @@ class NetworkService { async changePasswordByAdmin(config) { const { userId, passwordForm } = config; - return this.axiosInstance.put(`auth/users/${userId}/password`, passwordForm, { + return this.axiosInstance.patch(`auth/users/${userId}/password`, passwordForm, { headers: { "Content-Type": "application/json", }, @@ -1164,7 +1164,7 @@ class NetworkService { resolveIncidentManually = async (incidentId, options = {}) => { const body = {}; if (options.comment) body.comment = options.comment; - return this.axiosInstance.put(`/incidents/${incidentId}/resolve`, body); + return this.axiosInstance.patch(`/incidents/${incidentId}/resolve`, body); }; getIncidentSummary = async (config = {}) => { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 81924ad572..2f61a5f626 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -218,6 +218,7 @@ }, "labels": { "active": "Active", + "paused": "paused", "na": "N/A", "resolved": "Resolved", "responseTime": "Response time" @@ -776,6 +777,23 @@ "title": "An infrastructure monitor is used to:" } }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Let's create your first maintenance window!", + "checks": [ + "Mark your maintenance periods", + "Eliminate any misunderstandings", + "Stop sending alerts in maintenance windows" + ], + "title": "A maintenance window is used to:" + }, + "table": { + "headers": { + "nextWindow": "Next window", + "repeat": "Repeat" + } + } + }, "notifications": { "fallback": { "actionButton": "Create a channel", diff --git a/server/src/routes/maintenanceWindowRoute.ts b/server/src/routes/maintenanceWindowRoute.ts index 0230ba4bda..539f2413f1 100755 --- a/server/src/routes/maintenanceWindowRoute.ts +++ b/server/src/routes/maintenanceWindowRoute.ts @@ -16,7 +16,7 @@ class MaintenanceWindowRoutes { this.router.get("/monitor/:monitorId", this.mwController.getMaintenanceWindowsByMonitorId); this.router.get("/:id", this.mwController.getMaintenanceWindowById); - this.router.put("/:id", this.mwController.editMaintenanceWindow); + this.router.patch("/:id", this.mwController.editMaintenanceWindow); this.router.delete("/:id", this.mwController.deleteMaintenanceWindow); }