Merge pull request #3243 from bluewave-labs/feat/v2-create-maintenance
feat: v2 create maintenance
This commit is contained in:
21 files changed
+704
-763
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import type { DatePickerProps } from "@mui/x-date-pickers/DatePicker";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
|
||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
export const DatePickerComponent = ({
|
||||
fieldLabel,
|
||||
required,
|
||||
error,
|
||||
helperText,
|
||||
...props
|
||||
}: DatePickerComponentProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const picker = (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DatePicker
|
||||
{...props}
|
||||
slots={{
|
||||
openPickerIcon: () => (
|
||||
<Calendar
|
||||
size={20}
|
||||
stroke={theme.palette.text.secondary}
|
||||
/>
|
||||
),
|
||||
...props.slots,
|
||||
}}
|
||||
slotProps={{
|
||||
switchViewButton: { sx: { display: "none" } },
|
||||
nextIconButton: { sx: { ml: theme.spacing(2) } },
|
||||
day: {
|
||||
sx: {
|
||||
"&.MuiPickersDay-root.Mui-disabled": {
|
||||
color: theme.palette.text.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
field: {
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& > .MuiOutlinedInput-root": {
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
p: 0,
|
||||
pl: theme.spacing(3),
|
||||
pr: theme.spacing(5),
|
||||
},
|
||||
"& fieldset": {
|
||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||
{
|
||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||
},
|
||||
},
|
||||
},
|
||||
inputAdornment: { sx: { ml: 0 } },
|
||||
openPickerButton: {
|
||||
sx: {
|
||||
py: 0,
|
||||
mr: 0,
|
||||
},
|
||||
},
|
||||
...props.slotProps,
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
||||
{picker}
|
||||
{helperText && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={error ? "error" : "text.secondary"}
|
||||
>
|
||||
{helperText}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
|
||||
import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
|
||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const TimePickerComponent = ({
|
||||
fieldLabel,
|
||||
required,
|
||||
...props
|
||||
}: TimePickerComponentProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const picker = (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<MobileTimePicker
|
||||
{...props}
|
||||
slotProps={{
|
||||
field: {
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
p: 0,
|
||||
px: theme.spacing(5),
|
||||
},
|
||||
"& fieldset": {
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||
{
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
},
|
||||
},
|
||||
...props.slotProps,
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{picker}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return picker;
|
||||
};
|
||||
@@ -14,3 +14,5 @@ export * from "./Switch";
|
||||
export * from "./Slider";
|
||||
export * from "./ImageUpload";
|
||||
export * from "./ColorPicker";
|
||||
export { DatePickerComponent as DatePicker } from "./DatePicker";
|
||||
export { TimePickerComponent as TimePicker } from "./TimePicker";
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
maintenanceWindowSchema,
|
||||
repeatOptions,
|
||||
type MaintenanceWindowFormData,
|
||||
} from "@/Validation/maintenanceWindow";
|
||||
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
|
||||
|
||||
interface UseMaintenanceWindowFormOptions {
|
||||
data?: MaintenanceWindow | null;
|
||||
}
|
||||
|
||||
const getRepeatId = (repeatMs: number): string => {
|
||||
const option = repeatOptions.find((opt) => opt.value === repeatMs);
|
||||
return option?.id ?? "none";
|
||||
};
|
||||
|
||||
export const useMaintenanceWindowForm = ({
|
||||
data = null,
|
||||
}: UseMaintenanceWindowFormOptions = {}) => {
|
||||
return useMemo(() => {
|
||||
let defaults: MaintenanceWindowFormData;
|
||||
|
||||
if (data) {
|
||||
const startDate = dayjs(data.start);
|
||||
|
||||
defaults = {
|
||||
name: data.name,
|
||||
repeat: getRepeatId(data.repeat),
|
||||
startDate: startDate.format("YYYY-MM-DD"),
|
||||
startTime: startDate.format("HH:mm"),
|
||||
duration: data.duration ?? 0,
|
||||
durationUnit: data.durationUnit ?? "minutes",
|
||||
monitors: [data.monitorId],
|
||||
};
|
||||
} else {
|
||||
const now = dayjs();
|
||||
defaults = {
|
||||
name: "",
|
||||
repeat: "none",
|
||||
startDate: now.format("YYYY-MM-DD"),
|
||||
startTime: now.format("HH:mm"),
|
||||
duration: 0,
|
||||
durationUnit: "minutes",
|
||||
monitors: [],
|
||||
};
|
||||
}
|
||||
|
||||
return { schema: maintenanceWindowSchema, defaults };
|
||||
}, [data]);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import Select from "@/Components/v1/Inputs/Select/index.jsx";
|
||||
import PropTypes from "prop-types";
|
||||
const ConfigSelect = ({ configSelection, valueSelect, onChange, ...props }) => {
|
||||
const getValueById = (config, id) => {
|
||||
const item = config.find((config) => config._id === id);
|
||||
return item ? (item.value ? item.value : item.name) : null;
|
||||
};
|
||||
|
||||
const getIdByValue = (config, name) => {
|
||||
const item = config.find((config) => {
|
||||
if (config.value) {
|
||||
return config.value === name;
|
||||
} else {
|
||||
return config.name === name;
|
||||
}
|
||||
});
|
||||
return item ? item._id : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={getIdByValue(configSelection, valueSelect)}
|
||||
onChange={(event) => {
|
||||
const newValue = getValueById(configSelection, event.target.value);
|
||||
onChange(newValue);
|
||||
}}
|
||||
items={configSelection}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ConfigSelect.propTypes = {
|
||||
configSelection: PropTypes.array.isRequired,
|
||||
valueSelect: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
export default ConfigSelect;
|
||||
@@ -1,71 +0,0 @@
|
||||
// Components
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const MonitorListItem = ({ monitor, onDelete }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(4)}
|
||||
width="100%"
|
||||
>
|
||||
<Typography flexGrow={1}>{monitor.name}</Typography>
|
||||
<Icon
|
||||
name="Trash2"
|
||||
size={20}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onDelete(monitor)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
MonitorListItem.propTypes = {
|
||||
monitor: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
|
||||
const onDelete = (monitorToDelete) => {
|
||||
const newMonitors = selectedMonitors.filter(
|
||||
(monitor) => monitor.id !== monitorToDelete.id
|
||||
);
|
||||
setSelectedMonitors(newMonitors);
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(6)}
|
||||
width="100%"
|
||||
>
|
||||
{selectedMonitors?.map((monitor) => (
|
||||
<MonitorListItem
|
||||
key={monitor.id}
|
||||
monitor={monitor}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
MonitorList.propTypes = {
|
||||
selectedMonitors: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
setSelectedMonitors: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default MonitorList;
|
||||
@@ -1,68 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import Search from "@/Components/v1/Inputs/Search/index.jsx";
|
||||
import { useState } from "react";
|
||||
import ConfigRow from "@/Components/v1/ConfigRow/index.jsx";
|
||||
|
||||
/**
|
||||
* MonitorsConfig is a component that allows users to select and apply a maintenance window to a list of monitors.
|
||||
* It provides a search input to filter monitors and select multiple options. The selected monitors are passed
|
||||
* to the parent via the `onSelectMonitors` callback.
|
||||
*
|
||||
*
|
||||
* @component
|
||||
*
|
||||
* @param {Array} props.monitors - List of available monitors that can be selected.
|
||||
* @param {Array} props.selectedMonitors - List of monitors currently selected for applying the maintenance window.
|
||||
* @param {string} [props.error] - Optional error message that will be displayed if there's an error in monitor selection.
|
||||
* @param {boolean} props.isEditMode - A boolean flag indicating if the component is in edit mode (disabled search/input when true).
|
||||
* @param {Function} props.onSelectMonitors - Callback function that is invoked when monitors are selected or deselected.
|
||||
* @param {string} [props.secondaryLabel] - Optional label or description that provides additional context for the monitor search.
|
||||
*/
|
||||
|
||||
const MonitorsConfig = ({
|
||||
monitors,
|
||||
selectedMonitors,
|
||||
error,
|
||||
isEditMode,
|
||||
onSelectMonitors,
|
||||
secondaryLabel,
|
||||
}) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const handleSearch = (value) => {
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigRow
|
||||
title="Monitors for maintenance"
|
||||
description="Select monitors to apply the maintenance window to. Use the search field to filter and choose multiple monitors."
|
||||
>
|
||||
<Search
|
||||
id={"monitors"}
|
||||
label="Add monitors"
|
||||
multiple={true}
|
||||
isAdorned={false}
|
||||
options={monitors || []}
|
||||
filteredBy="name"
|
||||
secondaryLabel={secondaryLabel}
|
||||
inputValue={search}
|
||||
value={selectedMonitors}
|
||||
handleInputChange={handleSearch}
|
||||
handleChange={onSelectMonitors}
|
||||
error={error}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</ConfigRow>
|
||||
);
|
||||
};
|
||||
|
||||
MonitorsConfig.propTypes = {
|
||||
monitors: PropTypes.array.isRequired,
|
||||
selectedMonitors: PropTypes.array.isRequired,
|
||||
error: PropTypes.string,
|
||||
isEditMode: PropTypes.bool.isRequired,
|
||||
onSelectMonitors: PropTypes.func.isRequired,
|
||||
secondaryLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
export default MonitorsConfig;
|
||||
@@ -1,70 +0,0 @@
|
||||
import { networkService } from "../../../../main.jsx";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
MS_PER_SECOND,
|
||||
MS_PER_MINUTE,
|
||||
MS_PER_HOUR,
|
||||
MS_PER_DAY,
|
||||
MS_PER_WEEK,
|
||||
} from "../../../../Utils/timeUtilsLegacy.js";
|
||||
|
||||
const useMaintenanceActions = () => {
|
||||
const MS_LOOKUP = {
|
||||
seconds: MS_PER_SECOND,
|
||||
minutes: MS_PER_MINUTE,
|
||||
hours: MS_PER_HOUR,
|
||||
days: MS_PER_DAY,
|
||||
weeks: MS_PER_WEEK,
|
||||
};
|
||||
const REPEAT_LOOKUP = {
|
||||
none: 0,
|
||||
daily: MS_PER_DAY,
|
||||
weekly: MS_PER_DAY * 7,
|
||||
};
|
||||
const handleSubmitForm = async (maintenanceWindowId, form) => {
|
||||
const toStringId = (monitor) => {
|
||||
if (!monitor) {
|
||||
return "";
|
||||
}
|
||||
return monitor._id ?? monitor.id ?? "";
|
||||
};
|
||||
const start = dayjs(form.startDate)
|
||||
.set("hour", form.startTime.hour())
|
||||
.set("minute", form.startTime.minute());
|
||||
|
||||
const MS_MULTIPLIER = MS_LOOKUP[form.durationUnit];
|
||||
const durationInMs = form.duration * MS_MULTIPLIER;
|
||||
const end = start.add(durationInMs);
|
||||
|
||||
const repeat = REPEAT_LOOKUP[form.repeat];
|
||||
|
||||
const submit = {
|
||||
monitors: form.monitors.map((monitor) => toStringId(monitor)).filter(Boolean),
|
||||
name: form.name,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
repeat,
|
||||
};
|
||||
|
||||
if (repeat === 0) {
|
||||
submit.expiry = end;
|
||||
}
|
||||
|
||||
const requestConfig = { maintenanceWindow: submit };
|
||||
|
||||
if (maintenanceWindowId !== undefined) {
|
||||
requestConfig.maintenanceWindowId = maintenanceWindowId;
|
||||
}
|
||||
const request =
|
||||
maintenanceWindowId === undefined
|
||||
? networkService.createMaintenanceWindow(requestConfig)
|
||||
: networkService.editMaintenanceWindow(requestConfig);
|
||||
return request;
|
||||
};
|
||||
|
||||
return {
|
||||
handleSubmitForm,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMaintenanceActions;
|
||||
@@ -1,76 +0,0 @@
|
||||
import { networkService } from "../../../../main.jsx";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
MS_PER_SECOND,
|
||||
MS_PER_MINUTE,
|
||||
MS_PER_HOUR,
|
||||
MS_PER_DAY,
|
||||
MS_PER_WEEK,
|
||||
} from "../../../../Utils/timeUtilsLegacy.js";
|
||||
const useMaintenanceData = () => {
|
||||
const REVERSE_REPEAT_LOOKUP = {
|
||||
0: "none",
|
||||
[MS_PER_DAY]: "daily",
|
||||
[MS_PER_WEEK]: "weekly",
|
||||
};
|
||||
const getDurationAndUnit = (durationInMs) => {
|
||||
if (durationInMs % MS_PER_DAY === 0) {
|
||||
return {
|
||||
duration: (durationInMs / MS_PER_DAY).toString(),
|
||||
durationUnit: "days",
|
||||
};
|
||||
} else if (durationInMs % MS_PER_HOUR === 0) {
|
||||
return {
|
||||
duration: (durationInMs / MS_PER_HOUR).toString(),
|
||||
durationUnit: "hours",
|
||||
};
|
||||
} else if (durationInMs % MS_PER_MINUTE === 0) {
|
||||
return {
|
||||
duration: (durationInMs / MS_PER_MINUTE).toString(),
|
||||
durationUnit: "minutes",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
duration: (durationInMs / MS_PER_SECOND).toString(),
|
||||
durationUnit: "seconds",
|
||||
};
|
||||
}
|
||||
};
|
||||
const fetchMonitorsMaintenance = async () => {
|
||||
const response = await networkService.getMonitorsByTeamId({
|
||||
limit: null,
|
||||
types: ["http", "ping", "pagespeed", "port"],
|
||||
});
|
||||
const fetchedMonitors = response.data.data;
|
||||
return fetchedMonitors;
|
||||
};
|
||||
|
||||
const initializeMaintenanceForEdit = async (maintenanceWindowId, monitorList) => {
|
||||
const res = await networkService.getMaintenanceWindowById({
|
||||
maintenanceWindowId: maintenanceWindowId,
|
||||
});
|
||||
const maintenanceWindow = res.data.data;
|
||||
const { name, start, end, repeat, monitorId } = maintenanceWindow;
|
||||
const startTime = dayjs(start);
|
||||
const endTime = dayjs(end);
|
||||
const durationInMs = endTime.diff(startTime, "milliseconds").toString();
|
||||
const { duration, durationUnit } = getDurationAndUnit(durationInMs);
|
||||
const monitor = monitorList.find(
|
||||
(monitor) => (monitor._id ?? monitor.id) === monitorId
|
||||
);
|
||||
const maintenanceWindowInformation = {
|
||||
name,
|
||||
repeat: REVERSE_REPEAT_LOOKUP[repeat],
|
||||
startDate: startTime,
|
||||
startTime,
|
||||
duration,
|
||||
durationUnit,
|
||||
monitors: monitor ? [monitor] : [],
|
||||
};
|
||||
return maintenanceWindowInformation;
|
||||
};
|
||||
|
||||
return { fetchMonitorsMaintenance, initializeMaintenanceForEdit };
|
||||
};
|
||||
|
||||
export default useMaintenanceData;
|
||||
@@ -1,3 +0,0 @@
|
||||
.create-maintenance button {
|
||||
height: 35px;
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { Box, Button, Stack, Typography } from "@mui/material";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ConfigBox from "@/Components/v1/ConfigBox/index.jsx";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
|
||||
import { maintenanceWindowValidation } from "@/Validation/validation.js";
|
||||
import { createToast } from "@/Utils/toastUtils.jsx";
|
||||
import MonitorList from "./Components/MonitorList/index.jsx";
|
||||
import ConfigSelect from "./Components/ConfigSelect/index.jsx";
|
||||
import useMaintenanceData from "./hooks/useMaintenanceData.jsx";
|
||||
import useMaintenanceActions from "./hooks/useMaintenanceActions.jsx";
|
||||
|
||||
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
|
||||
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import "./index.css";
|
||||
import Search from "@/Components/v1/Inputs/Search/index.jsx";
|
||||
import { logger } from "@/Utils/Logger.js";
|
||||
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { buildErrors, hasValidationErrors } from "@/Validation/error.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const repeatConfig = [
|
||||
{ _id: 0, name: "Don't repeat", value: "none" },
|
||||
{
|
||||
_id: 1,
|
||||
name: "Repeat daily",
|
||||
value: "daily",
|
||||
},
|
||||
{ _id: 2, name: "Repeat weekly", value: "weekly" },
|
||||
];
|
||||
|
||||
const durationConfig = [
|
||||
{ _id: 0, name: "seconds" },
|
||||
{ _id: 1, name: "minutes" },
|
||||
{ _id: 2, name: "hours" },
|
||||
{
|
||||
_id: 3,
|
||||
name: "days",
|
||||
},
|
||||
];
|
||||
|
||||
const CreateMaintenance = () => {
|
||||
const { maintenanceWindowId } = useParams();
|
||||
const { handleSubmitForm } = useMaintenanceActions();
|
||||
const { fetchMonitorsMaintenance, initializeMaintenanceForEdit } = useMaintenanceData();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { user } = useSelector((state) => state.auth);
|
||||
const [monitors, setMonitors] = useState([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [form, setForm] = useState({
|
||||
repeat: "none",
|
||||
startDate: dayjs(),
|
||||
startTime: dayjs(),
|
||||
duration: "",
|
||||
durationUnit: "seconds",
|
||||
name: "",
|
||||
monitors: [],
|
||||
});
|
||||
const handleFormChange = (key, value) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
const { error } = maintenanceWindowValidation.validate(
|
||||
{ [key]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
setErrors((prev) => {
|
||||
return buildErrors(prev, key, error);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const fetchedMonitors = await fetchMonitorsMaintenance();
|
||||
setMonitors(fetchedMonitors);
|
||||
|
||||
if (maintenanceWindowId === undefined) {
|
||||
return;
|
||||
}
|
||||
const maintenanceWindowInformation = await initializeMaintenanceForEdit(
|
||||
maintenanceWindowId,
|
||||
fetchedMonitors
|
||||
);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
...maintenanceWindowInformation,
|
||||
}));
|
||||
} catch (error) {
|
||||
createToast({ body: "Failed to fetch data" });
|
||||
logger.error("Failed to fetch monitors", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMonitors();
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (hasValidationErrors(form, maintenanceWindowValidation, setErrors)) return;
|
||||
|
||||
const request = handleSubmitForm(maintenanceWindowId, form);
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await request;
|
||||
createToast({
|
||||
body: "Successfully created maintenance window",
|
||||
});
|
||||
navigate("/maintenance");
|
||||
} catch (error) {
|
||||
createToast({
|
||||
body: `Failed to ${
|
||||
maintenanceWindowId === undefined ? "create" : "edit"
|
||||
} maintenance window`,
|
||||
});
|
||||
logger.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="create-maintenance">
|
||||
<Breadcrumbs
|
||||
list={[
|
||||
{ name: "maintenance", path: "/maintenance" },
|
||||
{
|
||||
name: maintenanceWindowId === undefined ? "create" : "edit",
|
||||
path: `/maintenance/create`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Stack
|
||||
component="form"
|
||||
noValidate
|
||||
spellCheck="false"
|
||||
gap={theme.spacing(12)}
|
||||
mt={theme.spacing(6)}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
>
|
||||
{`${maintenanceWindowId === undefined ? t("createA") : t("edit")}`}{" "}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="h2"
|
||||
fontSize="inherit"
|
||||
fontWeight="inherit"
|
||||
>
|
||||
{t("maintenance")}{" "}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
>
|
||||
{t("window")}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="body2"
|
||||
fontSize={14}
|
||||
>
|
||||
{t("maintenanceWindowDescription")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="h2"
|
||||
>
|
||||
{t("settingsGeneralSettings")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(15)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
label={t("friendlyNameInput")}
|
||||
placeholder={t("friendlyNamePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(event) => {
|
||||
handleFormChange("name", event.target.value);
|
||||
}}
|
||||
error={errors["name"] ? true : false}
|
||||
helperText={errors["name"]}
|
||||
/>
|
||||
<ConfigSelect
|
||||
id="repeat"
|
||||
name="maintenance-repeat"
|
||||
label={t("maintenanceRepeat")}
|
||||
valueSelect={form.repeat}
|
||||
configSelection={repeatConfig}
|
||||
onChange={(value) => handleFormChange("repeat", value)}
|
||||
/>
|
||||
<Stack>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DatePicker
|
||||
id="startDate"
|
||||
disablePast
|
||||
disableHighlightToday
|
||||
value={form.startDate}
|
||||
slots={{
|
||||
openPickerIcon: () => (
|
||||
<Icon
|
||||
name="Calendar"
|
||||
size={20}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
slotProps={{
|
||||
switchViewButton: { sx: { display: "none" } },
|
||||
nextIconButton: { sx: { ml: theme.spacing(2) } },
|
||||
day: {
|
||||
sx: {
|
||||
"&.MuiPickersDay-root.Mui-disabled": {
|
||||
color: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
},
|
||||
},
|
||||
field: {
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& > .MuiOutlinedInput-root": {
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
p: 0,
|
||||
pr: theme.spacing(5),
|
||||
},
|
||||
"& fieldset": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||
{
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
},
|
||||
},
|
||||
inputAdornment: { sx: { ml: 0, px: 3 } },
|
||||
openPickerButton: {
|
||||
sx: {
|
||||
py: 0,
|
||||
mr: 0,
|
||||
"& path": {
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
strokeWidth: 1.1,
|
||||
},
|
||||
"&:hover": { backgroundColor: "transparent" },
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{}}
|
||||
onChange={(newDate) => {
|
||||
handleFormChange("startDate", newDate);
|
||||
}}
|
||||
error={errors["startDate"]}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="h2"
|
||||
>
|
||||
{t("startTime")}
|
||||
</Typography>
|
||||
<Typography> {t("timeZoneInfo")} </Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(15)}>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<MobileTimePicker
|
||||
id="startTime"
|
||||
label={t("startTime")}
|
||||
value={form.startTime}
|
||||
onChange={(newTime) => {
|
||||
handleFormChange("startTime", newTime);
|
||||
}}
|
||||
slotProps={{
|
||||
nextIconButton: { sx: { ml: theme.spacing(2) } },
|
||||
field: {
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& > .MuiOutlinedInput-root": {
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
p: 0,
|
||||
pl: theme.spacing(5),
|
||||
},
|
||||
"& fieldset": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||
{
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
error={errors["startTime"]}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="end"
|
||||
spacing={theme.spacing(8)}
|
||||
>
|
||||
<TextInput
|
||||
type="number"
|
||||
id="duration"
|
||||
label={t("duration")}
|
||||
value={form.duration}
|
||||
onChange={(event) => {
|
||||
handleFormChange("duration", event.target.value);
|
||||
}}
|
||||
error={errors["duration"] ? true : false}
|
||||
helperText={errors["duration"]}
|
||||
/>
|
||||
<ConfigSelect
|
||||
id="durationUnit"
|
||||
valueSelect={form.durationUnit}
|
||||
configSelection={durationConfig}
|
||||
onChange={(value) => handleFormChange("durationUnit", value)}
|
||||
error={errors["durationUnit"]}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="h2"
|
||||
>
|
||||
{t("monitorsToApply")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(15)}>
|
||||
<Search
|
||||
id={"monitors"}
|
||||
label={t("addMonitors")}
|
||||
multiple={true}
|
||||
isAdorned={true}
|
||||
options={monitors}
|
||||
filteredBy="name"
|
||||
secondaryLabel={"type"}
|
||||
inputValue={search}
|
||||
value={form.monitors}
|
||||
handleInputChange={setSearch}
|
||||
handleChange={(selected) => {
|
||||
handleFormChange("monitors", selected);
|
||||
}}
|
||||
error={errors["monitors"]}
|
||||
/>
|
||||
<MonitorList
|
||||
selectedMonitors={form.monitors}
|
||||
setSelectedMonitors={(monitors) =>
|
||||
setForm((prev) => ({ ...prev, monitors }))
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
|
||||
<Box
|
||||
ml="auto"
|
||||
display="inline-block"
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate("/maintenance")}
|
||||
sx={{ mr: theme.spacing(6) }}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={handleSubmit}
|
||||
disabled={false}
|
||||
>
|
||||
{`${
|
||||
maintenanceWindowId === undefined
|
||||
? t("createMaintenance")
|
||||
: t("editMaintenance")
|
||||
}`}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateMaintenance;
|
||||
@@ -0,0 +1,367 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { BasePage, ConfigBox } from "@/Components/v2/design-elements";
|
||||
import {
|
||||
TextField,
|
||||
Select,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Button,
|
||||
Autocomplete,
|
||||
} from "@/Components/v2/inputs";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import dayjs from "dayjs";
|
||||
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
|
||||
import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow";
|
||||
import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow";
|
||||
import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm";
|
||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
const CreateMaintenanceWindowPage = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { maintenanceWindowId } = useParams<{ maintenanceWindowId: string }>();
|
||||
const isEditMode = Boolean(maintenanceWindowId);
|
||||
|
||||
const { data: existingMaintenanceWindow } = useGet<MaintenanceWindow>(
|
||||
isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null,
|
||||
{},
|
||||
{ keepPreviousData: false }
|
||||
);
|
||||
|
||||
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
|
||||
|
||||
const { post, loading: isPosting } = usePost();
|
||||
const { patch, loading: isPatching } = usePatch();
|
||||
|
||||
const { schema, defaults } = useMaintenanceWindowForm({
|
||||
data: existingMaintenanceWindow,
|
||||
});
|
||||
|
||||
const form = useForm<MaintenanceWindowFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { control, handleSubmit, trigger } = form;
|
||||
useEffect(() => {
|
||||
if (existingMaintenanceWindow) {
|
||||
form.reset(defaults);
|
||||
}
|
||||
}, [existingMaintenanceWindow, defaults, form]);
|
||||
|
||||
const buildPayload = (data: MaintenanceWindowFormData) => {
|
||||
const startDateTime = dayjs(data.startDate)
|
||||
.set("hour", parseInt(data.startTime.split(":")[0], 10))
|
||||
.set("minute", parseInt(data.startTime.split(":")[1], 10));
|
||||
|
||||
const durationUnit = durationUnitOptions.find((o) => o.id === data.durationUnit);
|
||||
const durationInMs = data.duration * (durationUnit?.multiplier ?? 1000);
|
||||
const endDateTime = startDateTime.add(durationInMs, "milliseconds");
|
||||
|
||||
const repeatOption = repeatOptions.find((o) => o.id === data.repeat);
|
||||
const repeat = repeatOption?.value ?? 0;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: data.name,
|
||||
duration: data.duration,
|
||||
durationUnit: data.durationUnit,
|
||||
monitors: data.monitors,
|
||||
start: startDateTime.toISOString(),
|
||||
end: endDateTime.toISOString(),
|
||||
repeat,
|
||||
};
|
||||
|
||||
if (repeat === 0) {
|
||||
payload.expiry = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const onSubmit = async (data: MaintenanceWindowFormData) => {
|
||||
const payload = buildPayload(data);
|
||||
|
||||
let result;
|
||||
if (isEditMode && maintenanceWindowId) {
|
||||
result = await patch(`/maintenance-window/${maintenanceWindowId}`, payload);
|
||||
} else {
|
||||
result = await post("/maintenance-window", payload);
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
navigate("/maintenance");
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isPosting || isPatching;
|
||||
|
||||
const onError = (errors: any) => {
|
||||
console.error("Form submission errors:", errors);
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
component={"form"}
|
||||
onSubmit={handleSubmit(onSubmit, onError)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.general.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.general.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
defaultValue={defaults.name}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
|
||||
placeholder={t(
|
||||
"pages.maintenanceWindow.form.general.option.name.placeholder"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="repeat"
|
||||
control={control}
|
||||
defaultValue={defaults.repeat}
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.general.option.repeat.label"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
onChange={field.onChange}
|
||||
>
|
||||
{repeatOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
>
|
||||
<Typography>{option.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startDate.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
defaultValue={defaults.startDate}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startDate.option.startDate.label"
|
||||
)}
|
||||
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
|
||||
onChange={(date) => {
|
||||
field.onChange(date ? date.format("YYYY-MM-DD") : "");
|
||||
trigger("startDate");
|
||||
}}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startTime.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="startTime"
|
||||
control={control}
|
||||
defaultValue={defaults.startTime}
|
||||
render={({ field }) => (
|
||||
<TimePicker
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.option.startTime.label"
|
||||
)}
|
||||
value={field.value ? dayjs(field.value, "HH:mm") : null}
|
||||
onChange={(time) => {
|
||||
field.onChange(time ? time.format("HH:mm") : "");
|
||||
trigger("startDate");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-end"
|
||||
spacing={theme.spacing(4)}
|
||||
>
|
||||
<Controller
|
||||
name="duration"
|
||||
control={control}
|
||||
defaultValue={defaults.duration}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.option.duration.label"
|
||||
)}
|
||||
value={field.value === 0 ? "" : field.value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
field.onChange(val === "" ? 0 : Number(val));
|
||||
}}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="durationUnit"
|
||||
control={control}
|
||||
defaultValue={defaults.durationUnit}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
{durationUnitOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
>
|
||||
<Typography>{option.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="monitors"
|
||||
control={control}
|
||||
defaultValue={defaults.monitors}
|
||||
render={({ field, fieldState }) => {
|
||||
const monitorsList = monitors ?? [];
|
||||
const selectedMonitors = field.value
|
||||
.map((id: string) => monitorsList.find((m) => m.id === id))
|
||||
.filter((m): m is Monitor => m !== undefined);
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={monitorsList}
|
||||
getOptionLabel={(option: Monitor) => option.name}
|
||||
value={selectedMonitors}
|
||||
onChange={(_, newValue) => {
|
||||
field.onChange(newValue.map((m: Monitor) => m.id));
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={
|
||||
selectedMonitors.length === 0
|
||||
? t(
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)
|
||||
: ""
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{selectedMonitors.length > 0 && (
|
||||
<Stack>
|
||||
{selectedMonitors.map((monitor) => (
|
||||
<Stack
|
||||
key={monitor.id}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(4)}
|
||||
padding={theme.spacing(4)}
|
||||
marginTop={theme.spacing(2)}
|
||||
borderRadius={1}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Typography flexGrow={1}>{monitor.name}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value.filter((id: string) => id !== monitor.id)
|
||||
);
|
||||
}}
|
||||
aria-label="Remove monitor"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateMaintenanceWindowPage;
|
||||
@@ -49,7 +49,7 @@ import EditUser from "../Pages/Account/EditUser/index.jsx";
|
||||
import Settings from "../Pages/Settings";
|
||||
|
||||
import Maintenance from "../Pages/Maintenance";
|
||||
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance/index.jsx";
|
||||
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
|
||||
|
||||
import ProtectedRoute from "../Components/v1/ProtectedRoute";
|
||||
import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute";
|
||||
@@ -313,7 +313,13 @@ const Routes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="/maintenance/create/:maintenanceWindowId?"
|
||||
element={<CreateNewMaintenanceWindow />}
|
||||
element={
|
||||
<>
|
||||
<ThemeProvider theme={v2theme}>
|
||||
<CreateNewMaintenanceWindow />
|
||||
</ThemeProvider>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
active: boolean;
|
||||
name: string;
|
||||
duration: number;
|
||||
durationUnit: DurationUnit;
|
||||
repeat: number;
|
||||
start: string;
|
||||
end: string;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const repeatOptions = [
|
||||
{ id: "none", name: "Don't repeat", value: 0 },
|
||||
{ id: "daily", name: "Repeat daily", value: 86400000 },
|
||||
{ id: "weekly", name: "Repeat weekly", value: 604800000 },
|
||||
] as const;
|
||||
|
||||
export const durationUnitOptions = [
|
||||
{ id: "seconds", name: "seconds", multiplier: 1000 },
|
||||
{ id: "minutes", name: "minutes", multiplier: 60000 },
|
||||
{ id: "hours", name: "hours", multiplier: 3600000 },
|
||||
{ id: "days", name: "days", multiplier: 86400000 },
|
||||
] as const;
|
||||
|
||||
export const maintenanceWindowSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.max(100, "Name must be at most 100 characters"),
|
||||
repeat: z.string(),
|
||||
startDate: z.string().min(1, "Start date is required"),
|
||||
startTime: z.string().min(1, "Start time is required"),
|
||||
duration: z.number().int().min(1, "Duration must be at least 1"),
|
||||
durationUnit: z.string(),
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const startDateTime = dayjs(data.startDate)
|
||||
.set("hour", parseInt(data.startTime.split(":")[0], 10))
|
||||
.set("minute", parseInt(data.startTime.split(":")[1], 10));
|
||||
return startDateTime.isAfter(dayjs());
|
||||
},
|
||||
{
|
||||
message: "Start date and time must be in the future",
|
||||
path: ["startDate"],
|
||||
}
|
||||
);
|
||||
|
||||
export type MaintenanceWindowFormData = z.infer<typeof maintenanceWindowSchema>;
|
||||
+47
-12
@@ -792,6 +792,52 @@
|
||||
"nextWindow": "Next window",
|
||||
"repeat": "Repeat"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"general": {
|
||||
"title": "General settings",
|
||||
"description": "Set a name and repeat option for your maintenance window.",
|
||||
"option": {
|
||||
"name": {
|
||||
"label": "Name",
|
||||
"placeholder": "e.g. Weekly Maintenance"
|
||||
},
|
||||
"repeat": {
|
||||
"label": "Repeat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"startDate": {
|
||||
"title": "Start date",
|
||||
"description": "Select the start date for your maintenance window.",
|
||||
"option": {
|
||||
"startDate": {
|
||||
"label": "Start date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"startTime": {
|
||||
"title": "Start time",
|
||||
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
|
||||
"option": {
|
||||
"duration": {
|
||||
"label": "Duration"
|
||||
},
|
||||
|
||||
"startTime": {
|
||||
"label": "Start time"
|
||||
}
|
||||
},
|
||||
"monitors": {
|
||||
"title": "Monitors",
|
||||
"description": "Monitors that the maintenance window should apply to",
|
||||
"option": {
|
||||
"addMonitors": {
|
||||
"label": "Add monitors"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -1282,16 +1328,5 @@
|
||||
"monitorDeleted": "Monitor deleted successfully",
|
||||
"monitorPaused": "Monitor paused successfully",
|
||||
"monitorResumed": "Monitor resumed successfully",
|
||||
"name": "Name",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
"name": "Name"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema, model, type Types } from "mongoose";
|
||||
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
|
||||
import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
|
||||
|
||||
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
|
||||
monitorId: Types.ObjectId;
|
||||
@@ -34,6 +34,13 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
},
|
||||
durationUnit: {
|
||||
type: String,
|
||||
enum: ["seconds", "minutes", "hours", "days"],
|
||||
},
|
||||
repeat: {
|
||||
type: Number,
|
||||
},
|
||||
|
||||
@@ -33,6 +33,8 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
|
||||
teamId: this.toStringId(doc.teamId),
|
||||
active: doc.active,
|
||||
name: doc.name,
|
||||
duration: doc.duration,
|
||||
durationUnit: doc.durationUnit,
|
||||
repeat: doc.repeat,
|
||||
start: this.toDateString(doc.start),
|
||||
end: this.toDateString(doc.end),
|
||||
|
||||
@@ -45,6 +45,8 @@ class MaintenanceWindowService {
|
||||
monitorId,
|
||||
name: body.name,
|
||||
active: body.active ? body.active : true,
|
||||
duration: body.duration,
|
||||
durationUnit: body.durationUnit,
|
||||
repeat: body.repeat,
|
||||
start: body.start,
|
||||
end: body.end,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string;
|
||||
monitorId: String;
|
||||
teamId: string;
|
||||
active: boolean;
|
||||
name: string;
|
||||
duration: number;
|
||||
durationUnit: DurationUnit;
|
||||
repeat: number;
|
||||
start: string;
|
||||
end: string;
|
||||
|
||||
@@ -364,6 +364,8 @@ const createMaintenanceWindowBodyValidation = joi.object({
|
||||
monitors: joi.array().items(joi.string()).required(),
|
||||
name: joi.string().required(),
|
||||
active: joi.boolean(),
|
||||
duration: joi.number().required(),
|
||||
durationUnit: joi.string().valid("seconds", "minutes", "hours", "days").required(),
|
||||
start: joi.date().required(),
|
||||
end: joi.date().required(),
|
||||
repeat: joi.number().required(),
|
||||
@@ -402,6 +404,8 @@ const editMaintenanceByIdWindowBodyValidation = joi.object({
|
||||
end: joi.date(),
|
||||
expiry: joi.date(),
|
||||
monitors: joi.array(),
|
||||
duration: joi.number(),
|
||||
durationUnit: joi.string().valid("seconds", "minutes", "hours", "days"),
|
||||
});
|
||||
|
||||
//****************************************
|
||||
|
||||
Reference in new issue
Block a user