diff --git a/client/src/Components/Inputs/Search/index.jsx b/client/src/Components/Inputs/Search/index.jsx index e5654faeb..4624b2cba 100644 --- a/client/src/Components/Inputs/Search/index.jsx +++ b/client/src/Components/Inputs/Search/index.jsx @@ -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 ( 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 = ({ )} 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 ( + {multiple && isAdorned && !option.noOptions && ( + + )} {option[filteredBy] + (hasSecondaryLabel ? ` (${option[secondaryLabel]}${port ? `: ${port}` : ""})` diff --git a/client/src/Components/MonitorDetailsControlHeader/index.jsx b/client/src/Components/MonitorDetailsControlHeader/index.jsx index d569ec840..5ad665069 100644 --- a/client/src/Components/MonitorDetailsControlHeader/index.jsx +++ b/client/src/Components/MonitorDetailsControlHeader/index.jsx @@ -68,6 +68,9 @@ const MonitorDetailsControlHeader = ({ onClick={() => { testAllNotifications({ monitorId: monitor?._id }); }} + sx={{ + whiteSpace: "nowrap", + }} > {t("sendTestNotifications")} diff --git a/client/src/Components/MonitorDetailsControlHeader/status.jsx b/client/src/Components/MonitorDetailsControlHeader/status.jsx index fdfb955f4..65671a609 100644 --- a/client/src/Components/MonitorDetailsControlHeader/status.jsx +++ b/client/src/Components/MonitorDetailsControlHeader/status.jsx @@ -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)} > - - {monitor?.url?.replace(/^https?:\/\//, "") || "..."} - + {formatMonitorUrl(monitor?.url)} Checking every {formatDurationRounded(monitor?.interval)}. diff --git a/client/src/Hooks/checkHooks.js b/client/src/Hooks/checkHooks.js index dac2be671..683b89b20 100644 --- a/client/src/Hooks/checkHooks.js +++ b/client/src/Hooks/checkHooks.js @@ -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 }; diff --git a/client/src/Pages/Incidents/Components/IncidentTable/index.jsx b/client/src/Pages/Incidents/Components/IncidentTable/index.jsx index cf6fbb49d..3b2e07238 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable/index.jsx +++ b/client/src/Pages/Incidents/Components/IncidentTable/index.jsx @@ -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", diff --git a/client/src/Pages/Uptime/Details/index.jsx b/client/src/Pages/Uptime/Details/index.jsx index 79762e0c3..5f36ee7e5 100644 --- a/client/src/Pages/Uptime/Details/index.jsx +++ b/client/src/Pages/Uptime/Details/index.jsx @@ -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 = () => { diff --git a/client/src/Utils/utils.js b/client/src/Utils/utils.js index 8bd9c9019..81ec44b06 100644 --- a/client/src/Utils/utils.js +++ b/client/src/Utils/utils.js @@ -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; +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 5649f3de7..3ae8c3d75 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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" +} \ No newline at end of file diff --git a/server/controllers/authController.js b/server/controllers/authController.js index a1a35e616..df4034aef 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -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; } /** diff --git a/server/controllers/settingsController.js b/server/controllers/settingsController.js index cbdd73adf..ef1d689d6 100755 --- a/server/controllers/settingsController.js +++ b/server/controllers/settingsController.js @@ -117,7 +117,7 @@ class SettingsController { data: { messageId }, }); } catch (error) { - next(handleValidationError(error, SERVICE_NAME)); + next(handleError(error, SERVICE_NAME)); return; } }; diff --git a/server/index.js b/server/index.js index b520e6171..0753af995 100755 --- a/server/index.js +++ b/server/index.js @@ -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),