Merge branch 'develop' into refactor/notification-service

This commit is contained in:
Alex Holliday
2025-06-19 10:34:14 +08:00
11 changed files with 225 additions and 89 deletions
+86 -4
View File
@@ -1,7 +1,17 @@
import PropTypes from "prop-types";
import { Box, ListItem, Autocomplete, TextField, Stack, Typography } from "@mui/material";
import {
Box,
ListItem,
Autocomplete,
TextField,
Stack,
Typography,
Checkbox,
} from "@mui/material";
import { useTheme } from "@emotion/react";
import SearchIcon from "../../../assets/icons/search.svg?react";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
/**
* Search component using Material UI's Autocomplete.
@@ -60,24 +70,72 @@ const Search = ({
onBlur,
}) => {
const theme = useTheme();
const { t } = useTranslation();
const [selectAll, setSelectAll] = React.useState(false);
const [open, setOpen] = React.useState(false);
const enhancedOptions = React.useMemo(() => {
return multiple && isAdorned
? [
{ [filteredBy]: t("selectAll"), isSelectAll: true, _id: "select_all" },
...options,
]
: options;
}, [multiple, isAdorned, options, filteredBy]);
const isOptionSelected = (option) => {
if (!multiple && !isAdorned) return false;
if (Array.isArray(value)) {
return value.some((item) => item._id === option._id);
}
return false;
};
const handleSelectAll = (isSelectAll) => {
const newValue = isSelectAll ? [...options] : [];
handleChange(newValue);
setSelectAll(isSelectAll);
};
useEffect(() => {
const allSelected =
Array.isArray(value) && Array.isArray(options) && value.length === options.length;
if (selectAll !== allSelected) setSelectAll(allSelected);
}, [value, options]);
return (
<Autocomplete
onBlur={onBlur}
multiple={multiple}
id={id}
value={value}
open={open}
onOpen={() => setOpen(true)}
onClose={(event, reason) => {
if (reason === "blur" || reason === "escape") {
setOpen(false);
}
}}
inputValue={inputValue}
onInputChange={(_, newValue) => {
handleInputChange(newValue);
}}
onChange={(_, newValue) => {
handleChange(newValue);
if (multiple && isAdorned) {
const hasSelectAllSelected =
Array.isArray(newValue) && newValue.some((item) => item.isSelectAll);
if (hasSelectAllSelected) {
handleSelectAll(!selectAll);
} else {
handleChange(newValue);
setSelectAll(Array.isArray(newValue) && newValue.length === options.length);
}
} else {
handleChange(newValue);
setOpen(false);
}
}}
fullWidth
freeSolo
disabled={disabled}
disableClearable
options={options}
options={enhancedOptions}
getOptionLabel={(option) => option[filteredBy]}
isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier
renderInput={(params) => (
@@ -120,6 +178,9 @@ const Search = ({
</Stack>
)}
filterOptions={(options, { inputValue }) => {
if (inputValue.trim() === "" && multiple && isAdorned) {
return enhancedOptions;
}
const filtered = options.filter((option) =>
option[filteredBy].toLowerCase().includes(inputValue.toLowerCase())
);
@@ -136,6 +197,7 @@ const Search = ({
const { key, ...optionProps } = props;
const hasSecondaryLabel = secondaryLabel && option[secondaryLabel] !== undefined;
const port = option["port"];
const selected = isOptionSelected(option);
return (
<ListItem
key={key}
@@ -146,9 +208,29 @@ const Search = ({
pointerEvents: "none",
backgroundColor: theme.palette.primary.main,
}
: {}
: option.isSelectAll
? {
fontWeight: "bold",
backgroundColor: theme.palette.primary.light,
"&:hover": {
backgroundColor: theme.palette.primary.light,
},
}
: {}
}
>
{multiple && isAdorned && !option.noOptions && (
<Checkbox
checked={option.isSelectAll ? selectAll : selected}
sx={{
color: theme.palette.primary.contrastTextSecondary,
"&.Mui-checked": {
color: theme.palette.secondary.main,
},
padding: 0,
}}
/>
)}
{option[filteredBy] +
(hasSecondaryLabel
? ` (${option[secondaryLabel]}${port ? `: ${port}` : ""})`
@@ -68,6 +68,9 @@ const MonitorDetailsControlHeader = ({
onClick={() => {
testAllNotifications({ monitorId: monitor?._id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("sendTestNotifications")}
</Button>
@@ -8,6 +8,7 @@ import { formatDurationRounded } from "../../Utils/timeUtils";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "../../Hooks/useMonitorUtils";
import { formatMonitorUrl } from "../../Utils/utils";
/**
* Status component displays the status information of a monitor.
* It includes the monitor's name, URL, and check interval.
@@ -33,9 +34,7 @@ const Status = ({ monitor }) => {
gap={theme.spacing(4)}
>
<PulseDot color={statusColor[determineState(monitor)]} />
<Typography variant="monitorUrl">
{monitor?.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography variant="monitorUrl">{formatMonitorUrl(monitor?.url)}</Typography>
<Dot />
<Typography>
Checking every {formatDurationRounded(monitor?.interval)}.
+67 -32
View File
@@ -2,8 +2,57 @@ import { useState, useEffect } from "react";
import { networkService } from "../main";
import { createToast } from "../Utils/toastUtils";
const useFetchChecks = ({
teamId,
const useFetchChecksTeam = ({
status,
sortOrder,
limit,
dateRange,
filter,
page,
rowsPerPage,
enabled = true,
}) => {
const [checks, setChecks] = useState(undefined);
const [checksCount, setChecksCount] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [networkError, setNetworkError] = useState(false);
useEffect(() => {
const fetchChecks = async () => {
if (!enabled) {
return;
}
const config = {
status,
sortOrder,
limit,
dateRange,
filter,
page,
rowsPerPage,
};
try {
setIsLoading(true);
const res = await networkService.getChecksByTeam(config);
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
setNetworkError(true);
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchChecks();
}, [status, sortOrder, limit, dateRange, filter, page, rowsPerPage, enabled]);
return [checks, checksCount, isLoading, networkError];
};
const useFetchChecksByMonitor = ({
monitorId,
type,
status,
@@ -13,6 +62,7 @@ const useFetchChecks = ({
filter,
page,
rowsPerPage,
enabled = true,
}) => {
const [checks, setChecks] = useState(undefined);
const [checksCount, setChecksCount] = useState(undefined);
@@ -21,40 +71,25 @@ const useFetchChecks = ({
useEffect(() => {
const fetchChecks = async () => {
if (!type && !teamId) {
if (!enabled || !type) {
return;
}
const method = monitorId
? networkService.getChecksByMonitor
: networkService.getChecksByTeam;
const config = monitorId
? {
monitorId,
type,
status,
sortOrder,
limit,
dateRange,
filter,
page,
rowsPerPage,
}
: {
status,
teamId,
sortOrder,
limit,
dateRange,
filter,
page,
rowsPerPage,
};
const config = {
monitorId,
type,
status,
sortOrder,
limit,
dateRange,
filter,
page,
rowsPerPage,
};
try {
setIsLoading(true);
const res = await method(config);
const res = await networkService.getChecksByMonitor(config);
setChecks(res.data.data.checks);
setChecksCount(res.data.data.checksCount);
} catch (error) {
@@ -68,7 +103,6 @@ const useFetchChecks = ({
fetchChecks();
}, [
monitorId,
teamId,
type,
status,
sortOrder,
@@ -77,9 +111,10 @@ const useFetchChecks = ({
filter,
page,
rowsPerPage,
enabled,
]);
return [checks, checksCount, isLoading, networkError];
};
export { useFetchChecks };
export { useFetchChecksByMonitor, useFetchChecksTeam };
@@ -10,10 +10,11 @@ import NetworkError from "../../../../Components/GenericFallback/NetworkError";
//Utils
import { formatDateWithTz } from "../../../../Utils/timeUtils";
import { useSelector } from "react-redux";
import { useState, useEffect } from "react";
import { useState } from "react";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useFetchChecks } from "../../../../Hooks/checkHooks";
import { useFetchChecksTeam } from "../../../../Hooks/checkHooks";
import { useFetchChecksByMonitor } from "../../../../Hooks/checkHooks";
const IncidentTable = ({
shouldRender,
@@ -26,26 +27,41 @@ const IncidentTable = ({
const uiTimezone = useSelector((state) => state.ui.timezone);
//Local state
const [teamId, setTeamId] = useState(undefined);
const [monitorId, setMonitorId] = useState(undefined);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const selectedMonitorDetails = monitors?.[selectedMonitor];
const selectedMonitorType = selectedMonitorDetails?.type;
const [checks, checksCount, isLoading, networkError] = useFetchChecks({
status: false,
monitorId,
teamId,
type: selectedMonitorType,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter,
page: page,
rowsPerPage: rowsPerPage,
});
const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] =
useFetchChecksByMonitor({
monitorId: selectedMonitor === "0" ? undefined : selectedMonitor,
type: selectedMonitorType,
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor !== "0",
});
const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] =
useFetchChecksTeam({
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor === "0",
});
const checks = selectedMonitor === "0" ? checksTeam : checksMonitor;
const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor;
const isLoading = isLoadingTeam || isLoadingMonitor;
const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor;
const { t } = useTranslation();
@@ -58,16 +74,6 @@ const IncidentTable = ({
setRowsPerPage(event.target.value);
};
useEffect(() => {
if (selectedMonitor === "0") {
setTeamId("placeholder"); // TODO this isn't needed any longer, fix hook
setMonitorId(undefined);
} else {
setMonitorId(selectedMonitor);
setTeamId(undefined);
}
}, [selectedMonitor]);
const headers = [
{
id: "monitorName",
+12 -11
View File
@@ -18,7 +18,7 @@ import { useTheme } from "@emotion/react";
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
import { useFetchUptimeMonitorById } from "../../../Hooks/monitorHooks";
import useCertificateFetch from "./Hooks/useCertificateFetch";
import { useFetchChecks } from "../../../Hooks/checkHooks";
import { useFetchChecksByMonitor } from "../../../Hooks/checkHooks";
import { useTranslation } from "react-i18next";
// Constants
@@ -66,16 +66,17 @@ const UptimeDetails = () => {
const monitorType = monitor?.type;
const [checks, checksCount, checksAreLoading, checksNetworkError] = useFetchChecks({
monitorId,
type: monitorType,
sortOrder: "desc",
limit: null,
dateRange,
filter: null,
page,
rowsPerPage,
});
const [checks, checksCount, checksAreLoading, checksNetworkError] =
useFetchChecksByMonitor({
monitorId,
type: monitorType,
sortOrder: "desc",
limit: null,
dateRange,
filter: null,
page,
rowsPerPage,
});
// Handlers
const triggerUpdate = () => {
+8
View File
@@ -5,3 +5,11 @@ export const safelyParseFloat = (value) => {
}
return parsedValue;
};
export const formatMonitorUrl = (url, maxLength = 55) => {
if (!url) return "";
const strippedUrl = url.replace(/^https?:\/\//, "");
return strippedUrl.length > maxLength
? `${strippedUrl.slice(0, maxLength)}`
: strippedUrl;
};
+3 -2
View File
@@ -744,5 +744,6 @@
"settingsEmailRejectUnauthorized": "Reject Unauthorized",
"settingsEmailSecure": "Secure - Use SSL",
"settingsEmailPool": "Pool - Enable connection pooling",
"sendTestNotifications": "Send test notifications"
}
"sendTestNotifications": "Send test notifications",
"selectAll": "Select all"
}
+2 -2
View File
@@ -7,7 +7,6 @@ import {
recoveryTokenValidation,
newPasswordValidation,
} from "../validation/joi.js";
import logger from "../utils/logger.js";
import jwt from "jsonwebtoken";
import { getTokenFromHeaders } from "../utils/utils.js";
import crypto from "crypto";
@@ -15,12 +14,13 @@ import { handleValidationError, handleError } from "./controllerUtils.js";
const SERVICE_NAME = "authController";
class AuthController {
constructor(db, settingsService, emailService, jobQueue, stringService) {
constructor({ db, settingsService, emailService, jobQueue, stringService, logger }) {
this.db = db;
this.settingsService = settingsService;
this.emailService = emailService;
this.jobQueue = jobQueue;
this.stringService = stringService;
this.logger = logger;
}
/**
+1 -1
View File
@@ -117,7 +117,7 @@ class SettingsController {
data: { messageId },
});
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
next(handleError(error, SERVICE_NAME));
return;
}
};
+8 -7
View File
@@ -239,13 +239,14 @@ const startApp = async () => {
process.on("SIGTERM", shutdown);
//Create controllers
const authController = new AuthController(
ServiceRegistry.get(MongoDB.SERVICE_NAME),
ServiceRegistry.get(SettingsService.SERVICE_NAME),
ServiceRegistry.get(EmailService.SERVICE_NAME),
ServiceRegistry.get(JobQueue.SERVICE_NAME),
ServiceRegistry.get(StringService.SERVICE_NAME)
);
const authController = new AuthController({
db: ServiceRegistry.get(MongoDB.SERVICE_NAME),
settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME),
emailService: ServiceRegistry.get(EmailService.SERVICE_NAME),
jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME),
stringService: ServiceRegistry.get(StringService.SERVICE_NAME),
logger: logger,
});
const monitorController = new MonitorController(
ServiceRegistry.get(MongoDB.SERVICE_NAME),