From 4645bde0250d4a9cb476c01757d9f119a4cf6a35 Mon Sep 17 00:00:00 2001 From: llpingll <94099281+llpingll@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:19:21 +0000 Subject: [PATCH 001/392] Allow all users to change password and handle expired sessions --- client/package-lock.json | 2 +- .../Pages/Account/components/PasswordPanel.jsx | 16 ++++++++++++++-- client/src/Pages/Account/index.jsx | 2 +- client/src/locales/en.json | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 7f5397b7f..282ab2611 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -38,7 +38,7 @@ "react-toastify": "^10.0.5", "recharts": "2.15.2", "redux-persist": "6.0.0", - "swr": "2.3.6", + "swr": "^2.3.6", "vite-plugin-svgr": "^4.2.0", "zod": "4.1.11" }, diff --git a/client/src/Pages/Account/components/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx index f54c61a16..f06555dcd 100644 --- a/client/src/Pages/Account/components/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -11,6 +11,8 @@ import { useDispatch, useSelector } from "react-redux"; import { createToast } from "../../../Utils/toastUtils.jsx"; import { getTouchedFieldErrors } from "../../../Validation/error.js"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { clearAuthState } from "../../../Features/Auth/authSlice.js"; const defaultPasswordsState = { password: "", @@ -28,11 +30,12 @@ const PasswordPanel = () => { const theme = useTheme(); const dispatch = useDispatch(); const { t } = useTranslation(); + const navigate = useNavigate(); const SPACING_GAP = theme.spacing(12); //redux state - const { isLoading } = useSelector((state) => state.auth); + const { isLoading, authToken } = useSelector((state) => state.auth); const idToName = { "edit-current-password": "password", @@ -79,6 +82,16 @@ const PasswordPanel = () => { const handleSubmit = async (event) => { event.preventDefault(); + // Check for expired/invalid token before submitting + if (!authToken) { + createToast({ + body: t("passwordPanel.sessionExpired", "Your session has expired. Please log in again."), + }); + dispatch(clearAuthState()); + navigate("/login"); + return; + } + const { error } = newOrChangedCredentials.validate(localData, { abortEarly: false, context: { password: localData.newPassword }, @@ -105,7 +118,6 @@ const PasswordPanel = () => { confirm: "", }); } else { - // TODO: Check for other errors? createToast({ body: t( "passwordPanel.passwordInputIncorrect", diff --git a/client/src/Pages/Account/index.jsx b/client/src/Pages/Account/index.jsx index ae16ff9d7..3bf480336 100644 --- a/client/src/Pages/Account/index.jsx +++ b/client/src/Pages/Account/index.jsx @@ -85,7 +85,7 @@ const Account = ({ open = "profile" }) => { ))} - {user.role.includes("superadmin") && } + {!hideTeams && } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index de02815b1..7e5eb7092 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -555,7 +555,7 @@ "enterNewPassword": "Enter your new password", "confirmNewPassword": "Confirm new password", "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`).", - "saving": "Saving..." + "sessionExpired": "Your session has expired, please log in again to continue." }, "emailSent": "Email sent successfully", "failedToSendEmail": "Failed to send email", From 14a08a303be5bb8142df51d382c2d523663dd10f Mon Sep 17 00:00:00 2001 From: llpingll <94099281+llpingll@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:45:34 +0000 Subject: [PATCH 002/392] Fix helper text to correctly use translation to display message not key --- client/src/Pages/Account/components/PasswordPanel.jsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Account/components/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx index f06555dcd..427c263ea 100644 --- a/client/src/Pages/Account/components/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -85,7 +85,10 @@ const PasswordPanel = () => { // Check for expired/invalid token before submitting if (!authToken) { createToast({ - body: t("passwordPanel.sessionExpired", "Your session has expired. Please log in again."), + body: t( + "passwordPanel.sessionExpired", + "Your session has expired. Please log in again." + ), }); dispatch(clearAuthState()); navigate("/login"); @@ -179,7 +182,7 @@ const PasswordPanel = () => { value={localData.password} onChange={handleChange} error={errors[idToName["edit-current-password"]] ? true : false} - helperText={errors[idToName["edit-current-password"]]} + helperText={t(errors[idToName["edit-current-password"]])} endAdornment={} flex={1} /> @@ -205,7 +208,7 @@ const PasswordPanel = () => { value={localData.newPassword} onChange={handleChange} error={errors[idToName["edit-new-password"]] ? true : false} - helperText={errors[idToName["edit-new-password"]]} + helperText={t(errors[idToName["edit-new-password"]])} endAdornment={} flex={1} /> @@ -231,7 +234,7 @@ const PasswordPanel = () => { value={localData.confirm} onChange={handleChange} error={errors[idToName["edit-confirm-password"]] ? true : false} - helperText={errors[idToName["edit-confirm-password"]]} + helperText={t(errors[idToName["edit-confirm-password"]])} endAdornment={} flex={1} /> From f564636cb435e6076f50f58fbf16c59c2adaa92c Mon Sep 17 00:00:00 2001 From: llpingll <94099281+llpingll@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:11:25 +0000 Subject: [PATCH 003/392] Fix missingKey warning with helper text --- .../Account/components/PasswordPanel.jsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Account/components/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx index 427c263ea..1c365804f 100644 --- a/client/src/Pages/Account/components/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -182,7 +182,12 @@ const PasswordPanel = () => { value={localData.password} onChange={handleChange} error={errors[idToName["edit-current-password"]] ? true : false} - helperText={t(errors[idToName["edit-current-password"]])} + helperText={ + //Only call t() if there is an error to prevent unnecessary localization calls + errors[idToName["edit-current-password"]] + ? t(errors[idToName["edit-current-password"]]) + : "" + } endAdornment={} flex={1} /> @@ -208,7 +213,12 @@ const PasswordPanel = () => { value={localData.newPassword} onChange={handleChange} error={errors[idToName["edit-new-password"]] ? true : false} - helperText={t(errors[idToName["edit-new-password"]])} + helperText={ + //Only call t() if there is an error to prevent unnecessary localization calls + errors[idToName["edit-new-password"]] + ? t(errors[idToName["edit-new-password"]]) + : "" + } endAdornment={} flex={1} /> @@ -234,7 +244,12 @@ const PasswordPanel = () => { value={localData.confirm} onChange={handleChange} error={errors[idToName["edit-confirm-password"]] ? true : false} - helperText={t(errors[idToName["edit-confirm-password"]])} + helperText={ + //Only call t() if there is an error to prevent unnecessary localization calls + errors[idToName["edit-confirm-password"]] + ? t(errors[idToName["edit-confirm-password"]]) + : "" + } endAdornment={} flex={1} /> From 4ce4a124d640bf5809577dc9e44ee20817d4c1ec Mon Sep 17 00:00:00 2001 From: llpingll <94099281+llpingll@users.noreply.github.com> Date: Sun, 25 Jan 2026 22:57:36 +0000 Subject: [PATCH 004/392] Refactor PasswordPanel to pass navigation context via state to ForgotPassword using TextLink | Update ForgotPassword and CheckEmail to handle navigation context for Back links, ensuring users return to the correct page after password reset | Fix TextLink prop usage to avoid warnings by passing state and href correctly | Ensure error messages and navigation links use translation keys for localization --- client/src/Components/v1/TextLink/index.jsx | 4 +++- .../Account/components/PasswordPanel.jsx | 13 +++++++++++-- client/src/Pages/Auth/CheckEmail.jsx | 13 ++++++++++--- client/src/Pages/Auth/ForgotPassword.jsx | 19 ++++++++++++++----- client/src/locales/en.json | 3 +++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/client/src/Components/v1/TextLink/index.jsx b/client/src/Components/v1/TextLink/index.jsx index 83ae251a5..e226250a6 100644 --- a/client/src/Components/v1/TextLink/index.jsx +++ b/client/src/Components/v1/TextLink/index.jsx @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import { useTheme } from "@emotion/react"; import { Link as RouterLink } from "react-router-dom"; -const TextLink = ({ text, linkText, href, target = "_self" }) => { +const TextLink = ({ text, linkText, href, state, target = "_self" }) => { const theme = useTheme(); return ( @@ -19,6 +19,7 @@ const TextLink = ({ text, linkText, href, target = "_self" }) => { to={href} component={RouterLink} target={target} + state={state} > {linkText} @@ -30,6 +31,7 @@ TextLink.propTypes = { text: PropTypes.string, linkText: PropTypes.string, href: PropTypes.string, + state: PropTypes.object, target: PropTypes.string, }; diff --git a/client/src/Pages/Account/components/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx index 1c365804f..7deb6dc8c 100644 --- a/client/src/Pages/Account/components/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -13,6 +13,8 @@ import { getTouchedFieldErrors } from "../../../Validation/error.js"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { clearAuthState } from "../../../Features/Auth/authSlice.js"; +import TextLink from "@/Components/v1/TextLink/index.jsx"; +import { useLocation } from "react-router-dom"; const defaultPasswordsState = { password: "", @@ -31,6 +33,7 @@ const PasswordPanel = () => { const dispatch = useDispatch(); const { t } = useTranslation(); const navigate = useNavigate(); + const location = useLocation(); const SPACING_GAP = theme.spacing(12); @@ -267,8 +270,15 @@ const PasswordPanel = () => { )} + - - - - - - - ); -}; - -OptionsHeader.propTypes = { - shouldRender: PropTypes.bool, - selectedMonitor: PropTypes.string, - setSelectedMonitor: PropTypes.func, - monitors: PropTypes.object, - filter: PropTypes.string, - setFilter: PropTypes.func, - dateRange: PropTypes.string, - setDateRange: PropTypes.func, -}; - -export default OptionsHeader; diff --git a/client/src/Pages/Checks/Components/OptionsHeader/skeleton.jsx b/client/src/Pages/Checks/Components/OptionsHeader/skeleton.jsx deleted file mode 100644 index 8a8990dc4..000000000 --- a/client/src/Pages/Checks/Components/OptionsHeader/skeleton.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Checks/Components/StatusBoxes/StatusBox.jsx b/client/src/Pages/Checks/Components/StatusBoxes/StatusBox.jsx deleted file mode 100644 index d7f02ada3..000000000 --- a/client/src/Pages/Checks/Components/StatusBoxes/StatusBox.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Stack, Typography } from "@mui/material"; -import Background from "@/assets/Images/background-grid.svg?react"; -import Icon from "@/Components/v1/Icon"; - -const StatusBox = ({ title, value, status }) => { - const theme = useTheme(); - let sharedStyles = { - position: "absolute", - right: 8, - "& svg": { - width: 20, - height: 20, - opacity: 0.9, - "& path": { stroke: theme.palette.primary.contrastTextTertiary, strokeWidth: 1.7 }, - }, - }; - - let color; - let icon; - if (status === "up") { - color = theme.palette.success.lowContrast; - icon = ( - - - - ); - } else if (status === "down") { - color = theme.palette.error.lowContrast; - icon = ( - - - - ); - } else if (status === "paused") { - color = theme.palette.warning.lowContrast; - icon = ( - - - - ); - } else { - color = theme.palette.accent.main; - icon = ( - - - - ); - } - - return ( - - - - - - - - {title} - - {icon} - - - {value} - - - # - - - - - ); -}; - -StatusBox.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - status: PropTypes.string, -}; - -export default StatusBox; diff --git a/client/src/Pages/Checks/Components/StatusBoxes/index.jsx b/client/src/Pages/Checks/Components/StatusBoxes/index.jsx deleted file mode 100644 index bb993111a..000000000 --- a/client/src/Pages/Checks/Components/StatusBoxes/index.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from "prop-types"; -import { Stack } from "@mui/material"; -import StatusBox from "./StatusBox.jsx"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import SkeletonLayout from "./skeleton.jsx"; - -const StatusBoxes = ({ isLoading, summary }) => { - const theme = useTheme(); - const { t } = useTranslation(); - if (isLoading) return ; - return ( - - - - - - - ); -}; - -StatusBoxes.propTypes = { - isLoading: PropTypes.bool, - summary: PropTypes.object, -}; - -export default StatusBoxes; diff --git a/client/src/Pages/Checks/Components/StatusBoxes/skeleton.jsx b/client/src/Pages/Checks/Components/StatusBoxes/skeleton.jsx deleted file mode 100644 index b9aa016b7..000000000 --- a/client/src/Pages/Checks/Components/StatusBoxes/skeleton.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -const SkeletonLayout = () => { - const theme = useTheme(); - return ( - - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Checks/old.jsx b/client/src/Pages/Checks/old.jsx deleted file mode 100644 index bcd036a58..000000000 --- a/client/src/Pages/Checks/old.jsx +++ /dev/null @@ -1,100 +0,0 @@ -// Components -import { Stack } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import IncidentTable from "./Components/IncidentTable/index.jsx"; -import OptionsHeader from "./Components/OptionsHeader/index.jsx"; -import StatusBoxes from "./Components/StatusBoxes/index.jsx"; - -//Utils -import { useTheme } from "@emotion/react"; -import { useFetchMonitorsByTeamId } from "@/Hooks/monitorHooks.js"; -import { useFetchChecksSummaryByTeamId } from "@/Hooks/checkHooks.js"; -import { useState, useEffect } from "react"; -import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; - -//Constants -const Checks = () => { - // Redux state - const { t } = useTranslation(); - - const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }]; - - // Local state - const [selectedMonitor, setSelectedMonitor] = useState("0"); - const [filter, setFilter] = useState(undefined); - const [dateRange, setDateRange] = useState("hour"); - const [monitorLookup, setMonitorLookup] = useState(undefined); - const [updateTrigger, setUpdateTrigger] = useState(false); - - //Hooks - - //Utils - const theme = useTheme(); - const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({}); - const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({ - dateRange, - updateTrigger, - }); - const { monitorId } = useParams(); - - useEffect(() => { - if (monitorId) { - setSelectedMonitor(monitorId); - } - }, [monitorId]); - - useEffect(() => { - const monitorLookup = monitors?.reduce((acc, monitor) => { - acc[monitor.id] = { - id: monitor.id, - name: monitor.name, - type: monitor.type, - }; - return acc; - }, {}); - setMonitorLookup(monitorLookup); - }, [monitors]); - - if (networkError || networkErrorSummary) { - return ( - - - - ); - } - - return ( - - - - - - - - ); -}; - -export default Checks; diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index 7916e2c06..e4fe01412 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -200,8 +200,28 @@ class MongoChecksRepository implements IChecksRepository { return documents.map((doc) => this.toEntity(doc)); }; + private toDocument = (check: Partial): CheckDocument => { + // Map id to _id for MongoDB storage + const { id, metadata, ...rest } = check; + return { + _id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(), + metadata: metadata ? { + monitorId: new mongoose.Types.ObjectId(metadata.monitorId), + teamId: new mongoose.Types.ObjectId(metadata.teamId), + type: metadata.type, + } : { + monitorId: new mongoose.Types.ObjectId(), + teamId: new mongoose.Types.ObjectId(), + type: "http", + }, + ...rest, + } as unknown as CheckDocument; + }; + createChecks = async (checks: Check[]) => { - return await CheckModel.insertMany(checks); + const docs = checks.map((check) => this.toDocument(check)); + const inserted = await CheckModel.insertMany(docs); + return this.mapDocuments(inserted as unknown as CheckDocument[]); }; findByMonitorId = async ( From 396d5b369bd2202f3abbd510732fdf31e1bc0e25 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 28 Jan 2026 20:20:18 +0000 Subject: [PATCH 024/392] remove unused --- .../Details/Components/Charts/AreaChart.jsx | 330 ----------------- .../Components/Charts/AreaChartLegend.jsx | 62 ---- .../Details/Components/Charts/PieChart.jsx | 327 ----------------- .../Components/Charts/PieChartLegend.jsx | 101 ------ .../Components/PageSpeedAreaChart/index.jsx | 60 ---- .../PageSpeedAreaChart/skeleton.jsx | 17 - .../Components/PageSpeedStatusBoxes/index.jsx | 49 --- .../Components/PerformanceReport/index.jsx | 61 ---- .../Components/PerformanceReport/skeleton.jsx | 17 - client/src/Pages/PageSpeed/Details/old.jsx | 120 ------- .../Monitors/Components/Card/index.jsx | 331 ------------------ .../Monitors/Components/MonitorGrid/index.jsx | 22 -- client/src/Pages/PageSpeed/Monitors/old.jsx | 86 ----- .../Details/Hooks/useCertificateFetch.jsx | 44 --- client/src/Pages/Uptime/Details/old.jsx | 190 ---------- .../Monitors/Components/Filter/index.jsx | 162 --------- .../Monitors/Components/Skeleton/index.jsx | 61 ---- .../Monitors/Components/StatusBoxes/index.jsx | 42 --- .../Components/StatusBoxes/skeleton.jsx | 31 -- .../Components/StatusBoxes/statusBox.jsx | 115 ------ .../Components/UptimeDataTable/index.jsx | 241 ------------- client/src/Pages/Uptime/Monitors/old.jsx | 225 ------------ 22 files changed, 2694 deletions(-) delete mode 100644 client/src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx delete mode 100644 client/src/Pages/PageSpeed/Details/old.jsx delete mode 100644 client/src/Pages/PageSpeed/Monitors/Components/Card/index.jsx delete mode 100644 client/src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx delete mode 100644 client/src/Pages/PageSpeed/Monitors/old.jsx delete mode 100644 client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx delete mode 100644 client/src/Pages/Uptime/Details/old.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/old.jsx diff --git a/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx deleted file mode 100644 index d8c118237..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx +++ /dev/null @@ -1,330 +0,0 @@ -import PropTypes from "prop-types"; -import { - AreaChart, - Area, - XAxis, - Tooltip, - CartesianGrid, - ResponsiveContainer, - Text, -} from "recharts"; -import { useTheme } from "@emotion/react"; -import { useMemo, useState } from "react"; -import { Box, Stack, Typography } from "@mui/material"; -import { formatDateWithTz } from "../../../../../Utils/timeUtilsLegacy.js"; -import { useSelector } from "react-redux"; - -const config = { - seo: { - id: "seo", - text: "SEO", - color: "secondary", - }, - performance: { - id: "performance", - text: "performance", - color: "success", - }, - bestPractices: { - id: "bestPractices", - text: "best practices", - color: "warning", - }, - accessibility: { - id: "accessibility", - text: "accessibility", - color: "accent", - }, -}; - -/** - * Custom tooltip for the area chart. - * @param {Object} props - * @param {boolean} props.active - Whether the tooltip is active. - * @param {Array} props.payload - The payload data for the tooltip. - * @param {string} props.label - The label for the tooltip. - * @returns {JSX.Element|null} The tooltip element or null if not active. - */ - -const CustomToolTip = ({ active, payload, label, config }) => { - const theme = useTheme(); - const uiTimezone = useSelector((state) => state.ui.timezone); - - if (active && payload && payload.length) { - return ( - - - {formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)} - - {Object.keys(config) - .reverse() - .map((key) => { - const { color } = config[key]; - const dotColor = theme.palette[color].main; - - return ( - - - - {config[key].text} - {" "} - {payload[0].payload[key]} - - ); - })} - - ); - } - return null; -}; - -CustomToolTip.propTypes = { - active: PropTypes.bool, - payload: PropTypes.array, - label: PropTypes.string, - config: PropTypes.object, -}; - -/** - * Processes data to insert gaps with null values based on the interval. - * @param {Array} data - * @param {number} interval - The interval in milliseconds for gaps. - * @returns {Array} The formatted data with gaps. - */ -const processDataWithGaps = (data, interval) => { - if (data.length === 0) return []; - let formattedData = []; - let last = new Date(data[0].createdAt).getTime(); - - // Helper function to add a null entry - const addNullEntry = (timestamp) => { - formattedData.push({ - accessibility: "N/A", - bestPractices: "N/A", - performance: "N/A", - seo: "N/A", - createdAt: timestamp, - }); - }; - - data.forEach((entry) => { - const current = new Date(entry.createdAt).getTime(); - - if (current - last > interval * 2) { - // Insert null entries for each interval - let temp = last + interval; - while (temp < current) { - addNullEntry(new Date(temp).toISOString()); - temp += interval; - } - } - - formattedData.push(entry); - last = current; - }); - - return formattedData; -}; - -/** - * Custom tick component to render ticks on the XAxis. - * - * @param {Object} props - * @param {number} props.x - The x coordinate for the tick. - * @param {number} props.y - The y coordinate for the tick. - * @param {Object} props.payload - The data object containing the tick value. - * @param {number} props.index - The index of the tick in the array of ticks. - * - * @returns {JSX.Element|null} The tick element or null if the tick should be hidden. - */ -const CustomTick = ({ x, y, payload, index }) => { - const theme = useTheme(); - const uiTimezone = useSelector((state) => state.ui.timezone); - - // Render nothing for the first tick - if (index === 0) return null; - - return ( - - {formatDateWithTz(payload?.value, "h:mm a", uiTimezone)} - - ); -}; -CustomTick.propTypes = { - x: PropTypes.number, - y: PropTypes.number, - payload: PropTypes.shape({ - value: PropTypes.string.isRequired, - }), - index: PropTypes.number, -}; - -/** - * A chart displaying pagespeed details over time. - * @param {Object} props - * @param {Array} props.data - The data to display in the chart. - * @param {number} props.interval - The interval in milliseconds for processing gaps. - * @returns {JSX.Element} The area chart component. - */ - -const PageSpeedAreaChart = ({ data, monitor, metrics }) => { - const theme = useTheme(); - const [isHovered, setIsHovered] = useState(false); - const memoizedData = useMemo( - () => processDataWithGaps(data, monitor.interval), - [data[0]] - ); - - const filteredConfig = Object.keys(config).reduce((result, key) => { - if (metrics[key]) { - result[key] = config[key]; - } - return result; - }, {}); - - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - } - axisLine={false} - tickLine={false} - height={18} - minTickGap={0} - interval="equidistantPreserveStart" - /> - } - /> - - {Object.values(filteredConfig).map(({ id, color }) => { - /* TODO not working? */ - const startColor = theme.palette[color].main; - const endColor = theme.palette[color].lowContrast; - - return ( - - - - - ); - })} - - {Object.keys(filteredConfig).map((key) => { - const { color } = filteredConfig[key]; - const strokeColor = theme.palette[color].main; - const bgColor = theme.palette.primary.main; - - return ( - - ); - })} - - - ); -}; - -PageSpeedAreaChart.propTypes = { - data: PropTypes.arrayOf( - PropTypes.shape({ - createdAt: PropTypes.string.isRequired, - accessibility: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - bestPractices: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - performance: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - seo: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - }) - ).isRequired, - monitor: PropTypes.object.isRequired, - metrics: PropTypes.object, -}; - -export default PageSpeedAreaChart; diff --git a/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx deleted file mode 100644 index 525e4d0d2..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Box, Typography, Divider } from "@mui/material"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import LegendBox from "@/Components/v1/Charts/LegendBox/index.jsx"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "@emotion/react"; - -const AreaChartLegend = ({ metrics, handleMetrics }) => { - const theme = useTheme(); - const { t } = useTranslation(); - return ( - - } - header="Metrics" - > - - - {t("shown")} - - - - handleMetrics("accessibility")} - /> - - handleMetrics("bestPractices")} - /> - - handleMetrics("performance")} - /> - - handleMetrics("seo")} - /> - - - ); -}; - -export default AreaChartLegend; diff --git a/client/src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx deleted file mode 100644 index eec075684..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx +++ /dev/null @@ -1,327 +0,0 @@ -import PropTypes from "prop-types"; -import { PieChart as MuiPieChart } from "@mui/x-charts/PieChart"; -import { useDrawingArea } from "@mui/x-charts"; -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { Box } from "@mui/material"; - -/** - * Renders a centered label within a pie chart. - * - * @param {Object} props - * @param {string | number} props.value - The value to display in the label. - * @param {string} props.color - The color of the text. - * @returns {JSX.Element} - */ -const PieCenterLabel = ({ value, color, setExpand }) => { - const { width, height } = useDrawingArea(); - return ( - setExpand(true)} - > - - - {value} - - - ); -}; - -PieCenterLabel.propTypes = { - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - color: PropTypes.string, - setExpand: PropTypes.func, -}; - -/** - * A component that renders a label on a pie chart slice. - * The label is positioned relative to the center of the pie chart and is optionally highlighted. - * - * @param {Object} props - * @param {number} props.value - The value to display inside the pie slice. - * @param {number} props.startAngle - The starting angle of the pie slice in degrees. - * @param {number} props.endAngle - The ending angle of the pie slice in degrees. - * @param {string} props.color - The color of the label text when highlighted. - * @param {boolean} props.highlighted - Determines if the label should be highlighted or not. - * @returns {JSX.Element} - */ -const PieValueLabel = ({ value, startAngle, endAngle, color, highlighted }) => { - const { width, height } = useDrawingArea(); - - // Compute the midpoint angle in radians - const angle = (((startAngle + endAngle) / 2) * Math.PI) / 180; - const radius = height / 4; // length from center of the circle to where the text is positioned - - // Calculate x and y positions - const x = Math.sin(angle) * radius; - const y = -Math.cos(angle) * radius; - - return ( - - - +{value} - - - ); -}; - -// Validate props using PropTypes -PieValueLabel.propTypes = { - value: PropTypes.number.isRequired, - startAngle: PropTypes.number.isRequired, - endAngle: PropTypes.number.isRequired, - color: PropTypes.string.isRequired, - highlighted: PropTypes.bool.isRequired, -}; - -/** - * Weight constants for different performance metrics. - * @type {Object} - */ -const weights = { - fcp: 10, - si: 10, - lcp: 25, - tbt: 30, - cls: 25, -}; - -const PieChart = ({ audits }) => { - const theme = useTheme(); - const [highlightedItem, setHighLightedItem] = useState(null); - const [expand, setExpand] = useState(false); - - /** - * Retrieves color properties based on the performance value. - * - * @param {number} value - The performance score used to determine the color properties. - * @returns {{stroke: string, strokeBg: string, text: string, bg: string}} The color properties for the given performance value. - */ - const getColors = (value) => { - if (value >= 90 && value <= 100) - return { - stroke: theme.palette.success.main, - strokeBg: theme.palette.success.lowContrast, - text: theme.palette.success.contrastText, - bg: theme.palette.success.lowContrast, - }; - else if (value >= 50 && value < 90) - return { - stroke: theme.palette.warning.main, - strokeBg: theme.palette.warning.lowContrast, - text: theme.palette.warning.contrastText, - bg: theme.palette.warning.lowContrast, - }; - else if (value >= 0 && value < 50) - return { - stroke: theme.palette.error.contrastText, - strokeBg: theme.palette.error.lowContrast, - text: theme.palette.error.contrastText, - bg: theme.palette.error.lowContrast, - }; - return { - stroke: theme.palette.tertiary.contrastText, - strokeBg: theme.palette.tertiary.contrastText, - text: theme.palette.tertiary.contrastText, - bg: theme.palette.tertiary.main, - }; - }; - - /** - * Calculates and formats the data needed for rendering a pie chart based on audit scores and weights. - * This function generates properties for each pie slice, including angles, radii, and colors. - * It also calculates performance based on the weighted values. - * - * @returns {Array} An array of objects, each representing the properties for a slice of the pie chart. - * @returns {number} performance - A variable updated with the rounded sum of weighted values. - */ - let performance = 0; - const getPieData = (audits) => { - if (typeof audits === "undefined") return undefined; - - let data = []; - let startAngle = 0; - const padding = 3; // padding between arcs - const max = 360 - padding * (Object.keys(audits).length - 1); // _id is a child of audits - - Object.keys(audits).forEach((key) => { - if (audits[key].score) { - let value = audits[key].score * weights[key]; - let endAngle = startAngle + (weights[key] * max) / 100; - - let theme = getColors(audits[key].score * 100); - data.push({ - id: key, - data: [ - { - value: value, - color: theme.stroke, - label: key.toUpperCase(), - }, - { - value: weights[key] - value, - color: theme.strokeBg, - label: "", - }, - ], - arcLabel: (item) => `${item.label}`, - arcLabelRadius: 95, - startAngle: startAngle, - endAngle: endAngle, - innerRadius: 73, - outerRadius: 80, - cornerRadius: 2, - highlightScope: { faded: "global", highlighted: "series" }, - faded: { - innerRadius: 73, - outerRadius: 80, - additionalRadius: -20, - arcLabelRadius: 5, - }, - cx: pieSize.width / 2, - }); - - performance += Math.floor(value); - startAngle = endAngle + padding; - } - }); - - return data; - }; - - const pieSize = { width: 230, height: 230 }; - const pieData = getPieData(audits); - const colorMap = getColors(performance); - - return ( - setExpand(false)} - sx={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - width: "100%", - }} - > - {expand ? ( - - - {pieData?.map((pie) => ( - - ))} - - ) : ( - - - - )} - - ); -}; - -PieChart.propTypes = { - audits: PropTypes.object, -}; - -export default PieChart; diff --git a/client/src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx deleted file mode 100644 index 5cfdad608..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Stack, Box, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import LegendBox from "@/Components/v1/Charts/LegendBox/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import PropTypes from "prop-types"; - -const PieChartLegend = ({ audits }) => { - const theme = useTheme(); - - return ( - - } - header="Performance metrics" - sx={{ flex: 1 }} - > - {typeof audits !== "undefined" && - Object.keys(audits).map((key) => { - if (key === "_id") return; - - let audit = audits[key]; - let score = audit.score * 100; - let bg = - score >= 90 - ? theme.palette.success.main - : score >= 50 - ? theme.palette.warning.main - : score >= 0 - ? theme.palette.error.main - : theme.palette.tertiary.main; - - // Find the position where the number ends and the unit begins - const match = audit?.displayValue?.match(/(\d+\.?\d*)\s*([a-zA-Z]+)/); - let value; - let unit; - if (match) { - value = match[1]; - match[2] === "s" ? (unit = "seconds") : (unit = match[2]); - } else { - value = audit.displayValue; - } - - return ( - - - - {audit.title} - - - {value} - - {unit} - - - - - - ); - })} - - ); -}; - -PieChartLegend.propTypes = { - audits: PropTypes.object, -}; - -export default PieChartLegend; diff --git a/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx b/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx deleted file mode 100644 index 9020ddd6d..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import ChartBox from "@/Components/v1/Charts/ChartBox/index.jsx"; -import AreaChart from "../Charts/AreaChart.jsx"; -import AreaChartLegend from "../Charts/AreaChartLegend.jsx"; -import SkeletonLayout from "./skeleton.jsx"; -import Icon from "@/Components/v1/Icon"; -import { Stack } from "@mui/material"; -import PropTypes from "prop-types"; - -import { useTheme } from "@emotion/react"; - -const PageSpeedAreaChart = ({ shouldRender, monitor, metrics, handleMetrics }) => { - const theme = useTheme(); - - if (!shouldRender) { - return ; - } - - const data = monitor?.checks ? [...monitor.checks].reverse() : []; - - return ( - - - } - header="Score history" - height="100%" - borderRadiusRight={16} - Legend={ - - } - > - - - - ); -}; - -PageSpeedAreaChart.propTypes = { - shouldRender: PropTypes.bool, - monitor: PropTypes.object, - metrics: PropTypes.object, - handleMetrics: PropTypes.func, -}; - -export default PageSpeedAreaChart; diff --git a/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx b/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx deleted file mode 100644 index b793908d4..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Box, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx b/client/src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx deleted file mode 100644 index 6ff26c739..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx"; -import StatBox from "@/Components/v1/StatBox/index.jsx"; -import { Typography } from "@mui/material"; -import { getHumanReadableDuration } from "../../../../../Utils/timeUtilsLegacy.js"; -import { useTranslation } from "react-i18next"; - -const PageSpeedStatusBoxes = ({ shouldRender, monitorStats }) => { - const lastChecked = monitorStats?.lastCheckTimestamp - ? Date.now() - monitorStats.lastCheckTimestamp - : 0; - - // Determine time since last failure - const timeOfLastFailure = monitorStats?.timeOfLastFailure; - const timeSinceLastFailure = - timeOfLastFailure > 0 - ? Date.now() - timeOfLastFailure - : Date.now() - new Date(monitorStats?.createdAt); - - const streakTime = getHumanReadableDuration(timeSinceLastFailure); - - const time = getHumanReadableDuration(lastChecked); - - const { t } = useTranslation(); - - return ( - - - {streakTime} - {t("ago")} - - } - /> - - {time} - {t("ago")} - - } - /> - - ); -}; - -export default PageSpeedStatusBoxes; diff --git a/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx b/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx deleted file mode 100644 index a2e1b5f96..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import ChartBox from "@/Components/v1/Charts/ChartBox/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import PieChart from "../Charts/PieChart.jsx"; -import { Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import PieChartLegend from "../Charts/PieChartLegend.jsx"; -import SkeletonLayout from "./skeleton.jsx"; -import { useTranslation } from "react-i18next"; - -const PerformanceReport = ({ shouldRender, audits }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - if (!shouldRender) { - return ; - } - - return ( - - } - header="Performance report" - Legend={} - borderRadiusRight={16} - > - - - {t("pageSpeedDetailsPerformanceReport")}{" "} - - {t("pageSpeedDetailsPerformanceReportCalculator")} - - - - ); -}; - -export default PerformanceReport; diff --git a/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx b/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx deleted file mode 100644 index b793908d4..000000000 --- a/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Box, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/PageSpeed/Details/old.jsx b/client/src/Pages/PageSpeed/Details/old.jsx deleted file mode 100644 index 3190787b3..000000000 --- a/client/src/Pages/PageSpeed/Details/old.jsx +++ /dev/null @@ -1,120 +0,0 @@ -// Components -import { Stack, Typography } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx"; -import PageSpeedStatusBoxes from "./Components/PageSpeedStatusBoxes/index.jsx"; -import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx"; -import PageSpeedAreaChart from "./Components/PageSpeedAreaChart/index.jsx"; -import PerformanceReport from "./Components/PerformanceReport/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useParams } from "react-router-dom"; -import { useFetchPageSpeedMonitorById } from "../../../Hooks/monitorHooks.js"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -// Constants -const BREADCRUMBS = [ - { name: "pagespeed", path: "/pagespeed" }, - { name: "details", path: `` }, -]; - -const PageSpeedDetails = () => { - const theme = useTheme(); - const { t } = useTranslation(); - const isAdmin = useIsAdmin(); - const { monitorId } = useParams(); - - // Local state - const [metrics, setMetrics] = useState({ - accessibility: true, - bestPractices: true, - performance: true, - seo: true, - }); - const [trigger, setTrigger] = useState(false); - // Network - const [monitor, monitorStats, isLoading, networkError] = useFetchPageSpeedMonitorById({ - monitorId, - dateRange: "day", - updateTrigger: trigger, - }); - - // Handlers - const handleMetrics = (id) => { - setMetrics((prev) => ({ ...prev, [id]: !prev[id] })); - }; - - const triggerUpdate = () => { - setTrigger(!trigger); - }; - if (networkError === true) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - - // Empty view, displayed when loading is complete and there are no checks - if (!isLoading && monitor?.checks?.length === 0) { - return ( - - - - - {t("distributedUptimeDetailsNoMonitorHistory")} - - - ); - } - - return ( - - - - - - - - - - ); -}; - -export default PageSpeedDetails; diff --git a/client/src/Pages/PageSpeed/Monitors/Components/Card/index.jsx b/client/src/Pages/PageSpeed/Monitors/Components/Card/index.jsx deleted file mode 100644 index 4a85863c4..000000000 --- a/client/src/Pages/PageSpeed/Monitors/Components/Card/index.jsx +++ /dev/null @@ -1,331 +0,0 @@ -import PropTypes from "prop-types"; -import Icon from "@/Components/v1/Icon"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import { Box, Grid, Stack, Typography } from "@mui/material"; -import { useNavigate } from "react-router-dom"; -import { useTheme } from "@emotion/react"; -import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts"; -import { useSelector } from "react-redux"; -import { formatDateWithTz, formatDurationSplit } from "@/Utils/timeUtilsLegacy.js"; -import { useMonitorUtils } from "@/Hooks/useMonitorUtils.js"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import IconBox from "@/Components/v1/IconBox/index.jsx"; -/** - * CustomToolTip displays a tooltip with formatted date and score information. - * @param {Object} props - * @param {Array} props.payload - Data to display in the tooltip - * @returns {JSX.Element} The rendered tooltip component - */ -const CustomToolTip = ({ payload }) => { - const theme = useTheme(); - const uiTimezone = useSelector((state) => state.ui.timezone); - - return ( - - - {formatDateWithTz( - payload[0]?.payload.createdAt, - "ddd, MMMM D, YYYY, h:mm A", - uiTimezone - )} - - - - - - {payload[0]?.name} - {" "} - {payload[0]?.payload.score} - - - ); -}; - -CustomToolTip.propTypes = { - payload: PropTypes.array, -}; - -/* TODO separate utils in folder*/ -/** - * Processes the raw data to include a score for each entry. - * @param {Array} data - The raw data array. - * @returns {Array} - The formatted data array with scores. - */ -const processData = (data) => { - if (data.length === 0) return []; - let formattedData = []; - - const calculateScore = (entry) => { - return ( - (entry.accessibility + entry.bestPractices + entry.performance + entry.seo) / 4 - ); - }; - - data.forEach((entry) => { - entry = { ...entry, score: calculateScore(entry) }; - formattedData.push(entry); - }); - - return formattedData; -}; - -/* TODO separate component*/ -/** - * Renders an area chart displaying page speed scores. - * @param {Object} props - * @param {Array} props.data - The raw data to be displayed in the chart. - * @param {string} props.status - The status of the page speed which determines the chart's color scheme. - * @returns {JSX.Element} - The rendered area chart. - */ -const PagespeedAreaChart = ({ data, status }) => { - const theme = useTheme(); - const [isHovered, setIsHovered] = useState(false); - const { statusToTheme } = useMonitorUtils(); - - const themeColor = statusToTheme[status]; - - const formattedData = processData(data); - - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - } - /> - - - - - - - - - - ); -}; - -PagespeedAreaChart.propTypes = { - data: PropTypes.arrayOf( - PropTypes.shape({ - accessibility: PropTypes.number.isRequired, - bestPractices: PropTypes.number.isRequired, - performance: PropTypes.number.isRequired, - seo: PropTypes.number.isRequired, - }) - ).isRequired, - status: PropTypes.string.isRequired, -}; - -/* TODO separate component */ -/** - * Renders a card displaying monitor details and an area chart. - * @param {Object} props - * @param {Object} props.monitor - The monitor data to be displayed in the card. - * @returns {JSX.Element} - The rendered card. - */ -const Card = ({ monitor }) => { - const { determineState, pagespeedStatusMsg } = useMonitorUtils(); - const theme = useTheme(); - const { t } = useTranslation(); - const navigate = useNavigate(); - const monitorState = determineState(monitor); - - return ( - - navigate(`/pagespeed/${monitor.id}`)} - border={1} - borderColor={theme.palette.primary.lowContrast} - borderRadius={theme.shape.borderRadius} - backgroundColor={theme.palette.primary.main} - sx={{ - display: "grid", - gridTemplateColumns: "34px 2fr 1fr", - columnGap: theme.spacing(5), - gridTemplateRows: "34px 1fr 3fr", - cursor: "pointer", - "&:hover": { - backgroundColor: theme.palette.tertiary.main, - }, - "& path": { - transition: "stroke-width 400ms ease", - }, - }} - > - - - - - {monitor.name} - - - - {monitor.url} - - - - - - - {t("checkingEvery")}{" "} - {(() => { - const { time, format } = formatDurationSplit(monitor?.interval); - return ( - <> - - {time}{" "} - - {format} - - ); - })()} - - - - - ); -}; - -Card.propTypes = { - monitor: PropTypes.object.isRequired, -}; - -export default Card; diff --git a/client/src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx b/client/src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx deleted file mode 100644 index 9df5f7577..000000000 --- a/client/src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Grid } from "@mui/material"; -import Card from "../Card/index.jsx"; - -const MonitorGrid = ({ monitors, shouldRender = true }) => { - if (!shouldRender) return null; - - return ( - - {monitors?.map((monitor) => ( - - ))} - - ); -}; - -export default MonitorGrid; diff --git a/client/src/Pages/PageSpeed/Monitors/old.jsx b/client/src/Pages/PageSpeed/Monitors/old.jsx deleted file mode 100644 index f1bfcd0dd..000000000 --- a/client/src/Pages/PageSpeed/Monitors/old.jsx +++ /dev/null @@ -1,86 +0,0 @@ -// Components -import { useState } from "react"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import { Stack } from "@mui/material"; -import CreateMonitorHeader from "@/Components/v1/MonitorCreateHeader/index.jsx"; -import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx"; -import MonitorGrid from "./Components/MonitorGrid/index.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; -import FallbackPageSpeedWarning from "@/Components/v1/Fallback/FallbackPageSpeedWarning.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js"; -import { useFetchSettings } from "@/Hooks/settingsHooks.js"; -// Constants -const BREADCRUMBS = [{ name: `pagespeed`, path: "/pagespeed" }]; -const TYPES = ["pagespeed"]; -const PageSpeed = () => { - const theme = useTheme(); - const isAdmin = useIsAdmin(); - - const [ - summary, - monitorsWithChecks, - monitorsWithChecksCount, - monitorsWithChecksIsLoading, - monitorsWithChecksNetworkError, - ] = useFetchMonitorsWithChecks({ - types: TYPES, - limit: 10, - page: null, - rowsPerPage: null, - filter: null, - field: null, - order: null, - monitorUpdateTrigger: null, - }); - - const [settingsData, setSettingsData] = useState(undefined); - const [isSettingsLoading, settingsError] = useFetchSettings({ - setSettingsData, - setIsApiKeySet: () => {}, - setIsEmailPasswordSet: () => {}, - }); - - return ( - <> - - ) - } - > - - - - - - - - - ); -}; - -export default PageSpeed; diff --git a/client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx b/client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx deleted file mode 100644 index 8912438d8..000000000 --- a/client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { logger } from "../../../../Utils/Logger.js"; -import { useEffect, useState } from "react"; -import { networkService } from "../../../../main.jsx"; -import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; - -const useCertificateFetch = ({ - monitor, - monitorId, - certificateDateFormat, - uiTimezone, -}) => { - const [certificateExpiry, setCertificateExpiry] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const fetchCertificate = async () => { - if (monitor?.type !== "http") { - return; - } - - try { - setIsLoading(true); - const res = await networkService.getCertificateExpiry({ - monitorId: monitorId, - }); - if (res?.data?.data?.certificateDate) { - const date = res.data.data.certificateDate; - setCertificateExpiry( - formatDateWithTz(date, certificateDateFormat, uiTimezone) ?? "N/A" - ); - } - } catch (error) { - setCertificateExpiry("N/A"); - logger.error(error); - } finally { - setIsLoading(false); - } - }; - fetchCertificate(); - }, [monitorId, certificateDateFormat, uiTimezone, monitor]); - return [certificateExpiry, isLoading]; -}; - -export default useCertificateFetch; diff --git a/client/src/Pages/Uptime/Details/old.jsx b/client/src/Pages/Uptime/Details/old.jsx deleted file mode 100644 index 144ceb4dc..000000000 --- a/client/src/Pages/Uptime/Details/old.jsx +++ /dev/null @@ -1,190 +0,0 @@ -// Components -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx"; -import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx"; -import ChartBoxes from "./Components/ChartBoxes/index.jsx"; -import ResponseTimeChart from "./Components/Charts/ResponseTimeChart.jsx"; -import ResponseTable from "./Components/ResponseTable/index.jsx"; -import UptimeStatusBoxes from "./Components/UptimeStatusBoxes/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; - -// Utils -import { useState } from "react"; -import { useParams } from "react-router-dom"; -import { useSelector } from "react-redux"; -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useFetchUptimeMonitorById } from "../../../Hooks/monitorHooks.js"; -import useCertificateFetch from "./Hooks/useCertificateFetch.jsx"; -import { useFetchChecksByMonitor } from "../../../Hooks/checkHooks.js"; -import { useTranslation } from "react-i18next"; - -// Constants -const BREADCRUMBS = [ - { name: "uptime", path: "/uptime" }, - { name: "details", path: "" }, - // { name: "details", path: `/uptime/${monitorId}` }, Is this needed? We can't click on this anywy -]; - -const certificateDateFormat = "MMM D, YYYY h A"; - -const UptimeDetails = () => { - // Redux state - const uiTimezone = useSelector((state) => state.ui.timezone); - - // Local state - const [dateRange, setDateRange] = useState("recent"); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(5); - const [trigger, setTrigger] = useState(false); - - // Utils - const dateFormat = - dateRange === "day" || dateRange === "recent" ? "MMM D, h A" : "MMM D"; - const { monitorId } = useParams(); - const theme = useTheme(); - const isAdmin = useIsAdmin(); - const { t } = useTranslation(); - - const [monitorData, monitorStats, monitorIsLoading, monitorNetworkError] = - useFetchUptimeMonitorById({ - monitorId, - dateRange, - trigger, - }); - - const monitor = monitorData?.monitor; - - const [certificateExpiry, certificateIsLoading] = useCertificateFetch({ - monitor, - monitorId, - certificateDateFormat, - uiTimezone, - }); - - const monitorType = monitor?.type; - - const [checks, checksCount, checksAreLoading, checksNetworkError] = - useFetchChecksByMonitor({ - monitorId, - type: monitorType, - sortOrder: "desc", - limit: null, - dateRange, - filter: null, - page, - rowsPerPage, - }); - - // Handlers - const triggerUpdate = () => { - setTrigger(!trigger); - }; - - const handlePageChange = (_, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(event.target.value); - }; - - if (monitorNetworkError || checksNetworkError) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - - // Empty view, displayed when loading is complete and there are no checks - if (!monitorIsLoading && !checksAreLoading && checksCount === 0) { - return ( - - - - - - - - - {t("distributedUptimeDetailsNoMonitorHistory")} - - - ); - } - - return ( - - - - - - - - - - ); -}; - -export default UptimeDetails; diff --git a/client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx b/client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx deleted file mode 100644 index 62ba0be13..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -import FilterHeader from "@/Components/v1/FilterHeader/index.jsx"; -import { useMemo } from "react"; -import { Box, Button } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; -import { useTranslation } from "react-i18next"; - -/** - * Filter Component - * - * A high-level component that provides filtering options for type, status, and state. - * It allows users to select multiple options for each filter and reset the filters. - * - * @component - * @param {Object} props - The component props. - * @param {string[]} props.selectedTypes - An array of selected type values. - * @param {function} props.setSelectedTypes - A function to set the selected type values. - * @param {string[]} props.selectedStatus - An array of selected status values. - * @param {function} props.setSelectedStatus - A function to set the selected status values. - * @param {string[]} props.selectedState - An array of selected state values. - * @param {function} props.setSelectedState - A function to set the selected state values. - * @param {function} props.setToFilterStatus - A function to set the filter status based on selected status values. - * @param {function} props.setToFilterActive - A function to set the filter active state based on selected state values. - * @param {function} props.handleReset - A function to reset all filters. - * - * @returns {JSX.Element} The rendered Filter component. - */ - -const getTypeOptions = () => [ - { value: "http", label: "HTTP(S)" }, - { value: "ping", label: "Ping" }, - { value: "docker", label: "Docker" }, - { value: "port", label: "Port" }, - { value: "game", label: "Game" }, -]; - -// These functions were moved inline to ensure translations are applied correctly - -const Filter = ({ - selectedTypes, - setSelectedTypes, - selectedStatus, - setSelectedStatus, - selectedState, - setSelectedState, - setToFilterStatus, - setToFilterActive, - handleReset, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const typeOptions = getTypeOptions(); - // Create status options with translations - const statusOptions = [ - { value: "Up", label: t("monitorStatus.up") }, - { value: "Down", label: t("monitorStatus.down") }, - ]; - // Create state options with translations - const stateOptions = [ - { value: "Active", label: t("monitorState.active") }, - { value: "Paused", label: t("monitorState.paused") }, - ]; - - const handleTypeChange = (event) => { - const selectedValues = event.target.value; - setSelectedTypes(selectedValues.length > 0 ? selectedValues : undefined); - }; - - const handleStatusChange = (event) => { - const selectedValues = event.target.value; - setSelectedStatus(selectedValues.length > 0 ? selectedValues : undefined); - - if (selectedValues.length === 0 || selectedValues.length === 2) { - setToFilterStatus(undefined); - } else { - setToFilterStatus(selectedValues[0] === "Up" ? "true" : "false"); - } - }; - - const handleStateChange = (event) => { - const selectedValues = event.target.value; - setSelectedState(selectedValues); - - if (selectedValues.length === 0 || selectedValues.length === 2) { - setToFilterActive(undefined); - } else { - setToFilterActive(selectedValues[0] === "Active" ? "true" : "false"); - } - }; - - const isFilterActive = useMemo(() => { - return ( - (selectedTypes?.length ?? 0) > 0 || - (selectedState?.length ?? 0) > 0 || - (selectedStatus?.length ?? 0) > 0 - ); - }, [selectedState, selectedTypes, selectedStatus]); - - return ( - - - - - - - ); -}; - -Filter.propTypes = { - selectedTypes: PropTypes.arrayOf(PropTypes.string), - setSelectedTypes: PropTypes.func.isRequired, - selectedStatus: PropTypes.arrayOf(PropTypes.string), - setSelectedStatus: PropTypes.func.isRequired, - selectedState: PropTypes.arrayOf(PropTypes.string), - setSelectedState: PropTypes.func.isRequired, - setToFilterStatus: PropTypes.func.isRequired, - setToFilterActive: PropTypes.func.isRequired, - handleReset: PropTypes.func.isRequired, -}; - -export default Filter; diff --git a/client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx b/client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx deleted file mode 100644 index 4e55be536..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -/** - * Renders a skeleton layout. - * - * @returns {JSX.Element} - */ -const SkeletonLayout = () => { - const theme = useTheme(); - - return ( - <> - - - - - - - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx deleted file mode 100644 index 5cba9e3b1..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import PropTypes from "prop-types"; -import { Stack } from "@mui/material"; -import StatusBox from "./statusBox.jsx"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import SkeletonLayout from "./skeleton.jsx"; - -const StatusBoxes = ({ shouldRender, monitorsSummary }) => { - const theme = useTheme(); - const { t } = useTranslation(); - if (!shouldRender) return ; - return ( - - - - - - ); -}; - -StatusBoxes.propTypes = { - monitorsSummary: PropTypes.object, - shouldRender: PropTypes.bool, -}; - -export default StatusBoxes; diff --git a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx deleted file mode 100644 index df6e08ccd..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -const SkeletonLayout = () => { - const theme = useTheme(); - return ( - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx deleted file mode 100644 index 136c3575d..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Stack, Typography } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; -import Background from "@/assets/Images/background-grid.svg?react"; - -const StatusBox = ({ title, value, status }) => { - const theme = useTheme(); - let sharedStyles = { - position: "absolute", - right: 8, - opacity: 0.5, - "& svg path": { stroke: theme.palette.primary.contrastTextTertiary }, - }; - - let color; - let icon; - if (status === "up") { - color = theme.palette.success.lowContrast; - icon = ( - - - - ); - } else if (status === "down") { - color = theme.palette.error.lowContrast; - icon = ( - - - - ); - } else if (status === "paused") { - color = theme.palette.warning.lowContrast; - icon = ( - - - - ); - } - - return ( - - - - - - - - {title} - - {icon} - - - {value} - - - # - - - - - ); -}; - -StatusBox.propTypes = { - title: PropTypes.string, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - status: PropTypes.string, -}; - -export default StatusBox; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx b/client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx deleted file mode 100644 index 8c3239dd3..000000000 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx +++ /dev/null @@ -1,241 +0,0 @@ -// Components -import { Box, Stack } from "@mui/material"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import Host from "@/Components/v1/Host/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import BarChart from "@/Components/v1/Charts/BarChart/index.jsx"; -import ActionsMenu from "@/Components/v1/ActionsMenu/index.jsx"; - -import LoadingSpinner from "../LoadingSpinner/index.jsx"; -import TableSkeleton from "@/Components/v1/Table/skeleton.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js"; -import { useNavigate } from "react-router-dom"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; - -/** - * UptimeDataTable displays a table of uptime monitors with sorting, searching, and action capabilities - * @param {Object} props - Component props - * @param {boolean} props.isAdmin - Whether the current user has admin privileges - * @param {boolean} props.isLoading - Loading state of the table - * @param {Array<{ - * id: string, - * url: string, - * title: string, - * percentage: number, - * percentageColor: string, - * monitor: { - * id: string, - * type: string, - * checks: Array - * } - * }>} props.monitors - Array of monitor objects to display - * @param {number} props.monitorCount - Total count of monitors - * @param {Object} props.sort - Current sort configuration - * @param {string} props.sort.field - Field to sort by - * @param {'asc'|'desc'} props.sort.order - Sort direction - * @param {Function} props.setSort - Callback to update sort configuration - * @param {string} props.search - Current search query - * @param {Function} props.setSearch - Callback to update search query - * @param {boolean} props.isSearching - Whether a search is in progress - * @param {Function} props.setIsLoading - Callback to update loading state - * @param {Function} props.triggerUpdate - Callback to trigger a data refresh - * @returns {JSX.Element} Rendered component - */ -const UptimeDataTable = ({ - isAdmin, - isSearching, - filteredMonitors, - sort, - setSort, - triggerUpdate, - monitorsAreLoading, -}) => { - // Utils - const navigate = useNavigate(); - const { determineState } = useMonitorUtils(); - const theme = useTheme(); - const { t } = useTranslation(); - - // Local state - // Handlers - const handleSort = (field) => { - let order = ""; - if (sort?.field !== field) { - order = "desc"; - } else { - order = sort?.order === "asc" ? "desc" : "asc"; - } - setSort({ field, order }); - }; - - const headers = [ - { - id: "name", - content: ( - handleSort("name")} - > - {t("host")} - - {sort?.order === "asc" ? ( - - ) : ( - - )} - - - ), - render: (row) => ( - - ), - }, - { - id: "status", - content: ( - handleSort("status")} - > - {" "} - {t("status")} - - {sort?.order === "asc" ? ( - - ) : ( - - )} - - - ), - render: (row) => { - const status = determineState(row.monitor); - return ( - - ); - }, - }, - { - id: "responseTime", - content: t("responseTime"), - render: (row) => ( - - - - ), - }, - { - id: "type", - content: t("type"), - render: (row) => ( - - {row.monitor.type === "http" ? "HTTP(s)" : row.monitor.type} - - ), - }, - { - id: "actions", - content: t("actions"), - render: (row) => ( - - ), - }, - ]; - - if (monitorsAreLoading) { - return ; - } - - return ( - - - { - navigate(`/uptime/${row.id}`); - }, - emptyView: "No monitors found", - }} - /> - - ); -}; - -UptimeDataTable.propTypes = { - isSearching: PropTypes.bool, - setSort: PropTypes.func, - setSearch: PropTypes.func, - triggerUpdate: PropTypes.func, - debouncedSearch: PropTypes.string, - onSearchChange: PropTypes.func, - isAdmin: PropTypes.bool, - monitors: PropTypes.array, - filteredMonitors: PropTypes.array, - monitorCount: PropTypes.number, - monitorsAreLoading: PropTypes.bool, - sort: PropTypes.shape({ - field: PropTypes.string, - order: PropTypes.oneOf(["asc", "desc"]), - }), -}; - -export default UptimeDataTable; diff --git a/client/src/Pages/Uptime/Monitors/old.jsx b/client/src/Pages/Uptime/Monitors/old.jsx deleted file mode 100644 index 67019362f..000000000 --- a/client/src/Pages/Uptime/Monitors/old.jsx +++ /dev/null @@ -1,225 +0,0 @@ -// Required Data -// 1. Monitor summary -// 2. List of monitors filtered by search term with 25 checks each -// 2a.List of monitors must have the total number of monitors that match. - -// Components -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import Greeting from "../../../Utils/greeting.jsx"; -import StatusBoxes from "./Components/StatusBoxes/index.jsx"; -import UptimeDataTable from "./Components/UptimeDataTable/index.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; -import CreateMonitorHeader from "@/Components/v1/MonitorCreateHeader/index.jsx"; -import SearchComponent from "./Components/SearchComponent/index.jsx"; -import Filter from "./Components/Filter/index.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; - -import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx"; - -// MUI Components -import { Stack, Box, Button } from "@mui/material"; -// Utils -import { useState, useCallback, useEffect } from "react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { useSelector, useDispatch } from "react-redux"; -import { setRowsPerPage } from "../../../Features/UI/uiSlice.js"; -import PropTypes from "prop-types"; -import { - useFetchMonitorsWithChecks, - useFetchMonitorsByTeamId, -} from "@/Hooks/monitorHooks.js"; -import { useTranslation } from "react-i18next"; - -const TYPES = ["http", "ping", "docker", "port", "game"]; -const CreateMonitorButton = ({ shouldRender }) => { - // Utils - const navigate = useNavigate(); - const { t } = useTranslation(); - if (shouldRender === false) { - return; - } - - return ( - - - - ); -}; - -CreateMonitorButton.propTypes = { - shouldRender: PropTypes.bool, -}; - -const UptimeMonitors = () => { - // Redux state - const rowsPerPage = useSelector((state) => state.ui?.monitors?.rowsPerPage ?? 10); - - // Local state - const [search, setSearch] = useState(undefined); - const [page, setPage] = useState(undefined); - const [sort, setSort] = useState(undefined); - const [isSearching, setIsSearching] = useState(false); - const [monitorUpdateTrigger, setMonitorUpdateTrigger] = useState(false); - const [selectedTypes, setSelectedTypes] = useState(undefined); - const [selectedState, setSelectedState] = useState(undefined); - const [selectedStatus, setSelectedStatus] = useState(undefined); - const [toFilterStatus, setToFilterStatus] = useState(undefined); - const [toFilterActive, setToFilterActive] = useState(undefined); - - // Utils - const theme = useTheme(); - const isAdmin = useIsAdmin(); - const dispatch = useDispatch(); - const { t } = useTranslation(); - - const BREADCRUMBS = [{ name: t("menu.uptime"), path: "/uptime" }]; - - // Handlers - const handleChangePage = (event, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - dispatch( - setRowsPerPage({ - value: parseInt(event.target.value, 10), - table: "monitors", - }) - ); - setPage(0); - }; - - const triggerUpdate = useCallback(() => { - setMonitorUpdateTrigger((prev) => !prev); - }, []); - - const handleReset = () => { - setSelectedState(undefined); - setSelectedTypes(undefined); - setSelectedStatus(undefined); - setToFilterStatus(undefined); - setToFilterActive(undefined); - }; - - const filterLookup = new Map([ - [toFilterStatus, "status"], - [toFilterActive, "isActive"], - ]); - - const activeFilter = [...filterLookup].find(([key]) => key !== undefined); - const field = activeFilter?.[1] || (search ? "name" : sort?.field); - const filter = activeFilter?.[0] || search; - - const effectiveTypes = selectedTypes?.length ? selectedTypes : TYPES; - - const [ - summary, - monitorsWithChecks, - monitorsWithChecksCount, - monitorsWithChecksIsLoading, - networkError, - ] = useFetchMonitorsWithChecks({ - types: effectiveTypes, - limit: 25, - page: page, - rowsPerPage: rowsPerPage, - filter: filter, - field: field, - order: sort?.order, - monitorUpdateTrigger, - }); - - const [monitors, listIsLoading, listNetworkError] = useFetchMonitorsByTeamId({ - type: ["http", "ping", "docker", "port", "game"], - }); - - useEffect(() => { - if (isSearching) { - setPage(undefined); - } - }, [isSearching]); - - const isLoading = monitorsWithChecksIsLoading; - - return ( - <> - - - - - - - - - - - - - - - - - - ); -}; - -export default UptimeMonitors; From 7f1c996632728bde3e270d3b3773fd8010ec4c04 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 28 Jan 2026 20:21:41 +0000 Subject: [PATCH 025/392] format --- .../checks/MongoChecksRepistory.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index e4fe01412..1f65673f3 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -205,15 +205,17 @@ class MongoChecksRepository implements IChecksRepository { const { id, metadata, ...rest } = check; return { _id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(), - metadata: metadata ? { - monitorId: new mongoose.Types.ObjectId(metadata.monitorId), - teamId: new mongoose.Types.ObjectId(metadata.teamId), - type: metadata.type, - } : { - monitorId: new mongoose.Types.ObjectId(), - teamId: new mongoose.Types.ObjectId(), - type: "http", - }, + metadata: metadata + ? { + monitorId: new mongoose.Types.ObjectId(metadata.monitorId), + teamId: new mongoose.Types.ObjectId(metadata.teamId), + type: metadata.type, + } + : { + monitorId: new mongoose.Types.ObjectId(), + teamId: new mongoose.Types.ObjectId(), + type: "http", + }, ...rest, } as unknown as CheckDocument; }; From 5596e45ff3895a71ca7bfc2943ff6b58560a5699 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 28 Jan 2026 20:48:02 +0000 Subject: [PATCH 026/392] initial commit --- client/src/Pages/Infrastructure/Monitors/index.tsx | 5 +++++ .../src/Pages/Infrastructure/Monitors/{index.jsx => old.jsx} | 0 2 files changed, 5 insertions(+) create mode 100644 client/src/Pages/Infrastructure/Monitors/index.tsx rename client/src/Pages/Infrastructure/Monitors/{index.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx new file mode 100644 index 000000000..3ed96f6a1 --- /dev/null +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -0,0 +1,5 @@ +const InfrastructureMonitors = () => { + return null; +}; + +export default InfrastructureMonitors; diff --git a/client/src/Pages/Infrastructure/Monitors/index.jsx b/client/src/Pages/Infrastructure/Monitors/old.jsx similarity index 100% rename from client/src/Pages/Infrastructure/Monitors/index.jsx rename to client/src/Pages/Infrastructure/Monitors/old.jsx From c18159afd39dc1355a35341d2fd6e4bdbb92db6e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 28 Jan 2026 21:34:43 +0000 Subject: [PATCH 027/392] v2 infra migration --- .../Components/v2/design-elements/Gauge.tsx | 106 +++++++ .../Components/v2/design-elements/index.tsx | 1 + .../Components/v2/monitors/ControlsFilter.tsx | 40 +-- .../Monitors/Components/Filters/index.jsx | 94 ------ .../Monitors/Components/MonitorsTable.tsx | 275 ++++++++++++++++++ .../Components/MonitorsTable/index.jsx | 162 ----------- .../Components/MonitorsTableMenu/index.jsx | 196 ------------- .../Pages/Infrastructure/Monitors/index.tsx | 197 ++++++++++++- .../src/Pages/Infrastructure/Monitors/old.jsx | 139 --------- .../Components/LoadingSpinner/index.jsx | 46 --- .../Components/SearchComponent/index.jsx | 49 ---- .../Uptime/Monitors/Hooks/useDebounce.jsx | 18 -- client/src/Routes/index.jsx | 10 +- client/src/locales/en.json | 9 + 14 files changed, 617 insertions(+), 725 deletions(-) create mode 100644 client/src/Components/v2/design-elements/Gauge.tsx delete mode 100644 client/src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx create mode 100644 client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx delete mode 100644 client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx delete mode 100644 client/src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx delete mode 100644 client/src/Pages/Infrastructure/Monitors/old.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx delete mode 100644 client/src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx diff --git a/client/src/Components/v2/design-elements/Gauge.tsx b/client/src/Components/v2/design-elements/Gauge.tsx new file mode 100644 index 000000000..34c57487d --- /dev/null +++ b/client/src/Components/v2/design-elements/Gauge.tsx @@ -0,0 +1,106 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; + +import { useTheme } from "@mui/material/styles"; +import { useMemo, useState, useEffect } from "react"; +import { getInfraGaugeColor } from "@/Utils/MonitorUtils"; + +const MINIMUM_VALUE = 0; +const MAXIMUM_VALUE = 100; + +export const Gauge = ({ + isLoading = false, + progress = 0, + radius = 70, + strokeWidth = 15, + precision = 1, + unit = "%", +}: { + isLoading?: boolean; + progress?: number; + radius?: number; + strokeWidth?: number; + precision?: number; + unit?: string; +}) => { + const theme = useTheme(); + const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE)); + + // Calculate the length of the stroke for the circle + const { circumference, totalSize, strokeLength } = useMemo( + () => ({ + circumference: 2 * Math.PI * radius, + totalSize: radius * 2 + strokeWidth * 2, + strokeLength: (progress / 100) * (2 * Math.PI * radius), + }), + [radius, strokeWidth, progress] + ); + + const [offset, setOffset] = useState(circumference); + useEffect(() => { + setOffset(circumference); + const timer = setTimeout(() => { + setOffset(circumference - strokeLength); + }, 100); + + return () => clearTimeout(timer); + }, [progress, circumference, strokeLength]); + + const fillColor = getInfraGaugeColor(progressWithinRange, theme); + + if (isLoading) { + return; + } + + return ( + + + + + + + + {`${progressWithinRange.toFixed(precision)}${unit}`} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index 6ebffc624..000dab1b2 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -15,3 +15,4 @@ export { default as Icon } from "./Icon"; export * from "./Tooltip"; export * from "./StatBox"; export * from "./BaseChart"; +export * from "./Gauge"; diff --git a/client/src/Components/v2/monitors/ControlsFilter.tsx b/client/src/Components/v2/monitors/ControlsFilter.tsx index d7eecefb5..34eedd190 100644 --- a/client/src/Components/v2/monitors/ControlsFilter.tsx +++ b/client/src/Components/v2/monitors/ControlsFilter.tsx @@ -11,6 +11,7 @@ const statuses = ["up", "down"]; const states = ["active", "paused"]; export const ControlsFilter = ({ + showTypes = true, selectedTypes, setSelectedTypes, selectedStatus, @@ -19,8 +20,9 @@ export const ControlsFilter = ({ setSelectedState, onClearFilters, }: { - selectedTypes: MonitorType[]; - setSelectedTypes: React.Dispatch>; + showTypes?: boolean; + selectedTypes?: MonitorType[]; + setSelectedTypes?: React.Dispatch>; selectedStatus: string; setSelectedStatus: React.Dispatch>; selectedState: string; @@ -30,27 +32,29 @@ export const ControlsFilter = ({ const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isFilterActive = - selectedTypes.length > 0 || selectedStatus !== "" || selectedState !== ""; + (selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== ""; return ( - + {showTypes && setSelectedTypes && ( + + )} setSelectedInterface(e.target.value)} - items={availableInterfaces.map((interfaceName) => ({ - _id: interfaceName, - name: interfaceName, - }))} - sx={{ minWidth: 200 }} - /> - )} - - - - - ); -}; - -Network.propTypes = { - net: PropTypes.array, - checks: PropTypes.array, - isLoading: PropTypes.bool.isRequired, - dateRange: PropTypes.string.isRequired, - setDateRange: PropTypes.func.isRequired, -}; - -export default Network; diff --git a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/skeleton.jsx b/client/src/Pages/Infrastructure/Details/Components/NetworkStats/skeleton.jsx deleted file mode 100644 index 52fcfad87..000000000 --- a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/skeleton.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import { - Card, - CardContent, - Skeleton, - Table, - TableHead, - TableRow, - TableCell, - TableBody, -} from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - - - - Name - Bytes Sent - Bytes Received - Packets Sent - Packets Received - Errors In - Errors Out - Drops In - Drops Out - - - - {Array.from({ length: 5 }).map((_, idx) => ( - - {Array.from({ length: 9 }).map((__, colIdx) => ( - - - - ))} - - ))} - -
-
-
- ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx deleted file mode 100644 index d411886c7..000000000 --- a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx +++ /dev/null @@ -1,115 +0,0 @@ -// Components -import { Stack, Typography } from "@mui/material"; -import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx"; -import StatBox from "@/Components/v1/StatBox/index.jsx"; - -//Utils -import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js"; -import { useHardwareUtils } from "../../Hooks/useHardwareUtils.jsx"; -import { useTranslation } from "react-i18next"; - -const InfraStatBoxes = ({ shouldRender, monitor }) => { - // Utils - const { formatBytes } = useHardwareUtils(); - const { determineState } = useMonitorUtils(); - const { t } = useTranslation(); - - const latestCheck = monitor?.recentChecks?.[0]; - - // Get data from latest check - const physicalCores = latestCheck?.cpu?.physical_core ?? 0; - const logicalCores = latestCheck?.cpu?.logical_core ?? 0; - const cpuFrequency = latestCheck?.cpu?.frequency ?? 0; - const cpuTemperature = - latestCheck?.cpu?.temperature?.length > 0 - ? latestCheck.cpu.temperature.reduce((acc, curr) => acc + curr, 0) / - latestCheck.cpu.temperature.length - : 0; - const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0; - const diskTotalBytes = latestCheck?.disk[0]?.total_bytes ?? 0; - const os = latestCheck?.host?.os ?? undefined; - const platform = latestCheck?.host?.platform ?? undefined; - const osPlatform = - typeof os === "undefined" && typeof platform === "undefined" - ? undefined - : `${os} ${platform}`; - - return ( - - - - {physicalCores} - - {physicalCores === 1 ? "core" : "cores"} - - - } - /> - - {logicalCores} - - {logicalCores === 1 ? "core" : "cores"} - - - } - /> - - {(cpuFrequency / 1000).toFixed(2)} - Ghz - - } - /> - - {cpuTemperature.toFixed(2)} - °C - - } - /> - - - {/* - {(uptimePercentage * 100).toFixed(2)} - % - - } - /> */} - - - ); -}; - -export default InfraStatBoxes; diff --git a/client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx b/client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx deleted file mode 100644 index 8938847a4..000000000 --- a/client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx +++ /dev/null @@ -1,270 +0,0 @@ -import { Typography, Tooltip } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; - -// Constants -const BASE_BOX_PADDING_VERTICAL = 4; -const BASE_BOX_PADDING_HORIZONTAL = 8; -const TYPOGRAPHY_PADDING = 8; -const CHART_CONTAINER_HEIGHT = 300; - -const useHardwareUtils = () => { - const theme = useTheme(); - const { t } = useTranslation(); - - const getDimensions = () => { - const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2; - const totalChartContainerPadding = - parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2; - return { - baseBoxPaddingVertical: BASE_BOX_PADDING_VERTICAL, - baseBoxPaddingHorizontal: BASE_BOX_PADDING_HORIZONTAL, - totalContainerPadding: parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2, - areaChartHeight: - CHART_CONTAINER_HEIGHT - totalChartContainerPadding - totalTypographyPadding, - }; - }; - - const formatBytes = (bytes, space = false) => { - if (bytes === undefined || bytes === null) - return ( - <> - {0} - {space ? " " : ""} - {t("gb")} - - ); - if (typeof bytes !== "number") - return ( - <> - {0} - {space ? " " : ""} - {t("gb")} - - ); - if (bytes === 0) - return ( - <> - {0} - {space ? " " : ""} - {t("gb")} - - ); - - const GB = bytes / (1024 * 1024 * 1024); - const MB = bytes / (1024 * 1024); - - if (GB >= 1) { - return ( - <> - {Number(GB.toFixed(2))} - {space ? " " : ""} - {t("gb")} - - ); - } else { - return ( - <> - {Number(MB.toFixed(2))} - {space ? " " : ""} - {t("mb")} - - ); - } - }; - - const formatBytesPerSecondString = (bytesPerSec, space = false) => { - if ( - bytesPerSec === undefined || - bytesPerSec === null || - typeof bytesPerSec !== "number" || - bytesPerSec === 0 - ) { - return `0${space ? " " : ""}B/s`; - } - - const GB = bytesPerSec / (1024 * 1024 * 1024); - const MB = bytesPerSec / (1024 * 1024); - const KB = bytesPerSec / 1024; - - if (GB >= 1) { - return `${Number(GB.toFixed(1))}${space ? " " : ""}GB/s`; - } else if (MB >= 1) { - return `${Number(MB.toFixed(1))}${space ? " " : ""}MB/s`; - } else if (KB >= 1) { - return `${Number(KB.toFixed(1))}${space ? " " : ""}KB/s`; - } else { - return `${Number(bytesPerSec.toFixed(1))}${space ? " " : ""}B/s`; - } - }; - - const formatPacketsPerSecondString = (packetsPerSec, space = false) => { - if ( - packetsPerSec === undefined || - packetsPerSec === null || - typeof packetsPerSec !== "number" || - packetsPerSec === 0 - ) { - return `0${space ? " " : ""}pps`; - } - - const M = packetsPerSec / (1000 * 1000); - const K = packetsPerSec / 1000; - - if (M >= 1) { - return `${Number(M.toFixed(1))}${space ? " " : ""}Mpps`; - } else if (K >= 1) { - return `${Number(K.toFixed(1))}${space ? " " : ""}Kpps`; - } else { - return `${Math.round(packetsPerSec)}${space ? " " : ""}pps`; - } - }; - - const formatDeviceName = (device) => { - const deviceStr = String(device || ""); - - // Show full device path - return ( - - - {deviceStr} - - - ); - }; - - const formatMountpoint = (mountpoint) => { - const mountpointStr = String(mountpoint || ""); - - if (!mountpointStr) { - return ( - - - N/A - - - ); - } - - // Show full mountpoint path - return ( - - - {mountpointStr} - - - ); - }; - - /** - * Converts a decimal value to a percentage - * - * @function decimalToPercentage - * @param {number} value - Decimal value to convert - * @returns {number} Percentage representation - * - * @example - * decimalToPercentage(0.75) // Returns 75 - * decimalToPercentage(null) // Returns 0 - */ - const decimalToPercentage = (value) => { - if (value === null || value === undefined) return 0; - return value * 100; - }; - - const buildTemps = (checks) => { - let numCores = 1; - if (checks === null) return { temps: [], tempKeys: [] }; - - for (const check of checks) { - if (check?.avgTemperature?.length > numCores) { - numCores = check.avgTemperature.length; - break; - } - } - const temps = checks.map((check) => { - // If there's no data, set the temperature to 0 - if ( - check?.avgTemperature?.length === 0 || - check?.avgTemperature === undefined || - check?.avgTemperature === null - ) { - check.avgTemperature = Array(numCores).fill(0); - } - const res = check?.avgTemperature?.reduce( - (acc, cur, idx) => { - acc[`core${idx + 1}`] = cur; - return acc; - }, - { - _id: check._id, - } - ); - return res; - }); - if (temps.length === 0 || !temps[0]) { - return { temps: [], tempKeys: [] }; - } - - return { - tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "_id"), - temps, - }; - }; - - return { - formatBytes, - formatDeviceName, - formatMountpoint, - decimalToPercentage, - buildTemps, - getDimensions, - formatBytesPerSecondString, - formatPacketsPerSecondString, - }; -}; - -export { useHardwareUtils }; diff --git a/client/src/Pages/Infrastructure/Details/old.jsx b/client/src/Pages/Infrastructure/Details/old.jsx deleted file mode 100644 index 6ae630822..000000000 --- a/client/src/Pages/Infrastructure/Details/old.jsx +++ /dev/null @@ -1,142 +0,0 @@ -// Components -import { Stack, Typography, Tab } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import MonitorDetailsControlHeader from "@/Components/v1/MonitorDetailsControlHeader/index.jsx"; -import MonitorTimeFrameHeader from "@/Components/v1/MonitorTimeFrameHeader/index.jsx"; -import StatusBoxes from "./Components/StatusBoxes/index.jsx"; -import GaugeBoxes from "./Components/GaugeBoxes/index.jsx"; -import AreaChartBoxes from "./Components/AreaChartBoxes/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import NetworkStats from "./Components/NetworkStats/index.jsx"; -import CustomTabList from "@/Components/v1/Tab/index.jsx"; -import TabContext from "@mui/lab/TabContext"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useFetchHardwareMonitorById } from "../../../Hooks/monitorHooks.js"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; - -// Constants -const BREADCRUMBS = [ - { name: "infrastructure monitors", path: "/infrastructure" }, - { name: "details", path: "" }, -]; -const InfrastructureDetails = () => { - // Local state - const [dateRange, setDateRange] = useState("recent"); - const [trigger, setTrigger] = useState(false); - const [tab, setTab] = useState("details"); - - // Utils - const theme = useTheme(); - const { monitorId } = useParams(); - const { t } = useTranslation(); - const isAdmin = useIsAdmin(); - - const [monitor, isLoading, networkError] = useFetchHardwareMonitorById({ - monitorId, - dateRange, - updateTrigger: trigger, - }); - - const triggerUpdate = () => { - setTrigger(!trigger); - }; - - if (networkError === true) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - - if (!isLoading && monitor?.stats?.checks?.length === 0) { - return ( - - - - - {t("distributedUptimeDetailsNoMonitorHistory")} - - - ); - } - - return ( - - - - - setTab(v)} - > - - - - {tab === "details" && ( - <> - - - - - - )} - {tab === "network" && ( - - )} - - - ); -}; - -export default InfrastructureDetails; From 6395e8a10e0dbe137ec3094c1d5dfbb8adfbd1ac Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 17:52:10 +0000 Subject: [PATCH 039/392] iniital commit --- client/src/Pages/Notifications/index.tsx | 25 +++++++++++++++++++ .../Notifications/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 10 ++++++-- client/src/locales/en.json | 11 ++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/Notifications/index.tsx rename client/src/Pages/Notifications/{index.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Notifications/index.tsx b/client/src/Pages/Notifications/index.tsx new file mode 100644 index 000000000..cc8d68285 --- /dev/null +++ b/client/src/Pages/Notifications/index.tsx @@ -0,0 +1,25 @@ +import { BasePageWithStates } from "@/Components/v2/design-elements"; + +import { useTranslation } from "react-i18next"; +const NotificationsPage = () => { + const { t } = useTranslation(); + + return ( + + Notifications + + ); +}; +export default NotificationsPage; diff --git a/client/src/Pages/Notifications/index.jsx b/client/src/Pages/Notifications/old.jsx similarity index 100% rename from client/src/Pages/Notifications/index.jsx rename to client/src/Pages/Notifications/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index f9b5cb8a1..e0725e8e2 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -43,7 +43,7 @@ import CreateStatus from "../Pages/StatusPage/Create/index.jsx"; import StatusPages from "../Pages/StatusPage/StatusPages/index.jsx"; import Status from "../Pages/StatusPage/Status/index.jsx"; -import Notifications from "../Pages/Notifications/index.jsx"; +import Notifications from "../Pages/Notifications"; import CreateNotifications from "../Pages/Notifications/create/index.jsx"; // Settings @@ -210,7 +210,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> Date: Thu, 29 Jan 2026 18:14:23 +0000 Subject: [PATCH 040/392] v2-table --- .../components/NotificationsTable.tsx | 96 +++++++++++++++++++ client/src/Pages/Notifications/index.tsx | 31 ++++-- client/src/Types/Notification.ts | 17 ++++ client/src/locales/en.json | 5 + 4 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 client/src/Pages/Notifications/components/NotificationsTable.tsx create mode 100644 client/src/Types/Notification.ts diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx new file mode 100644 index 000000000..0e804d276 --- /dev/null +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -0,0 +1,96 @@ +import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; +import Typography from "@mui/material/Typography"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import { Table } from "@/Components/v2/design-elements"; + +import type { Notification } from "@/Types/Notification"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +interface NotificationsTableProps { + notifications: Notification[]; + setSelectedChannel: Function; +} + +export const NotificationsTable = ({ + notifications, + setSelectedChannel, +}: NotificationsTableProps) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + + const getActions = (channel: Notification): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/notification-channels/${channel.id}/configure`); + }, + closeMenu: true, + }, + + { + id: 7, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedChannel(channel); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.notificationName}; + }, + }, + + { + id: "type", + content: t("common.table.headers.type"), + render: (row) => { + return {row?.type}; + }, + }, + { + id: "destination", + content: t("pages.notifications.table.headers.destination"), + render: (row) => { + return {row?.address}; + }, + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + const headers = getHeaders(); + + return ( + { + navigate(`/notification-channels/${row.id}/configure`); + }} + /> + ); +}; diff --git a/client/src/Pages/Notifications/index.tsx b/client/src/Pages/Notifications/index.tsx index cc8d68285..c5d710394 100644 --- a/client/src/Pages/Notifications/index.tsx +++ b/client/src/Pages/Notifications/index.tsx @@ -1,25 +1,42 @@ import { BasePageWithStates } from "@/Components/v2/design-elements"; +import { NotificationsTable } from "@/Pages/Notifications/components/NotificationsTable"; +import { useState } from "react"; +import { useGet } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; +import type { Notification } from "@/Types/Notification"; + const NotificationsPage = () => { const { t } = useTranslation(); + const [selectedChannel, setSelectedChannel] = useState(null); + + const { + data: notifications, + isLoading, + isValidating, + error, + refetch, + } = useGet("/notifications/team"); + return ( - Notifications + ); }; + export default NotificationsPage; diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts new file mode 100644 index 000000000..669c840ca --- /dev/null +++ b/client/src/Types/Notification.ts @@ -0,0 +1,17 @@ +export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as const; +export type NotificationChannel = (typeof NotificationChannels)[number]; + +export interface Notification { + id: string; + userId: string; + teamId: string; + type: NotificationChannel; + notificationName: string; + address?: string; + phone?: string; + homeserverUrl?: string; + roomId?: string; + accessToken?: string; + createdAt: string; + updatedAt: string; +} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index f955f3c22..960397021 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -337,6 +337,11 @@ "Keep administrators informed of system changes" ], "title": "Notification channles are used to:" + }, + "table": { + "headers": { + "destination": "Destination" + } } } }, From c1258d3cdf0c10c3f4eedaa5376d93bfea1caa34 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 18:18:52 +0000 Subject: [PATCH 041/392] v2 --- .../components/NotificationsTable.tsx | 4 +-- client/src/Pages/Notifications/index.tsx | 27 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx index 0e804d276..306d8efa8 100644 --- a/client/src/Pages/Notifications/components/NotificationsTable.tsx +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -27,7 +27,7 @@ export const NotificationsTable = ({ id: 1, label: t("pages.common.monitors.actions.configure"), action: () => { - navigate(`/notification-channels/${channel.id}/configure`); + navigate(`/notifications/${channel.id}`); }, closeMenu: true, }, @@ -89,7 +89,7 @@ export const NotificationsTable = ({ headers={headers} data={notifications} onRowClick={(row) => { - navigate(`/notification-channels/${row.id}/configure`); + navigate(`/notifications/${row.id}`); }} /> ); diff --git a/client/src/Pages/Notifications/index.tsx b/client/src/Pages/Notifications/index.tsx index c5d710394..6e7dcc456 100644 --- a/client/src/Pages/Notifications/index.tsx +++ b/client/src/Pages/Notifications/index.tsx @@ -1,8 +1,9 @@ import { BasePageWithStates } from "@/Components/v2/design-elements"; import { NotificationsTable } from "@/Pages/Notifications/components/NotificationsTable"; +import { Dialog } from "@/Components/v2/inputs"; import { useState } from "react"; -import { useGet } from "@/Hooks/UseApi"; +import { useGet, useDelete } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; import type { Notification } from "@/Types/Notification"; @@ -10,6 +11,7 @@ const NotificationsPage = () => { const { t } = useTranslation(); const [selectedChannel, setSelectedChannel] = useState(null); + const isDialogOpen = Boolean(selectedChannel); const { data: notifications, @@ -17,7 +19,20 @@ const NotificationsPage = () => { isValidating, error, refetch, - } = useGet("/notifications/team"); + } = useGet("/notifications/team", {}, { keepPreviousData: true }); + + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleConfirm = async () => { + if (!selectedChannel) return; + await deleteFn(`/notifications/${selectedChannel.id}`); + setSelectedChannel(null); + refetch(); + }; + + const handleCancel = () => { + setSelectedChannel(null); + }; return ( { notifications={notifications ?? []} setSelectedChannel={setSelectedChannel} /> + ); }; From b1db74d96fc7ee8d2602392b80243ddadc8fecb0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 18:19:55 +0000 Subject: [PATCH 042/392] remove unused --- .../Notifications/components/ActionMenu.jsx | 83 ------------ client/src/Pages/Notifications/old.jsx | 121 ------------------ 2 files changed, 204 deletions(-) delete mode 100644 client/src/Pages/Notifications/components/ActionMenu.jsx delete mode 100644 client/src/Pages/Notifications/old.jsx diff --git a/client/src/Pages/Notifications/components/ActionMenu.jsx b/client/src/Pages/Notifications/components/ActionMenu.jsx deleted file mode 100644 index a3ccc4bbb..000000000 --- a/client/src/Pages/Notifications/components/ActionMenu.jsx +++ /dev/null @@ -1,83 +0,0 @@ -// Components -import Menu from "@mui/material/Menu"; -import IconButton from "@mui/material/IconButton"; -import Icon from "@/Components/v1/Icon"; -import MenuItem from "@mui/material/MenuItem"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; - -const ActionMenu = ({ notification, onDelete }) => { - const theme = useTheme(); - const navigate = useNavigate(); - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - const { t } = useTranslation(); - // Handlers - const handleClick = (event) => { - event.stopPropagation(); - setAnchorEl(event.currentTarget); - }; - - const handleClose = (event) => { - if (event) { - event.stopPropagation(); - } - setAnchorEl(null); - }; - - const handleRemove = (e) => { - e.stopPropagation(); - onDelete(notification.id); - handleClose(); - }; - - const handleConfigure = (e) => { - e.stopPropagation(); - navigate(`/notifications/${notification.id}`); - handleClose(); - }; - - return ( - <> - e.stopPropagation()} - > - - - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - {t("configure")} - - {t("delete")} - - - - ); -}; - -ActionMenu.propTypes = { - notification: PropTypes.object, - onDelete: PropTypes.func, -}; - -export default ActionMenu; diff --git a/client/src/Pages/Notifications/old.jsx b/client/src/Pages/Notifications/old.jsx deleted file mode 100644 index 70ed3ef96..000000000 --- a/client/src/Pages/Notifications/old.jsx +++ /dev/null @@ -1,121 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import Button from "@mui/material/Button"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import ActionMenu from "./components/ActionMenu.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { - useGetNotificationsByTeamId, - useDeleteNotification, -} from "../../Hooks/useNotifications.js"; -import { useTranslation } from "react-i18next"; - -const Notifications = () => { - const navigate = useNavigate(); - const theme = useTheme(); - const BREADCRUMBS = [{ name: "notifications", path: "/notifications" }]; - const [updateTrigger, setUpdateTrigger] = useState(false); - const [notifications, isLoading, error] = useGetNotificationsByTeamId(updateTrigger); - const [deleteNotification, isDeleting, deleteError] = useDeleteNotification(); - const { t } = useTranslation(); - // Handlers - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - const onDelete = (id) => { - deleteNotification(id, triggerUpdate); - }; - - const headers = [ - { - id: "name", - content: "Name", - render: (row) => { - return row.notificationName; - }, - }, - { - id: "target", - content: "Target", - render: (row) => { - return row.address; - }, - }, - { - id: "platform", - content: "Platform", - render: (row) => { - return row?.config?.platform || row.type; - }, - }, - - { - id: "actions", - content: "Actions", - onClick: (e) => { - e.stopPropagation(); - }, - render: (row) => { - return ( - - ); - }, - }, - ]; - - return ( - <> - - - - - - - {t("notifications.createTitle")} - navigate(`/notifications/${row.id}`), - rowSX: { - cursor: "pointer", - "&:hover td": { - backgroundColor: theme.palette.tertiary.main, - transition: "background-color .3s ease", - }, - }, - }} - headers={headers} - data={notifications} - /> - - - - ); -}; - -export default Notifications; From fc98192bcff381746ebf5c6d000e230142511c2c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 18:20:05 +0000 Subject: [PATCH 043/392] format --- client/src/Types/Notification.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 669c840ca..5b9645c9d 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -1,4 +1,11 @@ -export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as const; +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", +] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; export interface Notification { From a154ad11b5ba12c47f17694d85ed7bcb9cbaaaab Mon Sep 17 00:00:00 2001 From: mannilakash Date: Thu, 29 Jan 2026 13:21:56 -0500 Subject: [PATCH 044/392] fix: latest incident panel: Box -> Grid --- .../LatestIncidentsPanel/IncidentItem.jsx | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx index c9bf9abd2..e8001087b 100644 --- a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx +++ b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx @@ -6,6 +6,7 @@ import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js"; import Icon from "@/Components/v1/Icon"; import { useTranslation } from "react-i18next"; import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration"; +import { Grid } from "@mui/material"; const IncidentItem = ({ incident }) => { const { t } = useTranslation(); @@ -28,30 +29,24 @@ const IncidentItem = ({ incident }) => { }; return ( - - { > {incident.monitorName || t("incidentsPage.unknownMonitor")} - + - + { textTransform: "capitalize", }} /> - + - - {duration} - - + {duration} + + ); }; From 73f41906b002e320e6799c8b66a8a5b0cd65eadf Mon Sep 17 00:00:00 2001 From: sukritraj02 Date: Fri, 30 Jan 2026 00:00:35 +0530 Subject: [PATCH 045/392] initial changes , updated package-lock.json file --- client/package-lock.json | 25 +++++-------------- server/package-lock.json | 21 +++++++++++----- .../infrastructure/notificationsService.ts | 4 +-- 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index b4ad9f73c..db2bf8fd8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -97,7 +97,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -409,7 +408,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -453,7 +451,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1350,7 +1347,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.11.tgz", "integrity": "sha512-k2D3FLJS+/qD0qnd6ZlAjGFvaaxe1Dl10NyvpeDzIebMuYdn8VqYe6XBgGueEAtnzSJM4V03VD9kb5Fi24dnTA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", "@mui/core-downloads-tracker": "^6.4.11", @@ -1515,6 +1511,7 @@ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.0.tgz", "integrity": "sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@mui/utils": "^7.1.0", @@ -1542,6 +1539,7 @@ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@mui/types": "^7.4.2", @@ -1572,6 +1570,7 @@ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.0.tgz", "integrity": "sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@emotion/cache": "^11.13.5", @@ -1647,6 +1646,7 @@ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@mui/types": "^7.4.2", @@ -2712,7 +2712,6 @@ "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -2734,7 +2733,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.22.tgz", "integrity": "sha512-vUhG0YmQZ7kL/tmKLrD3g5zXbXXreZXB3pmROW8bg3CnLnpjkRVwUlLne7Ufa2r9yJ8+/6B73RzhAek5TBKh2Q==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2798,7 +2796,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3105,7 +3102,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -3518,8 +3514,7 @@ "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.1", @@ -3926,7 +3921,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4734,7 +4728,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -6011,7 +6004,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6025,7 +6017,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6229,8 +6220,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-persist": { "version": "6.0.0", @@ -6368,7 +6358,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.7" }, @@ -7004,7 +6993,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7124,7 +7112,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/server/package-lock.json b/server/package-lock.json index 7643f5f52..3f5b824b0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -873,6 +873,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1502,6 +1503,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1546,6 +1548,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4225,7 +4228,6 @@ "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=10.13.0" } @@ -4402,6 +4404,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4562,6 +4565,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5048,6 +5052,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5626,6 +5631,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6511,6 +6517,7 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", + "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7252,6 +7259,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7606,6 +7614,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9241,6 +9250,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11890,6 +11900,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13741,7 +13752,6 @@ "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", @@ -13768,7 +13778,6 @@ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10" } @@ -13779,7 +13788,6 @@ "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" @@ -13793,8 +13801,7 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", "license": "CC0-1.0", - "optional": true, - "peer": true + "optional": true }, "node_modules/swagger-ui-dist": { "version": "5.30.0", @@ -14079,6 +14086,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14241,6 +14249,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 06e8e3cd6..78b1dc8ab 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -214,8 +214,8 @@ export class NotificationsService implements INotificationsService { const combinedName = servicesCount === 1 ? servicesList : `${servicesCount} services: ${servicesList}`; // Use the first monitor as a base for URL and other fields. - const baseMonitor = monitors[0]; - const baseStatus = statusResponses[0]; + const baseMonitor = monitors[0]!; + const baseStatus = statusResponses[0]!; // Create a shallow clone so we don't mutate the original entity. // This preserves monitor properties while overriding the name for grouped display. From c0ba278e47c7b831df975bac11c360a8faa3426c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 18:34:50 +0000 Subject: [PATCH 046/392] loading state --- client/src/Pages/Notifications/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/Pages/Notifications/index.tsx b/client/src/Pages/Notifications/index.tsx index 6e7dcc456..daf59e74a 100644 --- a/client/src/Pages/Notifications/index.tsx +++ b/client/src/Pages/Notifications/index.tsx @@ -1,14 +1,17 @@ import { BasePageWithStates } from "@/Components/v2/design-elements"; import { NotificationsTable } from "@/Pages/Notifications/components/NotificationsTable"; import { Dialog } from "@/Components/v2/inputs"; +import { HeaderCreate } from "@/Components/v2/common"; import { useState } from "react"; import { useGet, useDelete } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; import type { Notification } from "@/Types/Notification"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; const NotificationsPage = () => { const { t } = useTranslation(); + const isAdmin = useIsAdmin(); const [selectedChannel, setSelectedChannel] = useState(null); const isDialogOpen = Boolean(selectedChannel); @@ -46,6 +49,11 @@ const NotificationsPage = () => { actionButtonText={t("pages.notifications.fallback.actionButton")} actionLink="/notifications/create" > + Date: Thu, 29 Jan 2026 18:41:46 +0000 Subject: [PATCH 047/392] initial commit --- client/package-lock.json | 4 ++-- client/package.json | 4 ++-- client/src/Pages/Notifications/create/index.tsx | 5 +++++ .../Pages/Notifications/create/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 10 ++++++++-- 5 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 client/src/Pages/Notifications/create/index.tsx rename client/src/Pages/Notifications/create/{index.jsx => old.jsx} (100%) diff --git a/client/package-lock.json b/client/package-lock.json index b4ad9f73c..d860ae844 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -29,7 +29,7 @@ "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", - "react-hook-form": "7.63.0", + "react-hook-form": "^7.63.0", "react-i18next": "^15.4.0", "react-icons": "5.5.0", "react-redux": "9.2.0", @@ -40,7 +40,7 @@ "redux-persist": "6.0.0", "swr": "^2.3.6", "vite-plugin-svgr": "^4.2.0", - "zod": "4.1.11" + "zod": "^4.1.11" }, "devDependencies": { "@types/node": "24.5.2", diff --git a/client/package.json b/client/package.json index a5c17e343..09599143a 100644 --- a/client/package.json +++ b/client/package.json @@ -34,7 +34,7 @@ "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", - "react-hook-form": "7.63.0", + "react-hook-form": "^7.63.0", "react-i18next": "^15.4.0", "react-icons": "5.5.0", "react-redux": "9.2.0", @@ -45,7 +45,7 @@ "redux-persist": "6.0.0", "swr": "^2.3.6", "vite-plugin-svgr": "^4.2.0", - "zod": "4.1.11" + "zod": "^4.1.11" }, "devDependencies": { "@types/node": "24.5.2", diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx new file mode 100644 index 000000000..82cee4861 --- /dev/null +++ b/client/src/Pages/Notifications/create/index.tsx @@ -0,0 +1,5 @@ +const NotificationsCreatePage = () => { + return
Create Notifications Page
; +}; + +export default NotificationsCreatePage; diff --git a/client/src/Pages/Notifications/create/index.jsx b/client/src/Pages/Notifications/create/old.jsx similarity index 100% rename from client/src/Pages/Notifications/create/index.jsx rename to client/src/Pages/Notifications/create/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index e0725e8e2..4806e45bd 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -44,7 +44,7 @@ import StatusPages from "../Pages/StatusPage/StatusPages/index.jsx"; import Status from "../Pages/StatusPage/Status/index.jsx"; import Notifications from "../Pages/Notifications"; -import CreateNotifications from "../Pages/Notifications/create/index.jsx"; +import CreateNotifications from "../Pages/Notifications/create"; // Settings import Account from "../Pages/Account/index.jsx"; @@ -220,7 +220,13 @@ const Routes = () => { /> } + element={ + <> + + + + + } /> Date: Fri, 30 Jan 2026 00:23:02 +0530 Subject: [PATCH 048/392] udated package-lock.json client --- client/package-lock.json | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index db2bf8fd8..2580d32dd 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -97,6 +97,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -408,6 +409,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -451,6 +453,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1347,6 +1350,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.11.tgz", "integrity": "sha512-k2D3FLJS+/qD0qnd6ZlAjGFvaaxe1Dl10NyvpeDzIebMuYdn8VqYe6XBgGueEAtnzSJM4V03VD9kb5Fi24dnTA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", "@mui/core-downloads-tracker": "^6.4.11", @@ -1511,7 +1515,6 @@ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.0.tgz", "integrity": "sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@mui/utils": "^7.1.0", @@ -1539,7 +1542,6 @@ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@mui/types": "^7.4.2", @@ -1570,7 +1572,6 @@ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.0.tgz", "integrity": "sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@emotion/cache": "^11.13.5", @@ -1646,7 +1647,6 @@ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.1", "@mui/types": "^7.4.2", @@ -2712,6 +2712,7 @@ "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -2733,6 +2734,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.22.tgz", "integrity": "sha512-vUhG0YmQZ7kL/tmKLrD3g5zXbXXreZXB3pmROW8bg3CnLnpjkRVwUlLne7Ufa2r9yJ8+/6B73RzhAek5TBKh2Q==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2796,6 +2798,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3102,6 +3105,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -3514,7 +3518,8 @@ "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.1", @@ -3921,6 +3926,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4728,6 +4734,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -6004,6 +6011,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6017,6 +6025,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6220,7 +6229,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-persist": { "version": "6.0.0", @@ -6358,6 +6368,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.7" }, @@ -6993,6 +7004,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7112,6 +7124,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -7355,4 +7368,4 @@ } } } -} +} \ No newline at end of file From 0b5e28c296646a5526c4db4bddee118c28f86543 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 19:29:36 +0000 Subject: [PATCH 049/392] form --- .../v2/design-elements/SplitBox.tsx | 78 +++++++++++++++++++ .../Components/v2/design-elements/index.tsx | 1 + client/src/Hooks/useNotificationForm.ts | 28 +++++++ .../src/Pages/Notifications/create/index.tsx | 75 +++++++++++++++++- client/src/Validation/notifications.ts | 51 ++++++++++++ client/src/locales/en.json | 8 ++ 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 client/src/Components/v2/design-elements/SplitBox.tsx create mode 100644 client/src/Hooks/useNotificationForm.ts create mode 100644 client/src/Validation/notifications.ts diff --git a/client/src/Components/v2/design-elements/SplitBox.tsx b/client/src/Components/v2/design-elements/SplitBox.tsx new file mode 100644 index 000000000..3a778dbcf --- /dev/null +++ b/client/src/Components/v2/design-elements/SplitBox.tsx @@ -0,0 +1,78 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +export const SplitBox = ({ + left, + right, +}: { + left: React.ReactNode; + right: React.ReactNode; +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + return ( + + + {left} + + + {right} + + + ); +}; + +export const ConfigBox = ({ + title, + subtitle, + leftContent, + rightContent, +}: { + title: string; + subtitle: string; + leftContent?: React.ReactNode; + rightContent: React.ReactNode; +}) => { + const theme = useTheme(); + return ( + + + {title} + + {subtitle} + {leftContent} + + } + right={rightContent} + /> + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index 33248a345..a96fea052 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -17,3 +17,4 @@ export * from "./StatBox"; export * from "./BaseChart"; export * from "./Gauge"; export * from "./Tabs"; +export * from "./SplitBox"; diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts new file mode 100644 index 000000000..dd882db67 --- /dev/null +++ b/client/src/Hooks/useNotificationForm.ts @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { notificationSchema } from "@/Validation/notifications"; +import type { Notification } from "@/Types/Notification"; + +interface UseNotificationFormOptions { + data?: Notification | null; +} + +export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => { + return useMemo(() => { + const defaults = + data?.type === "matrix" + ? { + type: "matrix" as const, + notificationName: data.notificationName || "", + homeserverUrl: data.homeserverUrl || "", + roomId: data.roomId || "", + accessToken: data.accessToken || "", + } + : { + type: (data?.type || "email") as Exclude, + notificationName: data?.notificationName || "", + address: data?.address || "", + }; + + return { schema: notificationSchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 82cee4861..58b14a426 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -1,5 +1,78 @@ +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { TextField } from "@/Components/v2/inputs"; + +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useGet } from "@/Hooks/UseApi"; +import { useNotificationForm } from "@/Hooks/useNotificationForm"; +import type { NotificationFormData } from "@/Validation/notifications"; +import type { Notification } from "@/Types/Notification"; +import { useTranslation } from "react-i18next"; + const NotificationsCreatePage = () => { - return
Create Notifications Page
; + const { t } = useTranslation(); + const { notificationId } = useParams<{ notificationId: string }>(); + const isEditMode = Boolean(notificationId); + + const { data: existingNotification, isLoading: notificationIsLoading } = + useGet(isEditMode ? `/notifications/${notificationId}` : null); + + const { schema, defaults } = useNotificationForm({ data: existingNotification }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { + register, + control, + watch, + reset, + formState: { errors }, + } = form; + + // Reset form when defaults change (i.e., when data is fetched) + useEffect(() => { + reset(defaults); + }, [defaults, reset]); + + const watchedType = watch("type"); + + // Suppress unused variable warnings - these will be used when UI is built + void register; + void control; + + return ( + + ( + + )} + /> + } + /> + + ); }; export default NotificationsCreatePage; diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts new file mode 100644 index 000000000..c78cf1ef9 --- /dev/null +++ b/client/src/Validation/notifications.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +const baseSchema = z.object({ + notificationName: z + .string() + .min(1, "Notification name is required") + .max(100, "Notification name must be at most 100 characters"), +}); + +const emailSchema = baseSchema.extend({ + type: z.literal("email"), + address: z.string().min(1, "Email is required").email("Please enter a valid email address"), +}); + +const slackSchema = baseSchema.extend({ + type: z.literal("slack"), + address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), +}); + +const discordSchema = baseSchema.extend({ + type: z.literal("discord"), + address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), +}); + +const webhookSchema = baseSchema.extend({ + type: z.literal("webhook"), + address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), +}); + +const pagerDutySchema = baseSchema.extend({ + type: z.literal("pager_duty"), + address: z.string().min(1, "Integration key is required"), +}); + +const matrixSchema = baseSchema.extend({ + type: z.literal("matrix"), + homeserverUrl: z.string().min(1, "Homeserver URL is required").url("Please enter a valid URL"), + roomId: z.string().min(1, "Room ID is required"), + accessToken: z.string().min(1, "Access token is required"), +}); + +export const notificationSchema = z.discriminatedUnion("type", [ + emailSchema, + slackSchema, + discordSchema, + webhookSchema, + pagerDutySchema, + matrixSchema, +]); + +export type NotificationFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 960397021..b98b43b7d 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -342,6 +342,14 @@ "headers": { "destination": "Destination" } + }, + "form": { + "notificationName": { + "description": "A descriptive name for the notification channel", + "optionName": "Channel name", + "placeholder": "e.g. Production Alerts", + "title": "Channel name" + } } } }, From 22ae6b4be618cdee1b6604925560d383a5efddf0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 19:34:49 +0000 Subject: [PATCH 050/392] type --- .../src/Pages/Notifications/create/index.tsx | 37 ++++++++++++++++++- client/src/locales/en.json | 5 +++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 58b14a426..1c4d586a9 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -1,5 +1,7 @@ import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; -import { TextField } from "@/Components/v2/inputs"; +import { TextField, Select } from "@/Components/v2/inputs"; +import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; import { useEffect } from "react"; import { useParams } from "react-router-dom"; @@ -10,6 +12,7 @@ import { useNotificationForm } from "@/Hooks/useNotificationForm"; import type { NotificationFormData } from "@/Validation/notifications"; import type { Notification } from "@/Types/Notification"; import { useTranslation } from "react-i18next"; +import { NotificationChannels } from "@/Types/Notification"; const NotificationsCreatePage = () => { const { t } = useTranslation(); @@ -71,6 +74,38 @@ const NotificationsCreatePage = () => { /> } /> + { + return ( + + ); + }} + /> + } + /> ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b98b43b7d..7ffa5e00e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -349,6 +349,11 @@ "optionName": "Channel name", "placeholder": "e.g. Production Alerts", "title": "Channel name" + }, + "type": { + "description": "Select the type of notification channel to create.", + "optionType": "Type", + "title": "Channel type" } } } From a25ff03b71d9f0e16476c71c3804bfdf7d049869 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 20:12:50 +0000 Subject: [PATCH 051/392] remaining fields --- .../src/Pages/Notifications/create/index.tsx | 143 ++++++++++++++++-- client/src/locales/en.json | 31 ++++ 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 1c4d586a9..29a6ea2f6 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -3,7 +3,7 @@ import { TextField, Select } from "@/Components/v2/inputs"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -19,8 +19,9 @@ const NotificationsCreatePage = () => { const { notificationId } = useParams<{ notificationId: string }>(); const isEditMode = Boolean(notificationId); - const { data: existingNotification, isLoading: notificationIsLoading } = - useGet(isEditMode ? `/notifications/${notificationId}` : null); + const { data: existingNotification } = useGet( + isEditMode ? `/notifications/${notificationId}` : null + ); const { schema, defaults } = useNotificationForm({ data: existingNotification }); @@ -34,7 +35,7 @@ const NotificationsCreatePage = () => { control, watch, reset, - formState: { errors }, + formState: {}, } = form; // Reset form when defaults change (i.e., when data is fetched) @@ -44,6 +45,32 @@ const NotificationsCreatePage = () => { const watchedType = watch("type"); + const addressConfig = useMemo(() => { + if (watchedType === "pager_duty") { + return { + title: t("pages.notifications.form.pagerDuty.title"), + description: t("pages.notifications.form.pagerDuty.description"), + fieldLabel: t("pages.notifications.form.pagerDuty.optionIntegrationKey"), + placeholder: t("pages.notifications.form.pagerDuty.placeholder"), + }; + } + if (watchedType === "email") { + return { + title: t("pages.notifications.form.address.title"), + description: t("pages.notifications.form.address.description"), + fieldLabel: t("pages.notifications.form.address.optionAddress"), + placeholder: t("pages.notifications.form.address.placeholderEmail"), + }; + } + return { + title: t("pages.notifications.form.address.title"), + description: t("pages.notifications.form.address.description"), + fieldLabel: t("pages.notifications.form.address.optionAddress"), + placeholder: t("pages.notifications.form.address.placeholderWebhook"), + }; + }, [watchedType, t]); + + // Suppress unused variable warnings - these will be used when UI is built void register; void control; @@ -58,17 +85,15 @@ const NotificationsCreatePage = () => { name="notificationName" control={control} defaultValue={defaults.notificationName} - render={({ field }) => ( + render={({ field, fieldState }) => ( )} /> @@ -82,12 +107,12 @@ const NotificationsCreatePage = () => { name="type" control={control} defaultValue={defaults.type} - render={({ field }) => { + render={({ field, fieldState }) => { return ( - {NotificationChannels.map((type: string) => { - return ( - - {type} - - ); - })} - - ); - }} + render={({ field, fieldState }) => ( + + )} /> } /> @@ -157,76 +160,86 @@ const NotificationsCreatePage = () => { )} {watchedType === "matrix" && ( ( - - )} - /> - } - /> - )} - {watchedType === "matrix" && ( - ( - - )} - /> - } - /> - )} - {watchedType === "matrix" && ( - ( - - )} - /> + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + } /> )} + + + + ); }; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 4806e45bd..10ee6f9cb 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -231,7 +231,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> Date: Thu, 29 Jan 2026 20:47:47 +0000 Subject: [PATCH 053/392] submit --- .../src/Pages/Notifications/create/index.tsx | 120 +++++++++--------- 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 30e098857..b5b98a7a0 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -7,9 +7,10 @@ import { useTheme } from "@mui/material/styles"; import { useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useGet } from "@/Hooks/UseApi"; +import { useGet, usePost } from "@/Hooks/UseApi"; import { useNotificationForm } from "@/Hooks/useNotificationForm"; import type { NotificationFormData } from "@/Validation/notifications"; import type { Notification } from "@/Types/Notification"; @@ -17,73 +18,74 @@ import { useTranslation } from "react-i18next"; import { NotificationChannels } from "@/Types/Notification"; const NotificationsCreatePage = () => { - const { t } = useTranslation(); - const theme = useTheme(); - const { notificationId } = useParams<{ notificationId: string }>(); - const isEditMode = Boolean(notificationId); + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + const { notificationId } = useParams<{ notificationId: string }>(); + const isEditMode = Boolean(notificationId); - const { data: existingNotification } = useGet( - isEditMode ? `/notifications/${notificationId}` : null - ); + const { data: existingNotification } = useGet( + isEditMode ? `/notifications/${notificationId}` : null + ); - const { schema, defaults } = useNotificationForm({ data: existingNotification }); + const { post, loading: isSubmitting } = usePost(); - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: defaults, - }); + const { schema, defaults } = useNotificationForm({ data: existingNotification }); - const { - control, - watch, - reset, - handleSubmit, - } = form; + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); - useEffect(() => { - reset(defaults); - }, [defaults, reset]); + const { control, watch, reset, handleSubmit } = form; - const watchedType = watch("type"); + useEffect(() => { + reset(defaults); + }, [defaults, reset]); - const addressConfig = useMemo(() => { - if (watchedType === "pager_duty") { - return { - title: t("pages.notifications.form.pagerDuty.title"), - description: t("pages.notifications.form.pagerDuty.description"), - fieldLabel: t("pages.notifications.form.pagerDuty.optionIntegrationKey"), - placeholder: t("pages.notifications.form.pagerDuty.placeholder"), - }; - } - if (watchedType === "email") { - return { - title: t("pages.notifications.form.address.title"), - description: t("pages.notifications.form.address.description"), - fieldLabel: t("pages.notifications.form.address.optionAddress"), - placeholder: t("pages.notifications.form.address.placeholderEmail"), - }; - } - return { - title: t("pages.notifications.form.address.title"), - description: t("pages.notifications.form.address.description"), - fieldLabel: t("pages.notifications.form.address.optionAddress"), - placeholder: t("pages.notifications.form.address.placeholderWebhook"), - }; - }, [watchedType, t]); + const watchedType = watch("type"); - const onSubmit = (data: NotificationFormData) => { - console.log(data); - }; + const addressConfig = useMemo(() => { + if (watchedType === "pager_duty") { + return { + title: t("pages.notifications.form.pagerDuty.title"), + description: t("pages.notifications.form.pagerDuty.description"), + fieldLabel: t("pages.notifications.form.pagerDuty.optionIntegrationKey"), + placeholder: t("pages.notifications.form.pagerDuty.placeholder"), + }; + } + if (watchedType === "email") { + return { + title: t("pages.notifications.form.address.title"), + description: t("pages.notifications.form.address.description"), + fieldLabel: t("pages.notifications.form.address.optionAddress"), + placeholder: t("pages.notifications.form.address.placeholderEmail"), + }; + } + return { + title: t("pages.notifications.form.address.title"), + description: t("pages.notifications.form.address.description"), + fieldLabel: t("pages.notifications.form.address.optionAddress"), + placeholder: t("pages.notifications.form.address.placeholderWebhook"), + }; + }, [watchedType, t]); - const handleTest = () => { - console.log("Test notification"); - }; + const onSubmit = async (data: NotificationFormData) => { + const result = await post("/notifications", data); + if (result) { + navigate("/notifications"); + } + }; - return ( - + const handleTest = () => { + console.log("Test notification"); + }; + + return ( + { {t("common.buttons.test")} From 2a3683c74a8bca9d6eebf7d65e6b4e6297ae3a14 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 21:08:26 +0000 Subject: [PATCH 057/392] breadcurmbs --- .../v2/design-elements/Breadcrumb.tsx | 23 +++++++++++++++---- .../components/NotificationsTable.tsx | 4 ++-- client/src/Routes/index.jsx | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/client/src/Components/v2/design-elements/Breadcrumb.tsx b/client/src/Components/v2/design-elements/Breadcrumb.tsx index 492ed0ec9..1dea2dff6 100644 --- a/client/src/Components/v2/design-elements/Breadcrumb.tsx +++ b/client/src/Components/v2/design-elements/Breadcrumb.tsx @@ -8,15 +8,28 @@ const isId = (segment: string): boolean => { return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment); }; +const actionSegments = ["create", "configure"]; + export const Breadcrumb = () => { const theme = useTheme(); const location = useLocation(); const segments = location.pathname.split("/").filter((x) => x); - // Build simplified breadcrumb: "uptime" or "uptime / details" + // Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create" const basePage = segments[0] || "home"; - const hasDetailsPage = segments.length > 1 && isId(segments[1]); + + const secondSegment = segments[1]; + const isActionPage = secondSegment && actionSegments.includes(secondSegment); // create/config + const isDetailsPage = secondSegment && isId(secondSegment); // details + const hasSubPage = isActionPage || isDetailsPage; + + const getSubPageLabel = (): string => { + if (isActionPage) { + return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1); + } + return "Details"; + }; return ( { }, }} > - {hasDetailsPage ? ( + {hasSubPage ? ( { {basePage.charAt(0).toUpperCase() + basePage.slice(1)} )} - {hasDetailsPage && ( + {hasSubPage && ( { color: theme.palette.primary.main, }} > - Details + {getSubPageLabel()} )} diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx index 306d8efa8..2885a34bb 100644 --- a/client/src/Pages/Notifications/components/NotificationsTable.tsx +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -27,7 +27,7 @@ export const NotificationsTable = ({ id: 1, label: t("pages.common.monitors.actions.configure"), action: () => { - navigate(`/notifications/${channel.id}`); + navigate(`/notifications/configure/${channel.id}`); }, closeMenu: true, }, @@ -89,7 +89,7 @@ export const NotificationsTable = ({ headers={headers} data={notifications} onRowClick={(row) => { - navigate(`/notifications/${row.id}`); + navigate(`/notifications/configure/${row.id}`); }} /> ); diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 10ee6f9cb..498be4ba7 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -230,7 +230,7 @@ const Routes = () => { /> From 348eea5475cce9b04b0e2c3eb92f99431ba7c221 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 21:08:36 +0000 Subject: [PATCH 058/392] breadcrumb fix --- client/src/Components/v2/design-elements/Breadcrumb.tsx | 6 ++++-- client/src/locales/en.json | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/src/Components/v2/design-elements/Breadcrumb.tsx b/client/src/Components/v2/design-elements/Breadcrumb.tsx index 1dea2dff6..e0a310c9f 100644 --- a/client/src/Components/v2/design-elements/Breadcrumb.tsx +++ b/client/src/Components/v2/design-elements/Breadcrumb.tsx @@ -3,6 +3,7 @@ import Typography from "@mui/material/Typography"; import { Link, useLocation } from "react-router-dom"; import { useTheme } from "@mui/material/styles"; import { ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; const isId = (segment: string): boolean => { return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment); @@ -11,13 +12,14 @@ const isId = (segment: string): boolean => { const actionSegments = ["create", "configure"]; export const Breadcrumb = () => { + const { t } = useTranslation(); const theme = useTheme(); const location = useLocation(); const segments = location.pathname.split("/").filter((x) => x); // Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create" - const basePage = segments[0] || "home"; + const basePage = segments[0] || t("common.breadcrumbs.home"); const secondSegment = segments[1]; const isActionPage = secondSegment && actionSegments.includes(secondSegment); // create/config @@ -28,7 +30,7 @@ export const Breadcrumb = () => { if (isActionPage) { return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1); } - return "Details"; + return t("common.breadcrumbs.details"); }; return ( diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 79e901dc1..80da1835c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -2,6 +2,10 @@ "common": { "appName": "Checkmate", "monitoringAgentName": "Capture", + "breadcrumbs": { + "home": "Home", + "details": "Details" + }, "labels": { "responseTime": "Response time" }, From 2dca5ccb6715eb118b6fed4fa8a679167f06c53e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 21:09:00 +0000 Subject: [PATCH 059/392] format --- client/src/Validation/notifications.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index c78cf1ef9..e505c695f 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -9,7 +9,10 @@ const baseSchema = z.object({ const emailSchema = baseSchema.extend({ type: z.literal("email"), - address: z.string().min(1, "Email is required").email("Please enter a valid email address"), + address: z + .string() + .min(1, "Email is required") + .email("Please enter a valid email address"), }); const slackSchema = baseSchema.extend({ @@ -34,7 +37,10 @@ const pagerDutySchema = baseSchema.extend({ const matrixSchema = baseSchema.extend({ type: z.literal("matrix"), - homeserverUrl: z.string().min(1, "Homeserver URL is required").url("Please enter a valid URL"), + homeserverUrl: z + .string() + .min(1, "Homeserver URL is required") + .url("Please enter a valid URL"), roomId: z.string().min(1, "Room ID is required"), accessToken: z.string().min(1, "Access token is required"), }); From a0aee182dec300cb0f0b032bd55cff783d82d689 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 21:15:14 +0000 Subject: [PATCH 060/392] remove unused --- .../NotificationIntegrationModal.jsx | 419 ------------------ .../Components/TabComponent.jsx | 115 ----- .../Components/TabPanel.jsx | 41 -- .../Hooks/useNotification.js | 129 ------ client/src/Hooks/useNotifications.js | 152 +------ client/src/Pages/Notifications/create/old.jsx | 320 ------------- client/src/Pages/Notifications/utils.js | 44 -- 7 files changed, 1 insertion(+), 1219 deletions(-) delete mode 100644 client/src/Components/v1/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx delete mode 100644 client/src/Components/v1/NotificationIntegrationModal/Components/TabComponent.jsx delete mode 100644 client/src/Components/v1/NotificationIntegrationModal/Components/TabPanel.jsx delete mode 100644 client/src/Components/v1/NotificationIntegrationModal/Hooks/useNotification.js delete mode 100644 client/src/Pages/Notifications/create/old.jsx delete mode 100644 client/src/Pages/Notifications/utils.js diff --git a/client/src/Components/v1/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx b/client/src/Components/v1/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx deleted file mode 100644 index 2aae996f6..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx +++ /dev/null @@ -1,419 +0,0 @@ -import { useState, useMemo, useEffect, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; - -import { - Dialog, - DialogContent, - DialogActions, - Button, - Typography, - Box, - Tabs, - Tab, - Stack, -} from "@mui/material"; -import { useTheme } from "@emotion/react"; -import TabPanel from "./TabPanel.jsx"; -import TabComponent from "./TabComponent.jsx"; -import useNotifications from "../Hooks/useNotification.js"; - -// Define constants for notification types to avoid magic values -const NOTIFICATION_TYPES = { - SLACK: "slack", - DISCORD: "discord", - TELEGRAM: "telegram", - WEBHOOK: "webhook", -}; - -// Define constants for field IDs -const FIELD_IDS = { - WEBHOOK: "webhook", - TOKEN: "token", - CHAT_ID: "chatId", - URL: "url", -}; - -const NotificationIntegrationModal = ({ - open, - onClose, - monitor, - setMonitor, - // Optional prop to configure available notification types - notificationTypes = null, -}) => { - const { t } = useTranslation(); - const theme = useTheme(); - const [tabValue, setTabValue] = useState(0); - - const [loading, _, sendTestNotification] = useNotifications(); - - // Helper to get the field state key with error handling - const getFieldKey = useCallback( - (typeId, fieldId) => { - if (typeof typeId !== "string" || typeId === "") { - throw new Error(t("errorInvalidTypeId")); - } - - if (typeof fieldId !== "string" || fieldId === "") { - throw new Error(t("errorInvalidFieldId")); - } - - return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`; - }, - [t] - ); - - // Define notification types - const DEFAULT_NOTIFICATION_TYPES = [ - { - id: NOTIFICATION_TYPES.SLACK, - label: t("notifications.slack.label"), - description: t("notifications.slack.description"), - fields: [ - { - id: FIELD_IDS.WEBHOOK, - label: t("notifications.slack.webhookLabel"), - placeholder: t("notifications.slack.webhookPlaceholder"), - type: "text", - }, - ], - }, - { - id: NOTIFICATION_TYPES.DISCORD, - label: t("notifications.discord.label"), - description: t("notifications.discord.description"), - fields: [ - { - id: FIELD_IDS.WEBHOOK, - label: t("notifications.discord.webhookLabel"), - placeholder: t("notifications.discord.webhookPlaceholder"), - type: "text", - }, - ], - }, - { - id: NOTIFICATION_TYPES.TELEGRAM, - label: t("notifications.telegram.label"), - description: t("notifications.telegram.description"), - fields: [ - { - id: FIELD_IDS.TOKEN, - label: t("notifications.telegram.tokenLabel"), - placeholder: t("notifications.telegram.tokenPlaceholder"), - type: "text", - }, - { - id: FIELD_IDS.CHAT_ID, - label: t("notifications.telegram.chatIdLabel"), - placeholder: t("notifications.telegram.chatIdPlaceholder"), - type: "text", - }, - ], - }, - { - id: NOTIFICATION_TYPES.WEBHOOK, - label: t("notifications.webhook.label"), - description: t("notifications.webhook.description"), - fields: [ - { - id: FIELD_IDS.URL, - label: t("notifications.webhook.urlLabel"), - placeholder: t("notifications.webhook.urlPlaceholder"), - type: "text", - }, - ], - }, - ]; - - // Use provided notification types or default to our translated ones - const activeNotificationTypes = notificationTypes || DEFAULT_NOTIFICATION_TYPES; - - // Memoized function to initialize integrations state - const initialIntegrationsState = useMemo(() => { - const state = {}; - - activeNotificationTypes.forEach((type) => { - // Add enabled flag for each notification type - state[type.id] = false; - - // Add state for each field in the notification type - type.fields.forEach((field) => { - const fieldKey = getFieldKey(type.id, field.id); - state[fieldKey] = ""; - }); - }); - - return state; - }, [activeNotificationTypes, getFieldKey]); // Only recompute when these dependencies change - - const [integrations, setIntegrations] = useState(initialIntegrationsState); - - useEffect(() => { - if (open) { - const extractNotificationValues = () => { - const values = {}; - - if (!monitor?.notifications || !Array.isArray(monitor.notifications)) { - return values; - } - - monitor.notifications.forEach((notification) => { - // Handle notification based on its structure - if (notification.type === "webhook" && notification.platform) { - if (typeof notification.config === "undefined") return; - const platform = notification.platform; - values[platform] = true; // Set platform as enabled - - // Extract configuration based on platform - switch (platform) { - case NOTIFICATION_TYPES.SLACK: - case NOTIFICATION_TYPES.DISCORD: - if (notification.config.webhookUrl) { - values[getFieldKey(platform, FIELD_IDS.WEBHOOK)] = - notification.config.webhookUrl; - } - break; - case NOTIFICATION_TYPES.TELEGRAM: - if (notification.config.botToken) { - values[getFieldKey(platform, FIELD_IDS.TOKEN)] = - notification.config.botToken; - } - if (notification.config.chatId) { - values[getFieldKey(platform, FIELD_IDS.CHAT_ID)] = - notification.config.chatId; - } - break; - case NOTIFICATION_TYPES.WEBHOOK: - if (notification.config.webhookUrl) { - values[getFieldKey(platform, FIELD_IDS.URL)] = - notification.config.webhookUrl; - } - break; - } - } - }); - - return values; - }; - - const extractedValues = extractNotificationValues(); - setIntegrations((prev) => ({ - ...prev, - ...extractedValues, - })); - } - }, [open, monitor, getFieldKey]); - - const handleChangeTab = (event, newValue) => { - setTabValue(newValue); - }; - - const handleIntegrationChange = (type, checked) => { - setIntegrations((prev) => ({ - ...prev, - [type]: checked, - })); - }; - - const handleInputChange = (type, value) => { - setIntegrations((prev) => ({ - ...prev, - [type]: value, - })); - }; - - const handleTestNotification = async (type) => { - // Get the notification type details - const notificationType = activeNotificationTypes.find((t) => t.id === type); - - if (typeof notificationType === "undefined") { - return; - } - - // Prepare config object based on notification type - const config = {}; - - // Add each field value to the config object - notificationType.fields.forEach((field) => { - const fieldKey = getFieldKey(type, field.id); - config[field.id] = integrations[fieldKey]; - }); - - await sendTestNotification(type, config); - }; - - // In NotificationIntegrationModal.jsx, update the handleSave function: - - const handleSave = () => { - // Get existing notifications - const notifications = [...(monitor?.notifications || [])]; - - // Get all notification types IDs - const existingTypes = activeNotificationTypes.map((type) => type.id); - - // Filter out notifications that are configurable in this modal - const filteredNotifications = notifications.filter((notification) => { - if (notification.platform) { - return !existingTypes.includes(notification.platform); - } - - return !existingTypes.includes(notification.type); - }); - - // Add each enabled notification with its configured fields - activeNotificationTypes.forEach((type) => { - if (integrations[type.id]) { - let notificationObject = { - type: "webhook", - platform: type.id, // Set platform to identify the specific service - config: {}, - }; - - // Configure based on notification type - switch (type.id) { - case "slack": - case "discord": - notificationObject.config.webhookUrl = - integrations[getFieldKey(type.id, "webhook")]; - break; - case "telegram": - notificationObject.config.botToken = - integrations[getFieldKey(type.id, "token")]; - notificationObject.config.chatId = - integrations[getFieldKey(type.id, "chatId")]; - break; - case "webhook": - notificationObject.config.webhookUrl = - integrations[getFieldKey(type.id, "url")]; - break; - } - - filteredNotifications.push(notificationObject); - } - }); - - // Update monitor with new notifications - setMonitor((prev) => ({ - ...prev, - notifications: filteredNotifications, - })); - - onClose(); - }; - - return ( - - - - {/* Left sidebar with tabs */} - - - {t("notifications.addOrEditNotifications")} - - - - {activeNotificationTypes.map((type) => ( - - ))} - - - - {/* Right side content */} - - {activeNotificationTypes.map((type, index) => ( - - - - ))} - - - - - - - - ); -}; - -NotificationIntegrationModal.propTypes = { - open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - monitor: PropTypes.object.isRequired, - setMonitor: PropTypes.func.isRequired, - notificationTypes: PropTypes.array, -}; - -export default NotificationIntegrationModal; diff --git a/client/src/Components/v1/NotificationIntegrationModal/Components/TabComponent.jsx b/client/src/Components/v1/NotificationIntegrationModal/Components/TabComponent.jsx deleted file mode 100644 index 87fb2c301..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Components/TabComponent.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from "react"; -import { Typography, Box, Button, CircularProgress } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "@emotion/react"; -import TextInput from "../../Inputs/TextInput/index.jsx"; -import Checkbox from "../../Inputs/Checkbox/index.jsx"; - -const TabComponent = ({ - type, - integrations, - handleIntegrationChange, - handleInputChange, - handleTestNotification, - isLoading, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - // Helper to get the field state key (e.g., slackWebhook, telegramToken) - const getFieldKey = (typeId, fieldId) => { - return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`; - }; - - // Check if all fields have values to enable test button - const areAllFieldsFilled = () => { - return type.fields.every((field) => { - const fieldKey = getFieldKey(type.id, field.id); - return integrations[fieldKey]; - }); - }; - - return ( - <> - - {type.label} - - - - {type.description} - - - - handleIntegrationChange(type.id, e.target.checked)} - disabled={isLoading} - /> - - - {type.fields.map((field) => { - const fieldKey = getFieldKey(type.id, field.id); - - return ( - - - {field.label} - - - handleInputChange(fieldKey, e.target.value)} - disabled={!integrations[type.id] || isLoading} - /> - - ); - })} - - - - - - ); -}; - -export default TabComponent; diff --git a/client/src/Components/v1/NotificationIntegrationModal/Components/TabPanel.jsx b/client/src/Components/v1/NotificationIntegrationModal/Components/TabPanel.jsx deleted file mode 100644 index 93546e7bc..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Components/TabPanel.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { Box } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -/** - * TabPanel component that displays content for the selected tab. - * - * @component - * @param {Object} props - The component props. - * @param {React.ReactNode} props.children - The content to be displayed when this tab panel is selected. - * @param {number} props.value - The currently selected tab value. - * @param {number} props.index - The index of this specific tab panel. - * @param {Object} props.other - Any additional props to be spread to the root element. - * @returns {React.ReactElement|null} The rendered tab panel or null if not selected. - */ -function TabPanel({ children, value, index, ...other }) { - const theme = useTheme(); - - return ( - - ); -} - -TabPanel.propTypes = { - children: PropTypes.node, - - index: PropTypes.number.isRequired, - - value: PropTypes.number.isRequired, -}; - -export default TabPanel; diff --git a/client/src/Components/v1/NotificationIntegrationModal/Hooks/useNotification.js b/client/src/Components/v1/NotificationIntegrationModal/Hooks/useNotification.js deleted file mode 100644 index c0712fa5f..000000000 --- a/client/src/Components/v1/NotificationIntegrationModal/Hooks/useNotification.js +++ /dev/null @@ -1,129 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { networkService } from "../../../../Utils/NetworkService.js"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; - -// Define constants for notification types to avoid magic values -const NOTIFICATION_TYPES = { - SLACK: "slack", - DISCORD: "discord", - TELEGRAM: "telegram", - WEBHOOK: "webhook", -}; - -// Define constants for field IDs -const FIELD_IDS = { - WEBHOOK: "webhook", - TOKEN: "token", - CHAT_ID: "chatId", - URL: "url", -}; - -/** - * Custom hook for notification-related operations - */ -const useNotifications = () => { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(undefined); - const { t } = useTranslation(); - - /** - * Send a test notification - * @param {string} type - The notification type (slack, discord, telegram, webhook) - * @param {object} config - Configuration object with necessary params - */ - const sendTestNotification = async (type, config) => { - setLoading(true); - setError(undefined); - - // Validation based on notification type - let payload = { platform: type }; - let isValid = true; - let errorMessage = ""; - - switch (type) { - case NOTIFICATION_TYPES.SLACK: - payload.webhookUrl = config.webhook; - if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") { - isValid = false; - errorMessage = t("notifications.slack.webhookRequired"); - } - break; - - case NOTIFICATION_TYPES.DISCORD: - payload.webhookUrl = config.webhook; - if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") { - isValid = false; - errorMessage = t("notifications.discord.webhookRequired"); - } - break; - - case NOTIFICATION_TYPES.TELEGRAM: - payload.botToken = config.token; - payload.chatId = config.chatId; - if ( - typeof payload.botToken === "undefined" || - payload.botToken === "" || - typeof payload.chatId === "undefined" || - payload.chatId === "" - ) { - isValid = false; - errorMessage = t("notifications.telegram.fieldsRequired"); - } - break; - - case NOTIFICATION_TYPES.WEBHOOK: - payload.webhookUrl = config.url; - payload.platform = NOTIFICATION_TYPES.SLACK; - if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") { - isValid = false; - errorMessage = t("notifications.webhook.urlRequired"); - } - break; - - default: - isValid = false; - errorMessage = t("notifications.unsupportedType"); - } - - // If validation fails, show error and return - if (isValid === false) { - createToast({ - body: errorMessage, - variant: "error", - }); - setLoading(false); - return; - } - - try { - const response = await networkService.testNotification({ - platform: type, - payload: payload, - }); - - if (response.data.success === true) { - createToast({ - body: t("notifications.testSuccess"), - variant: "info", - }); - } else { - throw new Error(response.data.msg || t("notifications.testFailed")); - } - } catch (error) { - const errorMsg = - error.response?.data?.msg || error.message || t("notifications.networkError"); - createToast({ - body: `${t("notifications.testFailed")}: ${errorMsg}`, - variant: "error", - }); - setError(errorMsg); - } finally { - setLoading(false); - } - }; - - return [loading, error, sendTestNotification]; -}; - -export default useNotifications; diff --git a/client/src/Hooks/useNotifications.js b/client/src/Hooks/useNotifications.js index 6c5919fac..6cf84e45f 100644 --- a/client/src/Hooks/useNotifications.js +++ b/client/src/Hooks/useNotifications.js @@ -1,36 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { createToast } from "../Utils/toastUtils.jsx"; import { networkService } from "../main.jsx"; -import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { NOTIFICATION_TYPES } from "../Pages/Notifications/utils.js"; - -const useCreateNotification = () => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const createNotification = async (notification) => { - try { - setIsLoading(true); - await networkService.createNotification({ notification }); - createToast({ - body: t("notifications.create.success"), - }); - navigate("/notifications"); - } catch (error) { - setError(error); - createToast({ - body: t("notifications.create.failed"), - }); - } finally { - setIsLoading(false); - } - }; - - return [createNotification, isLoading, error]; -}; const useGetNotificationsByTeamId = (updateTrigger) => { const [notifications, setNotifications] = useState([]); @@ -60,119 +31,6 @@ const useGetNotificationsByTeamId = (updateTrigger) => { return [notifications, isLoading, error]; }; -const useDeleteNotification = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { t } = useTranslation(); - - const deleteNotification = async (id, callback) => { - try { - setIsLoading(true); - await networkService.deleteNotificationById({ id }); - createToast({ - body: t("notifications.delete.success"), - }); - if (callback) { - callback(); - } - } catch (error) { - setError(error); - createToast({ - body: t("notifications.delete.failed"), - }); - } finally { - setIsLoading(false); - } - }; - - return [deleteNotification, isLoading, error]; -}; - -const useGetNotificationById = (id, setNotification) => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const getNotificationById = useCallback(async () => { - try { - setIsLoading(true); - const response = await networkService.getNotificationById({ id }); - - const notification = response?.data?.data ?? null; - - const notificationData = { - address: notification?.address, - notificationName: notification?.notificationName, - type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?.id, - }; - - setNotification(notificationData); - } catch (error) { - setError(error); - } finally { - setIsLoading(false); - } - }, [id, setNotification]); - - useEffect(() => { - if (id) { - getNotificationById(); - } - }, [getNotificationById, id]); - - return [isLoading, error]; -}; - -const useEditNotification = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { t } = useTranslation(); - const navigate = useNavigate(); - const editNotification = async (id, notification) => { - try { - setIsLoading(true); - await networkService.editNotification({ id, notification }); - createToast({ - body: t("notifications.edit.success"), - }); - navigate(`/notifications`); - } catch (error) { - setError(error); - createToast({ - body: t("notifications.edit.failed"), - }); - } finally { - setIsLoading(false); - } - }; - - return [editNotification, isLoading, error]; -}; - -const useTestNotification = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { t } = useTranslation(); - - const testNotification = async (notification) => { - try { - setIsLoading(true); - await networkService.testNotification({ notification }); - createToast({ - body: t("notifications.test.success"), - }); - } catch (error) { - setError(error); - createToast({ - body: error?.response?.data?.msg || t("notifications.test.failed"), - }); - } finally { - setIsLoading(false); - } - }; - - return [testNotification, isLoading, error]; -}; - const useTestAllNotifications = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(undefined); @@ -197,12 +55,4 @@ const useTestAllNotifications = () => { return [testAllNotifications, isLoading, error]; }; -export { - useCreateNotification, - useGetNotificationsByTeamId, - useDeleteNotification, - useGetNotificationById, - useEditNotification, - useTestNotification, - useTestAllNotifications, -}; +export { useGetNotificationsByTeamId, useTestAllNotifications }; diff --git a/client/src/Pages/Notifications/create/old.jsx b/client/src/Pages/Notifications/create/old.jsx deleted file mode 100644 index 14b72ab61..000000000 --- a/client/src/Pages/Notifications/create/old.jsx +++ /dev/null @@ -1,320 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import Button from "@mui/material/Button"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import Box from "@mui/material/Box"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { - useCreateNotification, - useGetNotificationById, - useEditNotification, - useTestNotification, - useDeleteNotification, -} from "../../../Hooks/useNotifications.js"; -import { notificationValidation } from "../../../Validation/validation.js"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; -import { useParams, useNavigate } from "react-router-dom"; -import { - NOTIFICATION_TYPES, - TITLE_MAP, - DESCRIPTION_MAP, - LABEL_MAP, - PLACEHOLDER_MAP, -} from "../utils.js"; - -// Setup - -const CreateNotifications = () => { - const { notificationId } = useParams(); - const navigate = useNavigate(); - const theme = useTheme(); - const [createNotification, isCreating] = useCreateNotification(); - const [editNotification, isEditing] = useEditNotification(); - const [testNotification, isTesting] = useTestNotification(); - const [deleteNotification, isDeleting] = useDeleteNotification(); - - const BREADCRUMBS = [ - { name: "notifications", path: "/notifications" }, - { - name: notificationId ? "edit" : "create", - path: notificationId ? `/notifications/${notificationId}` : "/notifications/create", - }, - ]; - - // Redux state - - // local state - const [notification, setNotification] = useState({ - notificationName: "", - address: "", - type: NOTIFICATION_TYPES[0].id, - }); - const [errors, setErrors] = useState({}); - const { t } = useTranslation(); - - const [notificationIsLoading] = useGetNotificationById(notificationId, setNotification); - - const [isOpen, setIsOpen] = useState(false); - - const getNotificationTypeValue = (typeId) => { - return NOTIFICATION_TYPES.find((type) => type.id === typeId)?.value || "email"; - }; - - const extractError = (error, field) => - error?.details.find((d) => d.path.includes(field))?.message; - - // handlers - const onSubmit = (e) => { - e.preventDefault(); - const form = { - ...notification, - type: getNotificationTypeValue(notification.type), - }; - - let error = null; - - error = notificationValidation.validate(form, { abortEarly: false }).error; - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - createToast({ body: Object.values(newErrors)[0] }); - setErrors(newErrors); - return; - } - - if (notificationId) { - editNotification(notificationId, form); - } else { - createNotification(form); - } - }; - - const onChange = (e) => { - const { name, value } = e.target; - let rawNotification = { ...notification, [name]: value }; - let newNotification = { - ...rawNotification, - type: getNotificationTypeValue(rawNotification.type), - }; - - const { error } = notificationValidation.validate(newNotification, { - abortEarly: false, - }); - let validationError = { ...errors }; - - if (name === "type") { - validationError["type"] = extractError(error, "type"); - validationError["address"] = extractError(error, "address"); - } else { - validationError[name] = extractError(error, name); - } - - setNotification(rawNotification); - setErrors(validationError); - }; - - const onTestNotification = () => { - const form = { - ...notification, - type: getNotificationTypeValue(notification.type), - }; - - let error = null; - - error = notificationValidation.validate(form, { abortEarly: false }).error; - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - createToast({ body: Object.values(newErrors)[0] }); - setErrors(newErrors); - return; - } - - testNotification(form); - }; - - const onDelete = () => { - if (notificationId) { - deleteNotification(notificationId, () => navigate("/notifications")); - } - }; - - const type = getNotificationTypeValue(notification.type); - return ( - - - {t("createNotifications.title")} - - - - - {t("createNotifications.nameSettings.title")} - - - {t("createNotifications.nameSettings.description")} - - - - - - - - - - {t("createNotifications.typeSettings.title")} - - - {t("createNotifications.typeSettings.description")} - - - - + Select a game + + )} + /> + )} + + {/* Display name field - common to all types */} + ( + + )} + /> + + } + /> + + {/* Submit Button */} + + + + + ); +}; + +export default CreateMonitorPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 498be4ba7..c2613a16c 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -60,6 +60,8 @@ import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; import BulkImport from "../Pages/Uptime/BulkImport/index.jsx"; import Logs from "../Pages/Logs/index.jsx"; +import CreateMonitor from "@/Pages/CreateMonitor"; + const Routes = () => { const mode = useSelector((state) => state.ui.mode); const AdminCheckedRegister = withAdminCheck(AuthRegister); @@ -78,6 +80,16 @@ const Routes = () => { path="/" element={} /> + + + + + + } + /> ; + +// Type-specific schemas exported for individual use +export { + httpSchema, + pingSchema, + portSchema, + dockerSchema, + gameSchema, + pagespeedSchema, + hardwareSchema, +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 80da1835c..d9f2e7ac6 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -220,6 +220,37 @@ } } }, + "createMonitor": { + "form": { + "type": { + "description": "Select the type of check to perform", + "optionType": "Type", + "title": "Type", + "optionHttp": "HTTP(S)", + "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", + "optionPing": "Ping", + "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", + "optionDocker": "Docker", + "optionDockerDescription": "Use Docker to monitor if a container is running.", + "optionPort": "Port", + "optionPortDescription": "Monitor if a specific port on a server is open.", + "optionGame": "Game", + "optionGameDescription": "Monitor if a specific game server is online." + }, + "general": { + "title": "General settings", + "description": { + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", + "pagespeed": "Here you can select the URL of the host, together with the type of monitor.", + "infrastructure": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent." + } + } + } + }, "uptime": { "table": { "headers": { From 84e4ea7b649802dbbef7738ba472c79d4aab82a2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 22:20:50 +0000 Subject: [PATCH 062/392] forward ref --- client/src/Components/v2/inputs/Select.tsx | 32 ++++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/client/src/Components/v2/inputs/Select.tsx b/client/src/Components/v2/inputs/Select.tsx index 057b0e72c..8be20897d 100644 --- a/client/src/Components/v2/inputs/Select.tsx +++ b/client/src/Components/v2/inputs/Select.tsx @@ -1,7 +1,7 @@ import Select from "@mui/material/Select"; -import React from "react"; -import { useTheme } from "@mui/material/styles"; import type { SelectProps } from "@mui/material/Select"; +import React, { forwardRef } from "react"; +import { useTheme } from "@mui/material/styles"; import Stack from "@mui/material/Stack"; import { FieldLabel } from "./FieldLabel"; import { ChevronDown } from "lucide-react"; @@ -14,18 +14,21 @@ interface SelectInputProps extends Omit, "label"> { placeholderColor?: string; } -export const SelectInput = ({ - fieldLabel, - required, - placeholder, - placeholderColor, - ...props -}: SelectInputProps) => { +const SelectInputInner = ( + { + fieldLabel, + required, + placeholder, + placeholderColor, + ...props + }: SelectInputProps, + ref: React.ForwardedRef +) => { const theme = useTheme(); const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled; - const renderValue = (selected: any) => { - const isMultiple = Boolean((props as any).multiple); + const renderValue = (selected: unknown) => { + const isMultiple = Boolean((props as { multiple?: boolean }).multiple); const isEmpty = isMultiple ? !Array.isArray(selected) || selected.length === 0 : selected === undefined || selected === null || selected === ""; @@ -39,7 +42,7 @@ export const SelectInput = ({ const capitalized = items.map( (item) => item.charAt(0).toUpperCase() + item.slice(1) ); - return ({capitalized.join(" | ")}) as any; + return {capitalized.join(" | ")}; } const nodes = React.Children.toArray(props.children as React.ReactNode); @@ -59,6 +62,7 @@ export const SelectInput = ({ const select = ( {...props} + ref={ref} displayEmpty renderValue={renderValue} inputProps={{ @@ -100,3 +104,7 @@ export const SelectInput = ({ return select; }; + +export const SelectInput = forwardRef(SelectInputInner) as ( + props: SelectInputProps & { ref?: React.ForwardedRef } +) => React.ReactElement; From 841d25ce3e0db0ce9ad15b8564061a7e75d5d63f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 29 Jan 2026 22:47:29 +0000 Subject: [PATCH 063/392] general --- client/src/Pages/CreateMonitor/index.tsx | 134 +++++++++++++++++------ client/src/Validation/monitor.ts | 4 +- client/src/locales/en.json | 32 +++++- 3 files changed, 132 insertions(+), 38 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 80f818407..e0b5e2930 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -8,6 +8,7 @@ import Stack from "@mui/material/Stack"; import RadioGroup from "@mui/material/RadioGroup"; import FormControl from "@mui/material/FormControl"; import { useTranslation } from "react-i18next"; +import MenuItem from "@mui/material/MenuItem"; import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; import { RadioWithDescription, Button, TextField, Select } from "@/Components/v2/inputs"; @@ -15,14 +16,15 @@ import { useGet } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; import type { Monitor, MonitorType } from "@/Types/Monitor"; import type { MonitorFormData } from "@/Validation/monitor"; -import MenuItem from "@mui/material/MenuItem"; interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; namePlaceholder: string; + showUrl: boolean; showPort: boolean; showGameSelect: boolean; + showSecret: boolean; } const getGeneralSettingsConfig = ( @@ -31,39 +33,67 @@ const getGeneralSettingsConfig = ( ): GeneralSettingsConfig => { const configs: Record = { http: { - urlLabel: t("monitorType.http.label"), - urlPlaceholder: t("monitorType.http.placeholder"), - namePlaceholder: t("monitorType.http.namePlaceholder"), + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, showPort: false, showGameSelect: false, + showSecret: false, }, ping: { - urlLabel: t("monitorType.ping.label"), - urlPlaceholder: t("monitorType.ping.placeholder"), - namePlaceholder: t("monitorType.ping.namePlaceholder"), + urlLabel: t("pages.createMonitor.form.general.option.host.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, showPort: false, showGameSelect: false, + showSecret: false, }, docker: { - urlLabel: t("monitorType.docker.label"), - urlPlaceholder: t("monitorType.docker.placeholder"), - namePlaceholder: t("monitorType.docker.namePlaceholder"), + urlLabel: t("pages.createMonitor.form.general.option.container.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, showPort: false, showGameSelect: false, + showSecret: false, }, port: { - urlLabel: t("monitorType.port.label"), - urlPlaceholder: t("monitorType.port.placeholder"), - namePlaceholder: t("monitorType.port.namePlaceholder"), + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, showPort: true, showGameSelect: false, + showSecret: false, }, game: { - urlLabel: t("monitorType.game.label"), - urlPlaceholder: t("monitorType.game.placeholder"), - namePlaceholder: t("monitorType.game.namePlaceholder"), + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, showPort: true, showGameSelect: true, + showSecret: false, + }, + pagespeed: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: false, + }, + hardware: { + urlLabel: t("pages.createMonitor.form.general.option.url.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: false, + showGameSelect: false, + showSecret: true, }, }; return configs[type] || configs.http; @@ -156,6 +186,20 @@ const CreateMonitorPage = () => { label={t("pages.createMonitor.form.type.optionGame")} description={t("pages.createMonitor.form.type.optionGameDescription")} /> + + )} @@ -169,22 +213,24 @@ const CreateMonitorPage = () => { subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)} rightContent={ - {/* URL/Host/Container field */} - ( - - )} - /> + {/* URL/Host/Container field - not shown for hardware */} + {generalSettingsConfig.showUrl && ( + ( + + )} + /> + )} {/* Port field - only for port and game types */} {generalSettingsConfig.showPort && ( @@ -225,6 +271,28 @@ const CreateMonitorPage = () => { /> )} + {/* Secret field - only for hardware type */} + {generalSettingsConfig.showSecret && ( + ( + + )} + /> + )} + {/* Display name field - common to all types */} { Date: Thu, 29 Jan 2026 23:04:40 +0000 Subject: [PATCH 064/392] notifications --- client/src/Pages/CreateMonitor/index.tsx | 85 +++++++++++++++++++++++- client/src/locales/en.json | 7 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index e0b5e2930..d5e10d97b 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -9,12 +9,23 @@ import RadioGroup from "@mui/material/RadioGroup"; import FormControl from "@mui/material/FormControl"; import { useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import { Trash2 } from "lucide-react"; import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; -import { RadioWithDescription, Button, TextField, Select } from "@/Components/v2/inputs"; +import { + RadioWithDescription, + Button, + TextField, + Select, + Autocomplete, +} from "@/Components/v2/inputs"; import { useGet } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; import type { Monitor, MonitorType } from "@/Types/Monitor"; +import type { Notification } from "@/Types/Notification"; import type { MonitorFormData } from "@/Validation/monitor"; interface GeneralSettingsConfig { @@ -109,6 +120,9 @@ const CreateMonitorPage = () => { isEditMode ? `/monitors/${monitorId}` : null ); + // Fetch notifications for the team + const { data: notifications } = useGet("/notifications/team"); + const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null }); const form = useForm({ @@ -313,6 +327,75 @@ const CreateMonitorPage = () => { } /> + {/* Notifications ConfigBox */} + { + // Map notifications to have 'name' property for v2 Autocomplete + const notificationOptions = (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + const selectedNotifications = notificationOptions.filter((n) => + (field.value ?? []).includes(n.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof notificationOptions) => { + field.onChange(newValue.map((n) => n.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + /> + {selectedNotifications.length > 0 && ( + + {selectedNotifications.map((notification, index) => ( + + + {notification.notificationName} + + { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== notification.id + ) + ); + }} + aria-label="Remove notification" + > + + + {index < selectedNotifications.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + {/* Submit Button */} Date: Thu, 29 Jan 2026 23:10:34 +0000 Subject: [PATCH 065/392] TLS/SSL --- client/src/Components/v2/inputs/Switch.tsx | 37 ++++++++++++++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Pages/CreateMonitor/index.tsx | 30 ++++++++++++++++++ client/src/locales/en.json | 9 ++++++ 4 files changed, 77 insertions(+) create mode 100644 client/src/Components/v2/inputs/Switch.tsx diff --git a/client/src/Components/v2/inputs/Switch.tsx b/client/src/Components/v2/inputs/Switch.tsx new file mode 100644 index 000000000..b8ae8c1ec --- /dev/null +++ b/client/src/Components/v2/inputs/Switch.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; +import Switch from "@mui/material/Switch"; +import type { SwitchProps } from "@mui/material/Switch"; +import { useTheme } from "@mui/material/styles"; + +export const SwitchComponent = forwardRef( + function SwitchComponent({ sx, ...props }, ref) { + const theme = useTheme(); + const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; + + return ( + + ); + } +); diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index a0646d394..4bfd62947 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -10,3 +10,4 @@ export { } from "./ToggleButton"; export { DialogInput as Dialog } from "./Dialog"; export * from "./Radio"; +export * from "./Switch"; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index d5e10d97b..93539e10c 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -21,6 +21,7 @@ import { TextField, Select, Autocomplete, + SwitchComponent as Switch, } from "@/Components/v2/inputs"; import { useGet } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; @@ -396,6 +397,35 @@ const CreateMonitorPage = () => { } /> + {/* TLS/SSL ConfigBox - only for HTTP monitors */} + {watchedType === "http" && ( + ( + + field.onChange(e.target.checked)} + /> + + {t("pages.createMonitor.form.ignoreTls.option.tls.label")} + + + )} + /> + } + /> + )} + {/* Submit Button */} Date: Thu, 29 Jan 2026 23:17:08 +0000 Subject: [PATCH 066/392] check frequency --- client/src/Pages/CreateMonitor/index.tsx | 31 ++++++++++++++++++++++++ client/src/locales/en.json | 9 +++++++ 2 files changed, 40 insertions(+) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 93539e10c..591f423fa 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -328,6 +328,37 @@ const CreateMonitorPage = () => { } /> + {/* Frequency ConfigBox */} + ( + + )} + /> + } + /> + {/* Notifications ConfigBox */} Date: Thu, 29 Jan 2026 23:39:14 +0000 Subject: [PATCH 067/392] add incidents config box --- client/src/Pages/CreateMonitor/index.tsx | 48 ++++++++++++++++++++++++ client/src/Validation/monitor.ts | 6 +-- client/src/locales/en.json | 12 ++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 591f423fa..c0740393b 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -359,6 +359,54 @@ const CreateMonitorPage = () => { } /> + {/* Incidents ConfigBox */} + + ( + { + const val = e.target.value; + field.onChange(val === "" ? undefined : Number(val)); + }} + onBlur={field.onBlur} + type="number" + fieldLabel={t("pages.createMonitor.form.incidents.option.checks.label")} + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ( + { + const val = e.target.value; + field.onChange(val === "" ? undefined : Number(val)); + }} + onBlur={field.onBlur} + type="number" + fieldLabel={t("pages.createMonitor.form.incidents.option.percentage.label")} + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + + } + /> + {/* Notifications ConfigBox */} Date: Thu, 29 Jan 2026 23:52:21 +0000 Subject: [PATCH 068/392] sliders --- client/src/Components/v2/inputs/Slider.tsx | 41 +++++++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Pages/CreateMonitor/index.tsx | 61 +++++++++++----------- 3 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 client/src/Components/v2/inputs/Slider.tsx diff --git a/client/src/Components/v2/inputs/Slider.tsx b/client/src/Components/v2/inputs/Slider.tsx new file mode 100644 index 000000000..11b3e8db8 --- /dev/null +++ b/client/src/Components/v2/inputs/Slider.tsx @@ -0,0 +1,41 @@ +import { forwardRef } from "react"; +import Slider from "@mui/material/Slider"; +import type { SliderProps } from "@mui/material/Slider"; +import { useTheme } from "@mui/material/styles"; + +export const SliderInput = forwardRef( + function SliderInput({ sx, ...props }, ref) { + const theme = useTheme(); + const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; + + return ( + + ); + } +); diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 4bfd62947..1618daf76 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -11,3 +11,4 @@ export { export { DialogInput as Dialog } from "./Dialog"; export * from "./Radio"; export * from "./Switch"; +export * from "./Slider"; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index c0740393b..215cdab5f 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -22,6 +22,7 @@ import { Select, Autocomplete, SwitchComponent as Switch, + SliderInput as Slider, } from "@/Components/v2/inputs"; import { useGet } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; @@ -340,7 +341,9 @@ const CreateMonitorPage = () => { + {t("matchMethodOptions.equal")} + {t("matchMethodOptions.include")} + {t("matchMethodOptions.regex")} + + )} + /> + ( + + )} + /> + ( + + )} + /> + + )} + + } + /> + )} + {/* Submit Button */} Date: Fri, 30 Jan 2026 20:21:31 +0000 Subject: [PATCH 074/392] useAdvncedMatching --- client/src/Hooks/useMonitorForm.ts | 2 + client/src/Pages/CreateMonitor/index.tsx | 63 ++++++++++++++----- client/src/Types/Monitor.ts | 1 + client/src/Validation/monitor.ts | 1 + client/src/locales/en.json | 4 +- server/src/db/models/Monitor.ts | 4 ++ .../monitors/MongoMonitorsRepository.ts | 2 + .../service/infrastructure/networkService.ts | 4 +- server/src/types/monitor.ts | 1 + server/src/validation/joi.ts | 2 + 10 files changed, 66 insertions(+), 18 deletions(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 50dc7b920..153cf9f19 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -33,6 +33,7 @@ export const useMonitorForm = ({ type: "http", url: data?.url || "", ignoreTlsErrors: data?.ignoreTlsErrors || false, + useAdvancedMatching: data?.useAdvancedMatching || false, matchMethod: data?.matchMethod || "", expectedValue: data?.expectedValue || "", jsonPath: data?.jsonPath || "", @@ -95,6 +96,7 @@ export const useMonitorForm = ({ type: "http", url: "", ignoreTlsErrors: false, + useAdvancedMatching: false, matchMethod: "", expectedValue: "", jsonPath: "", diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 313b744cd..d7d9a645e 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useParams, useLocation, useNavigate } from "react-router"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -7,9 +7,10 @@ import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; import RadioGroup from "@mui/material/RadioGroup"; import FormControl from "@mui/material/FormControl"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; +import Link from "@mui/material/Link"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import FormControlLabel from "@mui/material/FormControlLabel"; @@ -163,7 +164,7 @@ const CreateMonitorPage = () => { const watchedType = watch("type") as MonitorType; - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; useEffect(() => { clearErrors(); @@ -550,16 +551,22 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.advanced.description")} rightContent={ - setShowAdvancedSettings(e.target.checked)} + ( + field.onChange(e.target.checked)} + /> + } + label={t("advancedMatching")} /> - } - label={t("advancedMatching")} + )} /> - {showAdvancedSettings && ( + {watchedUseAdvancedMatching && ( { )} @@ -583,7 +594,9 @@ const CreateMonitorPage = () => { { )} /> + + + ), + }} + /> + )} diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 94b3d83d7..ddf44e56f 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -35,6 +35,7 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; matchMethod?: MonitorMatchMethod; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 39fccafbd..68a15fbec 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -27,6 +27,7 @@ const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, ignoreTlsErrors: z.boolean(), + useAdvancedMatching: z.boolean(), matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), expectedValue: z.string().optional(), jsonPath: z.string().optional(), diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 80cb2e206..123a1f146 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -2,6 +2,8 @@ "common": { "appName": "Checkmate", "monitoringAgentName": "Capture", + "see": "See", + "forDocumentation": "for query language documentation.", "breadcrumbs": { "home": "Home", "details": "Details" @@ -322,7 +324,7 @@ }, "jsonPath": { "label": "JSONPath expression", - "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value." + "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value. See jmespath.org for query language documentation." } } } diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 24c8ef0bf..d4cc9a872 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -92,6 +92,10 @@ const MonitorSchema = new Schema( type: Boolean, default: false, }, + useAdvancedMatching: { + type: Boolean, + default: false, + }, jsonPath: { type: String, }, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 9cb519303..9b30e5ac2 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -290,6 +290,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindowThreshold: doc.statusWindowThreshold, type: doc.type, ignoreTlsErrors: doc.ignoreTlsErrors, + useAdvancedMatching: doc.useAdvancedMatching ?? false, jsonPath: doc.jsonPath ?? undefined, expectedValue: doc.expectedValue ?? undefined, matchMethod: doc.matchMethod ?? undefined, @@ -374,6 +375,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindowThreshold: doc.statusWindowThreshold, type: doc.type, ignoreTlsErrors: doc.ignoreTlsErrors, + useAdvancedMatching: doc.useAdvancedMatching ?? false, jsonPath: doc.jsonPath ?? undefined, expectedValue: doc.expectedValue ?? undefined, matchMethod: doc.matchMethod ?? undefined, diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 7db8fc6e2..92d269808 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -255,7 +255,7 @@ class NetworkService implements INetworkService { } private async requestHttp(monitor: Monitor): Promise { - const { url, secret, id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor; + const { url, secret, id, teamId, type, ignoreTlsErrors, useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor; const httpResponse = this.buildStatusResponse({ monitor, overrides: { @@ -305,7 +305,7 @@ class NetworkService implements INetworkService { timings: response.timings, }); - if (!expectedValue && !jsonPath) { + if (!useAdvancedMatching) { return httpResponse; } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 62ced091d..fe6ac8c8a 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -26,6 +26,7 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; matchMethod?: MonitorMatchMethod; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 581d2afd2..926d3b0fe 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -158,6 +158,7 @@ const createMonitorBodyValidation = joi.object({ statusWindowThreshold: joi.number().min(1).max(100).default(60), url: joi.string().required(), ignoreTlsErrors: joi.boolean().default(false), + useAdvancedMatching: joi.boolean().default(false), port: joi.number(), isActive: joi.boolean(), interval: joi.number(), @@ -193,6 +194,7 @@ const editMonitorBodyValidation = joi.object({ notifications: joi.array().items(joi.string()), secret: joi.string(), ignoreTlsErrors: joi.boolean(), + useAdvancedMatching: joi.boolean(), jsonPath: joi.string().allow(""), expectedValue: joi.string().allow(""), matchMethod: joi.string().allow(null, ""), From 443ad46fa79bc202a9a2c05ee943a8d5fdae403c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 30 Jan 2026 20:38:05 +0000 Subject: [PATCH 075/392] update strings --- client/src/Pages/CreateMonitor/index.tsx | 22 +++++++++++----------- client/src/locales/en.json | 3 +++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index d7d9a645e..c38f4eb05 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -13,7 +13,6 @@ import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; -import FormControlLabel from "@mui/material/FormControlLabel"; import { Trash2 } from "lucide-react"; import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; @@ -25,7 +24,6 @@ import { Autocomplete, SwitchComponent as Switch, SliderWithLabel, - Checkbox, } from "@/Components/v2/inputs"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; @@ -555,15 +553,17 @@ const CreateMonitorPage = () => { name="useAdvancedMatching" control={control} render={({ field }) => ( - field.onChange(e.target.checked)} - /> - } - label={t("advancedMatching")} - /> + + field.onChange(e.target.checked)} + /> + {t("pages.createMonitor.form.advanced.option.advancedMatching.label")} + )} /> {watchedUseAdvancedMatching && ( diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 123a1f146..7cdfe215a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -315,6 +315,9 @@ "title": "Advanced settings", "description": "Optional settings for advanced use cases", "option": { + "advancedMatching": { + "label": "Use advanced matching" + }, "matchMethod": { "label": "Match method" }, From 91bb60f2ff9b923cf214cbb8bd29f6764714825b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 30 Jan 2026 20:55:24 +0000 Subject: [PATCH 076/392] delte header --- .../common/controls/HeaderMonitorControls.tsx | 93 +- client/src/Pages/CreateMonitor/index.tsx | 14 +- .../Create/Components/CustomAlertsSection.jsx | 87 -- .../Components/CustomThreshold/index.jsx | 127 --- .../Create/Components/DiskSelection.jsx | 90 -- .../Components/MonitorActionButtons.jsx | 83 -- .../Create/Components/MonitorStatusHeader.jsx | 73 -- .../hooks/useInfrastructureMonitorForm.jsx | 99 --- .../Create/hooks/useInfrastructureSubmit.jsx | 84 -- .../hooks/useValidateInfrastructureForm.jsx | 37 - .../src/Pages/Infrastructure/Create/index.jsx | 409 --------- client/src/Pages/PageSpeed/Create/index.css | 0 client/src/Pages/PageSpeed/Create/index.jsx | 514 ----------- .../src/Pages/PageSpeed/Create/skeleton.jsx | 86 -- client/src/Pages/Uptime/Create/index.css | 0 client/src/Pages/Uptime/Create/index.jsx | 826 ------------------ client/src/Pages/Uptime/Create/skeleton.jsx | 90 -- client/src/Routes/index.jsx | 7 - client/src/locales/en.json | 3 +- 19 files changed, 97 insertions(+), 2625 deletions(-) delete mode 100644 client/src/Pages/Infrastructure/Create/Components/CustomAlertsSection.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/Components/DiskSelection.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/Components/MonitorActionButtons.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/Components/MonitorStatusHeader.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/hooks/useValidateInfrastructureForm.jsx delete mode 100644 client/src/Pages/Infrastructure/Create/index.jsx delete mode 100644 client/src/Pages/PageSpeed/Create/index.css delete mode 100644 client/src/Pages/PageSpeed/Create/index.jsx delete mode 100644 client/src/Pages/PageSpeed/Create/skeleton.jsx delete mode 100644 client/src/Pages/Uptime/Create/index.css delete mode 100644 client/src/Pages/Uptime/Create/index.jsx delete mode 100644 client/src/Pages/Uptime/Create/skeleton.jsx diff --git a/client/src/Components/v2/common/controls/HeaderMonitorControls.tsx b/client/src/Components/v2/common/controls/HeaderMonitorControls.tsx index 17576a8b2..0db84940b 100644 --- a/client/src/Components/v2/common/controls/HeaderMonitorControls.tsx +++ b/client/src/Components/v2/common/controls/HeaderMonitorControls.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import { Icon, MonitorStatus } from "@/Components/v2/design-elements"; import { Button } from "@/Components/v2/inputs"; -import { Settings, Pause, Play, Mail, Bug } from "lucide-react"; +import { Settings, Pause, Play, Mail, Bug, Trash } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -9,10 +9,28 @@ import { useTheme } from "@mui/material/styles"; import { usePost } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor.js"; +interface BaseHeaderProps { + monitor: Monitor; +} + +const BaseHeader = ({ monitor, children }: React.PropsWithChildren) => { + const theme = useTheme(); + return ( + + + {children} + + ); +}; interface HeaderMonitorControlsProps { path: string; - monitor?: Monitor; + monitor?: Monitor | null; isAdmin: boolean; refetch: Function; } @@ -36,13 +54,7 @@ export const HeaderMonitorControls = ({ return null; } return ( - - + )} - + + ); +}; + +interface HeaderDeleteControlsProps { + monitor?: Monitor | null; + isAdmin: boolean; + refetch: Function; +} + +export const HeaderDeleteControls = ({ + monitor, + isAdmin, + refetch, +}: HeaderDeleteControlsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const theme = useTheme(); + const { + post, + loading: isPosting, + // error: postError, + } = usePost(); + + if (!monitor) { + return null; + } + return ( + + + {isAdmin && ( + + )} + {isAdmin && ( + + )} + + ); }; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index c38f4eb05..37741746b 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -14,6 +14,7 @@ import Link from "@mui/material/Link"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import { Trash2 } from "lucide-react"; +import { HeaderDeleteControls } from "@/Components/v2/common"; import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; import { @@ -138,7 +139,7 @@ const CreateMonitorPage = () => { ? "hardware" : "http"; - const { data: existingMonitor } = useGet( + const { data: existingMonitor, refetch: refetchMonitor } = useGet( isEditMode ? `/monitors/${monitorId}` : null ); @@ -206,6 +207,11 @@ const CreateMonitorPage = () => { component="form" onSubmit={handleSubmit(onSubmit, onError)} > + {/* Monitor Type Selection - only shown for uptime monitors */} {showTypeSelector && ( { checked={field.value ?? false} onChange={(e) => field.onChange(e.target.checked)} /> - {t("pages.createMonitor.form.advanced.option.advancedMatching.label")} + + {t( + "pages.createMonitor.form.advanced.option.advancedMatching.label" + )} + )} /> diff --git a/client/src/Pages/Infrastructure/Create/Components/CustomAlertsSection.jsx b/client/src/Pages/Infrastructure/Create/Components/CustomAlertsSection.jsx deleted file mode 100644 index 64f82cec2..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/CustomAlertsSection.jsx +++ /dev/null @@ -1,87 +0,0 @@ -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import { Box, Stack, Typography } from "@mui/material"; -import { CustomThreshold } from "./CustomThreshold/index.jsx"; -import { capitalizeFirstLetter } from "../../../../Utils/stringUtils.js"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; -const CustomAlertsSection = ({ - errors, - onChange, - infrastructureMonitor, - handleCheckboxChange, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const METRICS = ["cpu", "memory", "disk", "temperature"]; - const METRIC_PREFIX = "usage_"; - const hasAlertError = (errors) => { - return Object.keys(errors).filter((k) => k.startsWith(METRIC_PREFIX)).length > 0; - }; - const getAlertError = (errors) => { - const errorKey = Object.keys(errors).find((key) => key.startsWith(METRIC_PREFIX)); - return errorKey ? errors[errorKey] : null; - }; - return ( - - - - {t("infrastructureCustomizeAlerts")} - - - {t("infrastructureAlertNotificationDescription")} - - - - {METRICS.map((metric) => { - return ( - - ); - })} - - {hasAlertError(errors) && ( - - {getAlertError(errors)} - - )} - - - ); -}; - -CustomAlertsSection.propTypes = { - errors: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - infrastructureMonitor: PropTypes.object.isRequired, - handleCheckboxChange: PropTypes.func.isRequired, -}; - -export default CustomAlertsSection; diff --git a/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx b/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx deleted file mode 100644 index d783f7985..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Box, Stack, Typography } from "@mui/material"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; - -/** - * CustomThreshold Component - * - * A reusable component that renders a checkbox with an associated numeric input field - * and an optional unit label. The input field can be enabled/disabled based on checkbox state. - * - * @component - * @param {Object} props - Component properties - * @param {string} [props.checkboxId] - Optional unique identifier for the checkbox - * @param {string} [props.checkboxName] - Optional name attribute for the checkbox - * @param {string} props.checkboxLabel - Label text for the checkbox - * @param {boolean} props.isChecked - Current checked state of the checkbox - * @param {Function} props.onCheckboxChange - Callback function when checkbox is toggled - * @param {string} props.fieldId - Unique identifier for the input field - * @param {string} [props.fieldName] - Optional name attribute for the input field - * @param {string} props.fieldValue - Current value of the input field - * @param {Function} props.onFieldChange - Callback function when input field value changes - * @param {Function} props.onFieldBlur - Callback function when input field loses focus - * @param {string} props.alertUnit - Unit label displayed next to the input field - * @param {Object} props.errors - Object containing validation errors for the field - * @param {Object} props.infrastructureMonitor - Infrastructure monitor configuration object - * - * @returns {React.ReactElement} Rendered CustomThreshold component - * - * @example - * - */ -export const CustomThreshold = ({ - checkboxId, - checkboxName, - checkboxLabel, - onCheckboxChange, - isChecked, - fieldId, - fieldName, - fieldValue, - onFieldChange, - onFieldBlur, - alertUnit, - errors, -}) => { - const theme = useTheme(); - return ( - - - - - - - - - {alertUnit} - - - - ); -}; - -CustomThreshold.propTypes = { - checkboxId: PropTypes.string, - checkboxName: PropTypes.string, - checkboxLabel: PropTypes.string.isRequired, - isChecked: PropTypes.bool.isRequired, - onCheckboxChange: PropTypes.func.isRequired, - fieldId: PropTypes.string.isRequired, - fieldName: PropTypes.string, - fieldValue: PropTypes.string.isRequired, - onFieldChange: PropTypes.func.isRequired, - onFieldBlur: PropTypes.func, - alertUnit: PropTypes.string.isRequired, - infrastructureMonitor: PropTypes.object.isRequired, - errors: PropTypes.object.isRequired, -}; diff --git a/client/src/Pages/Infrastructure/Create/Components/DiskSelection.jsx b/client/src/Pages/Infrastructure/Create/Components/DiskSelection.jsx deleted file mode 100644 index 50c4ed980..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/DiskSelection.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from "react"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import { Box, Stack, Typography } from "@mui/material"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; - -const DiskSelection = ({ availableDisks, selectedDisks, onChange }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const handleDiskChange = (event, mountpoint) => { - const isChecked = event.target.checked; - let newSelectedDisks = []; - - if (isChecked) { - newSelectedDisks = [...selectedDisks, mountpoint]; - } else { - newSelectedDisks = selectedDisks.filter((disk) => disk !== mountpoint); - } - - onChange(newSelectedDisks); - }; - - return ( - - - - {t("v1.infrastructure.disk_selection_title")} - - - {t("v1.infrastructure.disk_selection_description")} - - - - - {!availableDisks || availableDisks.length === 0 ? ( - - {t("v1.infrastructure.disk_selection_info")} - - ) : ( - availableDisks.map((disk) => { - const identifier = disk.mountpoint || disk.device; - return ( - - - handleDiskChange(e, identifier)} - /> - - - ); - }) - )} - - - ); -}; - -DiskSelection.propTypes = { - availableDisks: PropTypes.arrayOf( - PropTypes.shape({ - mountpoint: PropTypes.string.isRequired, - }) - ), - selectedDisks: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired, -}; - -export default DiskSelection; diff --git a/client/src/Pages/Infrastructure/Create/Components/MonitorActionButtons.jsx b/client/src/Pages/Infrastructure/Create/Components/MonitorActionButtons.jsx deleted file mode 100644 index 2bde5f321..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/MonitorActionButtons.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState } from "react"; -import { Box, Button } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import Icon from "@/Components/v1/Icon"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import PropTypes from "prop-types"; - -const MonitorActionButtons = ({ monitor, isBusy, handlePause, handleRemove }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - - return ( - - - - setIsOpen(false)} - confirmationButtonLabel={t("delete")} - onConfirm={handleRemove} - /> - - ); -}; - -MonitorActionButtons.propTypes = { - monitor: PropTypes.object.isRequired, - isBusy: PropTypes.bool.isRequired, - handlePause: PropTypes.func.isRequired, - handleRemove: PropTypes.func.isRequired, -}; - -export default MonitorActionButtons; diff --git a/client/src/Pages/Infrastructure/Create/Components/MonitorStatusHeader.jsx b/client/src/Pages/Infrastructure/Create/Components/MonitorStatusHeader.jsx deleted file mode 100644 index 8fa6f4ff1..000000000 --- a/client/src/Pages/Infrastructure/Create/Components/MonitorStatusHeader.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Box, Stack, Tooltip, Typography } from "@mui/material"; -import { useMonitorUtils } from "../../../../Hooks/useMonitorUtils.js"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PulseDot from "@/Components/v1/Animated/PulseDot.jsx"; -import PropTypes from "prop-types"; -const MonitorStatusHeader = ({ monitor, infrastructureMonitor }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const { statusColor, pagespeedStatusMsg, determineState } = useMonitorUtils(); - return ( - - - - - - - - {infrastructureMonitor.url?.replace(/^https?:\/\//, "") || "..."} - - - {t("editing")} - - - ); -}; - -MonitorStatusHeader.propTypes = { - monitor: PropTypes.object.isRequired, - infrastructureMonitor: PropTypes.object.isRequired, -}; - -export default MonitorStatusHeader; diff --git a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx deleted file mode 100644 index a38ac9fc6..000000000 --- a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureMonitorForm.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState, useCallback } from "react"; -const useInfrastructureMonitorForm = () => { - const [infrastructureMonitor, setInfrastructureMonitor] = useState({ - url: "", - name: "", - notifications: [], - notify_email: false, - interval: 0.25, - statusWindowSize: 5, - statusWindowThreshold: 60, - cpu: false, - usage_cpu: "", - memory: false, - usage_memory: "", - disk: false, - usage_disk: "", - temperature: false, - usage_temperature: "", - secret: "", - selectedDisks: [], - }); - - const onChangeForm = (name, value) => { - setInfrastructureMonitor({ - ...infrastructureMonitor, - [name]: value, - }); - }; - const handleCheckboxChange = (event) => { - setInfrastructureMonitor({ - ...infrastructureMonitor, - [event.target.name]: event.target.checked, - }); - }; - const initializeInfrastructureMonitorForCreate = useCallback((globalSettings) => { - const gt = globalSettings?.data?.settings?.globalThresholds || {}; - setInfrastructureMonitor((prev) => ({ - ...prev, - url: "", - name: "", - notifications: [], - interval: 0.25, - cpu: gt.cpu !== undefined, - usage_cpu: gt.cpu !== undefined ? gt.cpu.toString() : "", - memory: gt.memory !== undefined, - usage_memory: gt.memory !== undefined ? gt.memory.toString() : "", - disk: gt.disk !== undefined, - usage_disk: gt.disk !== undefined ? gt.disk.toString() : "", - temperature: gt.temperature !== undefined, - usage_temperature: gt.temperature !== undefined ? gt.temperature.toString() : "", - secret: "", - selectedDisks: [], - })); - }, []); - - const initializeInfrastructureMonitorForUpdate = useCallback((monitor) => { - const MS_PER_MINUTE = 60000; - const { thresholds = {} } = monitor; - setInfrastructureMonitor((prev) => ({ - ...prev, - url: monitor.url.replace(/^https?:\/\//, ""), - name: monitor.name || "", - notifications: monitor.notifications || [], - interval: monitor.interval / MS_PER_MINUTE, - statusWindowSize: monitor.statusWindowSize, - statusWindowThreshold: monitor.statusWindowThreshold, - cpu: thresholds.usage_cpu !== undefined, - usage_cpu: - thresholds.usage_cpu !== undefined ? (thresholds.usage_cpu * 100).toString() : "", - memory: thresholds.usage_memory !== undefined, - usage_memory: - thresholds.usage_memory !== undefined - ? (thresholds.usage_memory * 100).toString() - : "", - disk: thresholds.usage_disk !== undefined, - usage_disk: - thresholds.usage_disk !== undefined - ? (thresholds.usage_disk * 100).toString() - : "", - temperature: thresholds.usage_temperature !== undefined, - usage_temperature: - thresholds.usage_temperature !== undefined - ? (thresholds.usage_temperature * 100).toString() - : "", - secret: monitor.secret || "", - selectedDisks: monitor.selectedDisks || [], - })); - }, []); - return { - infrastructureMonitor, - setInfrastructureMonitor, - onChangeForm, - handleCheckboxChange, - initializeInfrastructureMonitorForCreate, - initializeInfrastructureMonitorForUpdate, - }; -}; - -export default useInfrastructureMonitorForm; diff --git a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx b/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx deleted file mode 100644 index 04279cac4..000000000 --- a/client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useCreateMonitor, useUpdateMonitor } from "../../../../Hooks/monitorHooks.js"; -const useInfrastructureSubmit = () => { - const [createMonitor, isCreating] = useCreateMonitor(); - const [updateMonitor, isUpdating] = useUpdateMonitor(); - const buildForm = (infrastructureMonitor, https) => { - const MS_PER_MINUTE = 60000; - - let form = { - url: `http${https ? "s" : ""}://` + infrastructureMonitor.url, - name: - infrastructureMonitor.name === "" - ? infrastructureMonitor.url - : infrastructureMonitor.name, - interval: infrastructureMonitor.interval * MS_PER_MINUTE, - statusWindowSize: infrastructureMonitor.statusWindowSize, - statusWindowThreshold: infrastructureMonitor.statusWindowThreshold, - cpu: infrastructureMonitor.cpu, - ...(infrastructureMonitor.cpu - ? { usage_cpu: infrastructureMonitor.usage_cpu } - : {}), - memory: infrastructureMonitor.memory, - ...(infrastructureMonitor.memory - ? { usage_memory: infrastructureMonitor.usage_memory } - : {}), - disk: infrastructureMonitor.disk, - ...(infrastructureMonitor.disk - ? { usage_disk: infrastructureMonitor.usage_disk } - : {}), - ...(infrastructureMonitor.temperature - ? { usage_temperature: infrastructureMonitor.usage_temperature } - : {}), - secret: infrastructureMonitor.secret, - selectedDisks: infrastructureMonitor.selectedDisks, - }; - return form; - }; - const submitInfrastructureForm = async ( - infrastructureMonitor, - form, - isCreate, - monitorId - ) => { - const { - cpu, - usage_cpu, - memory, - usage_memory, - disk, - usage_disk, - temperature, - usage_temperature, - selectedDisks, - ...rest - } = form; - - const thresholds = { - ...(cpu ? { usage_cpu: usage_cpu / 100 } : {}), - ...(memory ? { usage_memory: usage_memory / 100 } : {}), - ...(disk ? { usage_disk: usage_disk / 100 } : {}), - ...(temperature ? { usage_temperature: usage_temperature / 100 } : {}), - }; - - const finalForm = { - ...(isCreate ? {} : { id: monitorId }), - ...rest, - description: form.name, - type: "hardware", - notifications: infrastructureMonitor.notifications, - selectedDisks, - thresholds, - }; - // Handle create or update - isCreate - ? await createMonitor({ monitor: finalForm, redirect: "/infrastructure" }) - : await updateMonitor({ monitor: finalForm, redirect: "/infrastructure" }); - }; - return { - buildForm, - submitInfrastructureForm, - isCreating, - isUpdating, - }; -}; -export default useInfrastructureSubmit; diff --git a/client/src/Pages/Infrastructure/Create/hooks/useValidateInfrastructureForm.jsx b/client/src/Pages/Infrastructure/Create/hooks/useValidateInfrastructureForm.jsx deleted file mode 100644 index f7665e65f..000000000 --- a/client/src/Pages/Infrastructure/Create/hooks/useValidateInfrastructureForm.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState } from "react"; -import { infrastructureMonitorValidation } from "../../../../Validation/validation.js"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -const useValidateInfrastructureForm = () => { - const [errors, setErrors] = useState({}); - - const validateField = (name, value) => { - const { error } = infrastructureMonitorValidation.validate( - { [name]: value }, - { abortEarly: false } - ); - setErrors((prev) => ({ - ...prev, - ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), - })); - }; - - const validateForm = (form) => { - const { error } = infrastructureMonitorValidation.validate(form, { - abortEarly: false, - }); - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - console.log(newErrors); - setErrors(newErrors); - createToast({ body: "Please check the form for errors." }); - return error; - } - return null; - }; - return { errors, validateField, validateForm }; -}; -export default useValidateInfrastructureForm; diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx deleted file mode 100644 index 936d87ab0..000000000 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ /dev/null @@ -1,409 +0,0 @@ -//Components -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import FieldWrapper from "@/Components/v1/Inputs/FieldWrapper/index.jsx"; -import Link from "@/Components/v1/Link/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material"; -import { HttpAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import MonitorStatusHeader from "./Components/MonitorStatusHeader.jsx"; -import MonitorActionButtons from "./Components/MonitorActionButtons.jsx"; -import CustomAlertsSection from "./Components/CustomAlertsSection.jsx"; -import DiskSelection from "./Components/DiskSelection.jsx"; -// Utils -import NotificationsConfig from "@/Components/v1/NotificationConfig/index.jsx"; -import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications.js"; -import { networkService } from "../../../Utils/NetworkService.js"; -import { useParams } from "react-router-dom"; -import { useState, useEffect } from "react"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { - useDeleteMonitor, - useFetchGlobalSettings, - useFetchMonitorById, - usePauseMonitor, -} from "@/Hooks/monitorHooks.js"; -import useInfrastructureMonitorForm from "./hooks/useInfrastructureMonitorForm.jsx"; -import useValidateInfrastructureForm from "./hooks/useValidateInfrastructureForm.jsx"; -import useInfrastructureSubmit from "./hooks/useInfrastructureSubmit.jsx"; - -const CreateInfrastructureMonitor = () => { - const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined"; - - const theme = useTheme(); - const { t } = useTranslation(); - - // State - const [monitor, setMonitor] = useState(null); - const [https, setHttps] = useState(false); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [availableDisks, setAvailableDisks] = useState([]); - - // Fetch monitor details if editing - const [isLoading] = useFetchMonitorById({ - monitorId, - setMonitor, - updateTrigger: true, - }); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - const [globalSettings, globalSettingsLoading] = useFetchGlobalSettings(); - const [notifications, notificationsAreLoading] = useGetNotificationsByTeamId(); - const [pauseMonitor, isPausing] = usePauseMonitor(); - const { - infrastructureMonitor, - setInfrastructureMonitor, - onChangeForm, - handleCheckboxChange, - initializeInfrastructureMonitorForCreate, - initializeInfrastructureMonitorForUpdate, - } = useInfrastructureMonitorForm(); - const { errors, validateField, validateForm } = useValidateInfrastructureForm(); - const { buildForm, submitInfrastructureForm, isCreating, isUpdating } = - useInfrastructureSubmit(); - - const FREQUENCIES = [ - { _id: 0.25, name: t("time.fifteenSeconds") }, - { _id: 0.5, name: t("time.thirtySeconds") }, - { _id: 1, name: t("time.oneMinute") }, - { _id: 2, name: t("time.twoMinutes") }, - { _id: 5, name: t("time.fiveMinutes") }, - { _id: 10, name: t("time.tenMinutes") }, - ]; - const CRUMBS = [ - { name: "Infrastructure monitors", path: "/infrastructure" }, - ...(isCreate - ? [{ name: "Create", path: "/infrastructure/create" }] - : [ - { name: "Details", path: `/infrastructure/${monitorId}` }, - { name: "Configure", path: `/infrastructure/configure/${monitorId}` }, - ]), - ]; - // Populate form fields if editing - useEffect(() => { - if (isCreate) { - if (globalSettingsLoading) return; - setHttps(false); - initializeInfrastructureMonitorForCreate(globalSettings); - } else if (monitor) { - setHttps(monitor.url.startsWith("https")); - initializeInfrastructureMonitorForUpdate(monitor); - const fetchLastCheck = async () => { - try { - let disks = monitor?.recentChecks?.[0]?.disk || []; - - if (disks.length > 0) { - setAvailableDisks(disks); - return; - } - - const response = await networkService.getChecksByMonitor({ - monitorId, - dateRange: "all", - rowsPerPage: 1, - sortOrder: "desc", - }); - disks = response?.data?.data?.checks?.[0]?.disk || []; - setAvailableDisks(disks); - } catch (error) { - setAvailableDisks([]); - } - }; - - fetchLastCheck(); - } - }, [ - isCreate, - monitor, - globalSettings, - globalSettingsLoading, - initializeInfrastructureMonitorForCreate, - initializeInfrastructureMonitorForUpdate, - ]); - - // Handlers - const onSubmit = async (event) => { - event.preventDefault(); - const form = buildForm(infrastructureMonitor, https); - // When editing, exclude URL from validation since it's disabled and can't be changed - const formToValidate = isCreate ? form : { ...form, url: monitor.url }; - const error = validateForm(formToValidate); - if (error) { - return; - } - submitInfrastructureForm(infrastructureMonitor, form, isCreate, monitorId); - }; - - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - const onChange = (event) => { - const { value, name } = event.target; - onChangeForm(name, value); - validateField(name, value); - }; - - const handlePause = async () => { - await pauseMonitor({ monitorId, triggerUpdate }); - }; - - const handleRemove = async (event) => { - event.preventDefault(); - await deleteMonitor({ monitor, redirect: "/infrastructure" }); - }; - - const isBusy = - isLoading || - isUpdating || - isCreating || - isDeleting || - isPausing || - notificationsAreLoading; - - return ( - - - - - - - - {!isCreate ? infrastructureMonitor.name : t("createYour") + " "} - - {isCreate ? ( - - {t("monitor")} - - ) : ( - <> - )} - - {!isCreate && monitor && ( - - )} - - {!isCreate && monitor && ( - - )} - - - - - {t("settingsGeneralSettings")} - - - {t("infrastructureCreateGeneralSettingsDescription")} - - - {t("infrastructureServerRequirement")}{" "} - - - - - } - placeholder={"localhost:59232/api/v1/metrics"} - label={t("infrastructureServerUrlLabel")} - https={https} - value={infrastructureMonitor.url} - onChange={onChange} - error={errors["url"] ? true : false} - helperText={errors["url"]} - disabled={!isCreate} - /> - {isCreate && ( - - - - - - - )} - - - - - - - - {t("notificationConfig.title")} - - {t("notificationConfig.description")} - - - - - - - {t("createMonitorPage.incidentConfigTitle")} - - - {t("createMonitorPage.incidentConfigDescription")} - - - - - - - - - - {monitorId && ( - - onChangeForm("selectedDisks", newSelectedDisks) - } - /> - )} - - - - - {t("distributedUptimeCreateAdvancedSettings")} - - - - - - - - - - - {!isCreate && ( - setIsOpen(false)} - confirmationButtonLabel={t("delete")} - onConfirm={handleRemove} - /> - )} - - ); -}; - -export default PageSpeedSetup; diff --git a/client/src/Pages/PageSpeed/Create/skeleton.jsx b/client/src/Pages/PageSpeed/Create/skeleton.jsx deleted file mode 100644 index 347fcaa65..000000000 --- a/client/src/Pages/PageSpeed/Create/skeleton.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Box, Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -/** - * Renders a skeleton layout. - * - * @returns {JSX.Element} - */ -const SkeletonLayout = () => { - const theme = useTheme(); - - return ( - <> - - - - - - - - - - - - - - - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Uptime/Create/index.css b/client/src/Pages/Uptime/Create/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx deleted file mode 100644 index 01fc9eb7a..000000000 --- a/client/src/Pages/Uptime/Create/index.jsx +++ /dev/null @@ -1,826 +0,0 @@ -//Components -import { - Box, - Button, - ButtonGroup, - FormControlLabel, - Stack, - Switch, - Tooltip, - Typography, -} from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { HttpAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import Radio from "@/Components/v1/Inputs/Radio/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import NotificationsConfig from "@/Components/v1/NotificationConfig/index.jsx"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import PulseDot from "@/Components/v1/Animated/PulseDot.jsx"; -import SkeletonLayout from "./skeleton.jsx"; - -// Utils -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { monitorValidation } from "../../../Validation/validation.js"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import Icon from "@/Components/v1/Icon"; -import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js"; -import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications.js"; -import { useParams } from "react-router-dom"; -import { - useCreateMonitor, - useDeleteMonitor, - useUpdateMonitor, - usePauseMonitor, - useFetchMonitorById, - useFetchMonitorGames, -} from "../../../Hooks/monitorHooks.js"; - -/** - * Create page renders monitor creation or configuration views. - * @component - */ -const UptimeCreate = ({ isClone = false }) => { - const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined" || isClone; - - // States - const [monitor, setMonitor] = useState({ - type: "http", - statusWindowSize: 5, - statusWindowThreshold: 60, - matchMethod: "equal", - expectedValue: "", - jsonPath: "", - notifications: [], - interval: 60000, - ignoreTlsErrors: false, - ...(isCreate ? { url: "", name: "" } : { port: undefined }), - }); - const [errors, setErrors] = useState({}); - const [https, setHttps] = useState(true); - const [isOpen, setIsOpen] = useState(false); - const [useAdvancedMatching, setUseAdvancedMatching] = useState(false); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [games, setGames] = useState({}); - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - // Hooks - const [notifications, notificationsAreLoading, notificationsError] = - useGetNotificationsByTeamId(); - const { determineState, statusColor } = useMonitorUtils(); - // Fetch monitor details - const [isFetchingMonitor] = useFetchMonitorById({ - monitorId, - setMonitor, - updateTrigger, - }); - - // Fetch games - const [isFetchingGames] = useFetchMonitorGames({ - setGames, - updateTrigger, - }); - - // Combine the loading states - const isLoading = isFetchingMonitor || isFetchingGames; - - const [createMonitor, isCreating] = useCreateMonitor(); - const [pauseMonitor, isPausing] = usePauseMonitor({}); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - const [updateMonitor, isUpdating] = useUpdateMonitor(); - - // Setup - const theme = useTheme(); - const { t } = useTranslation(); - - // Constants - const MS_PER_MINUTE = 60000; - const FREQUENCIES = [ - { _id: 0.25, name: t("time.fifteenSeconds") }, - { _id: 0.5, name: t("time.thirtySeconds") }, - { _id: 1, name: t("time.oneMinute") }, - { _id: 2, name: t("time.twoMinutes") }, - { _id: 3, name: t("time.threeMinutes") }, - { _id: 4, name: t("time.fourMinutes") }, - { _id: 5, name: t("time.fiveMinutes") }, - { _id: 10, name: t("time.tenMinutes") }, - { _id: 15, name: t("time.fifteenMinutes") }, - { _id: 30, name: t("time.thirtyMinutes") }, - ]; - - const GAMELIST = Object.entries(games).map(([key, value]) => ({ - _id: key, - name: value.name, - })); - - const CRUMBS = [ - { name: "uptime", path: "/uptime" }, - ...(isCreate - ? [{ name: "create", path: `/uptime/create` }] - : [ - { name: "details", path: `/uptime/${monitorId}` }, - { name: "configure", path: `/uptime/configure/${monitorId}` }, - ]), - ]; - const matchMethodOptions = [ - { _id: "equal", name: t("matchMethodOptions.equal") }, - { _id: "include", name: t("matchMethodOptions.include") }, - { _id: "regex", name: t("matchMethodOptions.regex") }, - ]; - const expectedValuePlaceholders = { - regex: t("matchMethodOptions.regexPlaceholder"), - equal: t("matchMethodOptions.equalPlaceholder"), - include: t("matchMethodOptions.includePlaceholder"), - }; - const monitorTypeMaps = { - http: { - label: t("monitorType.http.label"), - placeholder: t("monitorType.http.placeholder"), - namePlaceholder: t("monitorType.http.namePlaceholder"), - }, - ping: { - label: t("monitorType.ping.label"), - placeholder: t("monitorType.ping.placeholder"), - namePlaceholder: t("monitorType.ping.namePlaceholder"), - }, - docker: { - label: t("monitorType.docker.label"), - placeholder: t("monitorType.docker.placeholder"), - namePlaceholder: t("monitorType.docker.namePlaceholder"), - }, - port: { - label: t("monitorType.port.label"), - placeholder: t("monitorType.port.placeholder"), - namePlaceholder: t("monitorType.port.namePlaceholder"), - }, - game: { - label: t("monitorType.game.label"), - placeholder: t("monitorType.game.placeholder"), - namePlaceholder: t("monitorType.game.namePlaceholder"), - }, - }; - - // Handlers - const onSubmit = async (event) => { - event.preventDefault(); - const { notifications, ...rest } = monitor; - - let form = {}; - if (isCreate) { - form = { - url: - monitor.type === "http" && !isClone - ? `http${https ? "s" : ""}://` + monitor.url - : monitor.url, - name: monitor.name || monitor.url.substring(0, 50), - statusWindowSize: monitor.statusWindowSize, - statusWindowThreshold: monitor.statusWindowThreshold, - type: monitor.type, - - port: - monitor.type === "port" || monitor.type === "game" ? monitor.port : undefined, - interval: monitor.interval, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, - jsonPath: monitor.jsonPath, - ignoreTlsErrors: monitor.ignoreTlsErrors, - gameId: monitor.gameId || undefined, - }; - } else { - form = { - id: monitor.id, - url: monitor.url, - name: monitor.name || monitor.url.substring(0, 50), - statusWindowSize: monitor.statusWindowSize, - statusWindowThreshold: monitor.statusWindowThreshold, - type: monitor.type, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, - jsonPath: monitor.jsonPath, - interval: monitor.interval, - teamId: monitor.teamId, - userId: monitor.userId, - port: - monitor.type === "port" || monitor.type === "game" ? monitor.port : undefined, - ignoreTlsErrors: monitor.ignoreTlsErrors, - gameId: monitor.gameId || undefined, - }; - } - if (!useAdvancedMatching) { - form.matchMethod = isCreate ? undefined : ""; - form.expectedValue = isCreate ? undefined : ""; - form.jsonPath = isCreate ? undefined : ""; - } - - const { error } = monitorValidation.validate(form, { - abortEarly: false, - }); - - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: t("checkFormError") }); - return; - } - - form = { - ...form, - description: monitor.name || monitor.url, - notifications: monitor.notifications, - }; - - if (isCreate) { - await createMonitor({ monitor: form, redirect: "/uptime" }); - } else { - await updateMonitor({ monitor: form, redirect: "/uptime" }); - } - }; - - const onChange = (event) => { - let { name, value, checked } = event.target; - - if (name === "ignoreTlsErrors") { - value = checked; - } - - if (name === "useAdvancedMatching") { - setUseAdvancedMatching(checked); - return; - } - - if (name === "interval") { - value = value * MS_PER_MINUTE; - } - - setMonitor((prev) => ({ ...prev, [name]: value })); - - if (name === "type") { - setErrors({}); - return; - } - - const { error } = monitorValidation.validate( - { type: monitor.type, [name]: value }, - { abortEarly: false } - ); - - setErrors((prev) => ({ - ...prev, - ...(error && error.details[0].path[0] === name - ? { [name]: error.details[0].message } - : { [name]: undefined }), - })); - }; - - const handleRemove = async (event) => { - event.preventDefault(); - const TEMP_MONITOR = { id: monitor.id }; - await deleteMonitor({ monitor: TEMP_MONITOR, redirect: "/uptime" }); - }; - - const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; - const displayInterval = monitor?.interval / MS_PER_MINUTE || 1; - const parsedUrl = monitor?.url; - const protocol = parsedUrl?.protocol?.replace(":", "") || ""; - - useEffect(() => { - if (!isCreate || isClone) { - if (monitor.matchMethod) { - setUseAdvancedMatching(true); - } else { - setUseAdvancedMatching(false); - } - } - }, [monitor, isCreate]); - - if (Object.keys(monitor).length === 0) { - return ; - } - - return ( - - - - - - - - - {!isCreate ? monitor.name : t("createYour") + " "} - - {isCreate && ( - - {t("monitor")} - - )} - - {!isCreate && ( - - - - - - - - {monitor.url?.replace(/^https?:\/\//, "") || "..."} - - - {t("editing")} - - - )} - - {!isCreate && ( - - - - - )} - - {isCreate && ( - - - - {t("distributedUptimeCreateChecks")} - - - {t("distributedUptimeCreateChecksDescription")} - - - - - - {monitor.type === "http" ? ( - - - - - ) : ( - "" - )} - - - - - - {errors["type"] ? ( - - - {errors["type"]} - - - ) : ( - "" - )} - - - )} - - - - {t("settingsGeneralSettings")} - - - {isCreate - ? t(`uptimeGeneralInstructions.${monitor.type}`) - : t("distributedUptimeCreateSelectURL")} - - - - - ) - } - helperText={errors["url"]} - onChange={onChange} - disabled={!isCreate} - /> -
onOpenDetails?.(row.id)} + emptyViewText={t("common.table.empty")} + /> + + + ); }; diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 667af29e0..abf605e8d 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -5,6 +5,7 @@ import { SummaryCardLatestIncidents, SummaryCardStats, } from "@/Pages/Incidents/Components/SummaryCard"; +import { IncidentsTable } from "@/Pages/Incidents/Components/IncidentTable"; import { useGet } from "@/Hooks/UseApi"; import { useState, useEffect, useMemo } from "react"; @@ -96,21 +97,23 @@ const IncidentsPage = () => { void setFilter; void dateRange; void setDateRange; - void page; - void setPage; - void rowsPerPage; - void setRowsPerPage; void isLoadingIncidents; void isLoadingMonitors; void isLoadingSummary; void refetchIncidents; void refetchSummary; void monitorLookup; - void incidents; - void incidentsCount; void summaryData; void networkError; + const handleOpenDetails = (incidentId: string) => { + console.log("Open details for incident:", incidentId); + }; + + const handleResolve = (incidentId: string) => { + console.log("Resolve incident:", incidentId); + }; + return ( { /> + ); }; From bcc57c713588fa14d7fbe4ea552c6f0decd9521e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 31 Jan 2026 01:12:05 +0000 Subject: [PATCH 096/392] strings --- client/src/Components/v2/inputs/Dialog.tsx | 17 ++++- .../Components/ControlsIncidentFilter.tsx | 0 .../Incidents/Components/DialogResolution.tsx | 70 +++++++++++++++++++ .../Incidents/Components/HeaderIncidents.tsx | 0 .../Incidents/Components/IncidentTable.tsx | 25 ++++--- client/src/Pages/Incidents/index.tsx | 51 ++++++++------ client/src/locales/en.json | 16 ++++- 7 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx create mode 100644 client/src/Pages/Incidents/Components/DialogResolution.tsx create mode 100644 client/src/Pages/Incidents/Components/HeaderIncidents.tsx diff --git a/client/src/Components/v2/inputs/Dialog.tsx b/client/src/Components/v2/inputs/Dialog.tsx index 77c59f1e2..6e0c5f9a6 100644 --- a/client/src/Components/v2/inputs/Dialog.tsx +++ b/client/src/Components/v2/inputs/Dialog.tsx @@ -1,4 +1,5 @@ import Dialog from "@mui/material/Dialog"; +import type { DialogProps } from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -6,6 +7,7 @@ import DialogTitle from "@mui/material/DialogTitle"; import { Button } from "@/Components/v2/inputs"; import { typographyLevels } from "@/Utils/Theme/v2Palette"; import { useTranslation } from "react-i18next"; +import type { ReactNode } from "react"; export const DialogInput = ({ open, @@ -18,6 +20,9 @@ export const DialogInput = ({ loading = false, cancelText, confirmText, + children, + maxWidth, + fullWidth = false, }: { open: boolean; title?: string; @@ -29,13 +34,21 @@ export const DialogInput = ({ loading?: boolean; cancelText?: string; confirmText?: string; + children?: ReactNode; + maxWidth?: DialogProps["maxWidth"]; + fullWidth?: boolean; }) => { const { t } = useTranslation(); return ( - + {title} - {content} + {content && {content}} + {children}
{ + const { t } = useTranslation(); const theme = useTheme(); const { monitorId } = useParams(); @@ -27,6 +29,8 @@ const IncidentsPage = () => { const [dateRange, setDateRange] = useState("recent"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); + const [activeIncidentsPage, setActiveIncidentsPage] = useState(0); + const [activeIncidentsRowsPerPage, setActiveIncidentsRowsPerPage] = useState(5); // Resolve dialog state const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false); @@ -73,6 +77,27 @@ const IncidentsPage = () => { error: monitorsError, } = useGet("/monitors/team"); + // Build active incidents URL (always fetch all active, no date filter) + const activeIncidentsUrl = useMemo(() => { + const params = new URLSearchParams(); + if (selectedMonitor !== "0") params.append("monitorId", selectedMonitor); + params.append("status", "true"); + params.append("sortOrder", "desc"); + params.append("page", String(activeIncidentsPage)); + params.append("rowsPerPage", String(activeIncidentsRowsPerPage)); + return `/incidents/team?${params.toString()}`; + }, [selectedMonitor, activeIncidentsPage, activeIncidentsRowsPerPage]); + + // Fetch active incidents + const { + data: activeIncidentsData, + refetch: refetchActiveIncidents, + } = useGet( + activeIncidentsUrl, + {}, + { keepPreviousData: true, refreshInterval: 10000 } + ); + // Fetch incident summary const { data: summaryData, @@ -89,6 +114,8 @@ const IncidentsPage = () => { // Derived state const incidents = incidentsData?.incidents ?? []; const incidentsCount = incidentsData?.count ?? 0; + const activeIncidents = activeIncidentsData?.incidents ?? []; + const activeIncidentsCount = activeIncidentsData?.count ?? 0; const networkError = !!incidentsError || !!monitorsError || !!summaryError; // Expose state and handlers for future UI use @@ -102,7 +129,7 @@ const IncidentsPage = () => { void networkError; const handleOpenDetails = (incidentId: string) => { - const incident = incidents.find((i) => i.id === incidentId) ?? null; + const incident = incidents.find((i) => i.id === incidentId) ?? activeIncidents.find((i) => i.id === incidentId) ?? null; const monitor = monitorsData?.find((m) => m.id === incident?.monitorId) ?? null; setIsDetailsDialogOpen(true); setSelectedIncident(incident); @@ -132,6 +159,7 @@ const IncidentsPage = () => { const handleResolved = () => { refetchIncidents(); + refetchActiveIncidents(); refetchSummary(); }; @@ -141,11 +169,8 @@ const IncidentsPage = () => { direction={{ xs: "column", md: "row" }} gap={theme.spacing(8)} > - - + + { setDateRange={setDateRange} isLoading={isLoadingIncidents} /> + {activeIncidentsCount > 0 && ( + + )} { + +type IncidentLike = Pick; + +export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryItem) => { if (!incident?.startTime) { return "-"; } From 83b628c8f95dcbdabb7e7482438da1f8261cb551 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 17:36:42 +0000 Subject: [PATCH 108/392] divider --- .../Incidents/Components/IncidentTable.tsx | 34 ++++----- client/src/Pages/Incidents/index.tsx | 72 ++++++++++++------- client/src/locales/en.json | 2 +- 3 files changed, 59 insertions(+), 49 deletions(-) diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index df82764d9..46352c6f7 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -18,7 +18,6 @@ import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; interface IncidentsTableProps { - title?: string; incidents?: Incident[]; monitors?: Monitor[]; incidentsCount: number; @@ -103,7 +102,11 @@ export const IncidentsTable = ({ return ( ); }, @@ -112,11 +115,7 @@ export const IncidentsTable = ({ id: "startTime", content: t("pages.incidents.table.headers.startTime"), render: (row) => { - return formatDateWithTz( - row.createdAt, - "YYYY-MM-DD HH:mm:ss A", - uiTimezone - ); + return formatDateWithTz(row.createdAt, "YYYY-MM-DD HH:mm:ss A", uiTimezone); }, }, { @@ -124,11 +123,7 @@ export const IncidentsTable = ({ content: t("pages.incidents.table.headers.endTime"), render: (row) => { if (row.endTime) { - return formatDateWithTz( - row.endTime, - "YYYY-MM-DD HH:mm:ss A", - uiTimezone - ); + return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone); } return "-"; }, @@ -165,7 +160,12 @@ export const IncidentsTable = ({ let value: ValueType = "neutral"; if (code < 300) value = "positive"; else if (code >= 400) value = "negative"; - return ; + return ( + + ); }, }, { @@ -202,14 +202,6 @@ export const IncidentsTable = ({ return ( - {title && ( - - {title} - - )}
{ const incidentsUrl = useMemo(() => { const params = new URLSearchParams(); if (selectedMonitor !== "0") params.append("monitorId", selectedMonitor); - if (filter === "active") params.append("status", "true"); - else if (filter === "resolved") params.append("status", "false"); - else if (filter === "manual" || filter === "automatic") + // Always show only resolved incidents (active ones are in the separate table above) + params.append("status", "false"); + if (filter === "manual" || filter === "automatic") params.append("resolutionType", filter); params.append("sortOrder", "desc"); if (dateRange !== "all") params.append("dateRange", dateRange); @@ -89,14 +91,12 @@ const IncidentsPage = () => { }, [selectedMonitor, activeIncidentsPage, activeIncidentsRowsPerPage]); // Fetch active incidents - const { - data: activeIncidentsData, - refetch: refetchActiveIncidents, - } = useGet( - activeIncidentsUrl, - {}, - { keepPreviousData: true, refreshInterval: 10000 } - ); + const { data: activeIncidentsData, refetch: refetchActiveIncidents } = + useGet( + activeIncidentsUrl, + {}, + { keepPreviousData: true, refreshInterval: 10000 } + ); // Fetch incident summary const { @@ -129,7 +129,10 @@ const IncidentsPage = () => { void networkError; const handleOpenDetails = (incidentId: string) => { - const incident = incidents.find((i) => i.id === incidentId) ?? activeIncidents.find((i) => i.id === incidentId) ?? null; + const incident = + incidents.find((i) => i.id === incidentId) ?? + activeIncidents.find((i) => i.id === incidentId) ?? + null; const monitor = monitorsData?.find((m) => m.id === incident?.monitorId) ?? null; setIsDetailsDialogOpen(true); setSelectedIncident(incident); @@ -173,27 +176,42 @@ const IncidentsPage = () => { + + {t("pages.incidents.table.activeIncidents")} + + + {activeIncidentsCount > 0 && ( + <> + + + + )} + + + {t("pages.incidents.table.resolvedIncidents")} + - {activeIncidentsCount > 0 && ( - - )} Date: Mon, 2 Feb 2026 17:49:36 +0000 Subject: [PATCH 109/392] fitlers --- .../Components/ControlsIncidentFilter.tsx | 81 +++++++++++++++++++ .../Incidents/Components/IncidentTable.tsx | 2 +- client/src/Pages/Incidents/index.tsx | 54 ++++++------- client/src/locales/en.json | 10 +++ 4 files changed, 117 insertions(+), 30 deletions(-) diff --git a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx index e69de29bb..09cc1237f 100644 --- a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx +++ b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx @@ -0,0 +1,81 @@ +import Stack from "@mui/material/Stack"; +import { Select, Button } from "@/Components/v2/inputs"; +import MenuItem from "@mui/material/MenuItem"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; + +const resolutionTypes = ["manual", "automatic"]; + +interface ControlsIncidentFilterProps { + monitors?: Monitor[]; + selectedMonitor: string; + setSelectedMonitor: React.Dispatch>; + selectedResolutionType: string; + setSelectedResolutionType: React.Dispatch>; + onClearFilters: () => void; +} + +export const ControlsIncidentFilter = ({ + monitors, + selectedMonitor, + setSelectedMonitor, + selectedResolutionType, + setSelectedResolutionType, + onClearFilters, +}: ControlsIncidentFilterProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const isFilterActive = selectedMonitor !== "0" || selectedResolutionType !== ""; + + return ( + + + + {isFilterActive && ( + + )} + + ); +}; diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index 46352c6f7..b500ed5ba 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -18,6 +18,7 @@ import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; interface IncidentsTableProps { + title?: string; incidents?: Incident[]; monitors?: Monitor[]; incidentsCount: number; @@ -30,7 +31,6 @@ interface IncidentsTableProps { } export const IncidentsTable = ({ - title, incidents, monitors, incidentsCount, diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 7bc0e8fa5..56595afdb 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -12,6 +12,7 @@ import { IncidentsTable } from "@/Pages/Incidents/Components/IncidentTable"; import { DialogResolution } from "@/Pages/Incidents/Components/DialogResolution"; import { DialogIncidentDetails } from "@/Pages/Incidents/Components/DialogIncidentDetails"; import { HeaderTimeRange } from "@/Components/v2/common"; +import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter"; import { useGet } from "@/Hooks/UseApi"; import { useState, useEffect, useMemo } from "react"; @@ -64,7 +65,6 @@ const IncidentsPage = () => { const { data: incidentsData, isLoading: isLoadingIncidents, - error: incidentsError, refetch: refetchIncidents, } = useGet( incidentsUrl, @@ -73,11 +73,7 @@ const IncidentsPage = () => { ); // Fetch monitors for lookup - const { - data: monitorsData, - isLoading: isLoadingMonitors, - error: monitorsError, - } = useGet("/monitors/team"); + const { data: monitorsData } = useGet("/monitors/team"); // Build active incidents URL (always fetch all active, no date filter) const activeIncidentsUrl = useMemo(() => { @@ -99,12 +95,9 @@ const IncidentsPage = () => { ); // Fetch incident summary - const { - data: summaryData, - isLoading: isLoadingSummary, - error: summaryError, - refetch: refetchSummary, - } = useGet("/incidents/team/summary"); + const { data: summaryData, refetch: refetchSummary } = useGet( + "/incidents/team/summary" + ); // Reset page when filters change useEffect(() => { @@ -116,17 +109,11 @@ const IncidentsPage = () => { const incidentsCount = incidentsData?.count ?? 0; const activeIncidents = activeIncidentsData?.incidents ?? []; const activeIncidentsCount = activeIncidentsData?.count ?? 0; - const networkError = !!incidentsError || !!monitorsError || !!summaryError; - // Expose state and handlers for future UI use - void selectedMonitor; - void setSelectedMonitor; - void filter; - void setFilter; - void isLoadingIncidents; - void isLoadingMonitors; - void isLoadingSummary; - void networkError; + const handleClearFilters = () => { + setSelectedMonitor("0"); + setFilter("all"); + }; const handleOpenDetails = (incidentId: string) => { const incident = @@ -176,15 +163,23 @@ const IncidentsPage = () => { - - {t("pages.incidents.table.activeIncidents")} - - + {activeIncidentsCount > 0 && ( <> + + {t("pages.incidents.table.activeIncidents")} + + { > {t("pages.incidents.table.resolvedIncidents")} + Date: Mon, 2 Feb 2026 17:56:11 +0000 Subject: [PATCH 110/392] fix isFilterACtive logic --- .../src/Pages/Incidents/Components/ControlsIncidentFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx index 09cc1237f..b2f452011 100644 --- a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx +++ b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx @@ -28,7 +28,7 @@ export const ControlsIncidentFilter = ({ const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const isFilterActive = selectedMonitor !== "0" || selectedResolutionType !== ""; + const isFilterActive = selectedMonitor !== "0" || (selectedResolutionType !== "" && selectedResolutionType !== "all"); return ( Date: Mon, 2 Feb 2026 18:31:21 +0000 Subject: [PATCH 111/392] no active incidents msg --- client/src/Pages/Incidents/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 56595afdb..1f332224b 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -171,7 +171,7 @@ const IncidentsPage = () => { setSelectedResolutionType={setFilter} onClearFilters={handleClearFilters} /> - {activeIncidentsCount > 0 && ( + {activeIncidentsCount > 0 ? ( <> { /> + ) : ( + + {t("pages.incidents.summaryCard.activeIncidents.active_zero")} + )} Date: Mon, 2 Feb 2026 18:45:29 +0000 Subject: [PATCH 112/392] remove unused --- .../Components/ActiveIncidentsPanel/index.jsx | 134 ------ .../Incidents/Components/CardSummary.tsx | 45 +- .../Incidents/Components/HeaderIncidents.tsx | 0 .../Components/IncidentDetailsModal/index.jsx | 413 ------------------ .../Incidents/Components/IncidentItem.tsx | 104 ----- .../IncidentTable/IncidentActionsMenu.jsx | 156 ------- .../Components/IncidentTable/index.jsx | 193 -------- .../IncidentsSummaryPanel/index.jsx | 100 ----- .../IncidentsSummaryPanel/skeleton.jsx | 20 - .../LatestIncidentsPanel/IncidentItem.jsx | 115 ----- .../Components/LatestIncidentsPanel/index.jsx | 114 ----- .../Components/OptionsHeader/index.jsx | 139 ------ .../ResolveIncidentDialog/index.jsx | 101 ----- .../Components/StatisticsPanel/index.jsx | 168 ------- .../Components/SummaryCard/index.jsx | 64 --- .../Incidents/hooks/useFetchIncidents.js | 215 --------- .../hooks/useGetIncidentsDuration.js | 25 -- client/src/Pages/Incidents/old.jsx | 178 -------- client/src/locales/en.json | 75 +--- 19 files changed, 44 insertions(+), 2315 deletions(-) delete mode 100644 client/src/Pages/Incidents/Components/ActiveIncidentsPanel/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/HeaderIncidents.tsx delete mode 100644 client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/IncidentItem.tsx delete mode 100644 client/src/Pages/Incidents/Components/IncidentTable/IncidentActionsMenu.jsx delete mode 100644 client/src/Pages/Incidents/Components/IncidentTable/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/IncidentsSummaryPanel/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/IncidentsSummaryPanel/skeleton.jsx delete mode 100644 client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx delete mode 100644 client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/OptionsHeader/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/ResolveIncidentDialog/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/StatisticsPanel/index.jsx delete mode 100644 client/src/Pages/Incidents/Components/SummaryCard/index.jsx delete mode 100644 client/src/Pages/Incidents/hooks/useFetchIncidents.js delete mode 100644 client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js delete mode 100644 client/src/Pages/Incidents/old.jsx diff --git a/client/src/Pages/Incidents/Components/ActiveIncidentsPanel/index.jsx b/client/src/Pages/Incidents/Components/ActiveIncidentsPanel/index.jsx deleted file mode 100644 index fda257695..000000000 --- a/client/src/Pages/Incidents/Components/ActiveIncidentsPanel/index.jsx +++ /dev/null @@ -1,134 +0,0 @@ -import PropTypes from "prop-types"; -import { Box, Stack, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; -import Icon from "@/Components/v1/Icon"; -import SummaryCard from "../SummaryCard/index.jsx"; - -/** - * ActiveIncidentsPanel Component - * - * Displays a quick overview of currently active incidents. - * Shows only essential information: monitor name, status badge, and duration. - * - * @param {Object} props - * @param {Array} props.incidents - Array of active incidents - * @param {number} props.totalCount - Total count of active incidents (for badge) - * @param {boolean} props.isLoading - Loading state - * @param {Object} props.error - Error object if any - * @param {Function} props.onIncidentClick - Callback when incident is clicked (optional) - */ -const ActiveIncidentsPanel = ({ totalCount = 0, isLoading = false, error = null }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - if (isLoading) { - return ; - } - - if (error) { - return ( - - - {t("incidentsPage.incidentsActivePanelError")} - - - ); - } - - if (!totalCount || totalCount === 0) { - return ( - - - - - - - - {t("incidentsPage.allSystemsAreOperational")} - - - - ); - } - - return ( - - - - - - - - {totalCount} - - - - ); -}; - -ActiveIncidentsPanel.propTypes = { - totalCount: PropTypes.number, - isLoading: PropTypes.bool, - error: PropTypes.object, - onIncidentClick: PropTypes.func, -}; - -export default ActiveIncidentsPanel; diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx index 12d1f87cb..60b13bde7 100644 --- a/client/src/Pages/Incidents/Components/CardSummary.tsx +++ b/client/src/Pages/Incidents/Components/CardSummary.tsx @@ -5,13 +5,45 @@ import Grid from "@mui/material/Grid"; import { BaseBox, ValueLabel } from "@/Components/v2/design-elements"; import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react"; import Box from "@mui/material/Box"; -import { SummaryItem } from "@/Pages/Incidents/Components/IncidentItem"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident"; import { getIncidentsDuration } from "@/Pages/Incidents/utils"; +interface SummaryItemProps { + icon: React.ReactNode; + label: string; + value: string | number; +} + +const SummaryItem = ({ icon, label, value }: SummaryItemProps) => { + const theme = useTheme(); + return ( + + + {icon} + {label} + + + {value} + + + ); +}; + interface SummaryCardProps { title: string; sx?: React.CSSProperties; @@ -92,6 +124,7 @@ export const SummaryCardActiveIncidents = ({ summary }: SummaryCardActiveInciden const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) => { const theme = useTheme(); + const { t } = useTranslation(); const duration = getIncidentsDuration(incident); return ( fontWeight={500} noWrap > - {incident.monitorName ?? "N/A"} + {incident.monitorName ?? t("common.labels.na")} @@ -130,7 +163,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) => > @@ -182,8 +215,8 @@ export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => { if (!summary) return null; const mostAffected = !summary.total || summary.total === 0 - ? "N/A" - : summary.topMonitor?.monitorName || t("N/A"); + ? t("common.labels.na") + : summary.topMonitor?.monitorName || t("common.labels.na"); return ( { } label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")} - value={summary.total > 0 ? `${summary.avgResolutionTimeHours || 0} hours` : "N/A"} + value={summary.total > 0 ? `${summary.avgResolutionTimeHours || 0} hours` : t("common.labels.na")} /> ); diff --git a/client/src/Pages/Incidents/Components/HeaderIncidents.tsx b/client/src/Pages/Incidents/Components/HeaderIncidents.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx b/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx deleted file mode 100644 index 34efd632e..000000000 --- a/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx +++ /dev/null @@ -1,413 +0,0 @@ -// Components -import { Stack, Typography, Grid, Box, Button, Divider } from "@mui/material"; -import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import SummaryCard from "../SummaryCard/index.jsx"; -import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx"; - -// Utils -import { useEffect, useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js"; -import { useSelector } from "react-redux"; -import PropTypes from "prop-types"; - -// Hooks -import useFetchIncidents from "../../hooks/useFetchIncidents.js"; -import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration.js"; - -const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const { fetchIncidentById, resolveIncident } = useFetchIncidents(); - const [incidentData, setIncidentData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false); - const uiTimezone = useSelector((state) => state.ui.timezone); - const isActive = incidentData?.incident?.status === true; - const duration = useGetIncidentsDuration(incidentData?.incident || null, isActive); - - const statusColor = isActive ? theme.palette.error.main : theme.palette.success.main; - const toCapitalLetter = (text) => - typeof text === "string" && text.length > 0 - ? text.charAt(0).toUpperCase() + text.slice(1) - : text; - - const KeyValueRow = ({ label, value, sx = {} }) => ( - - - - {label} - - - - - {value ?? "-"} - - - - ); - - useEffect(() => { - const loadIncident = async () => { - if (!incidentId || !open) { - setIncidentData(null); - setIsLoading(false); - return; - } - - setIsLoading(true); - try { - const incidentData = await fetchIncidentById(incidentId); - setIncidentData(incidentData); - } catch (error) { - console.error("Error fetching incident:", error); - setIncidentData(null); - } finally { - setIsLoading(false); - } - }; - - loadIncident(); - }, [incidentId, open, fetchIncidentById]); - - const openResolveDialog = () => { - setIsResolveDialogOpen(true); - }; - - const closeResolveDialog = () => { - setIsResolveDialogOpen(false); - }; - - const handleAfterResolve = async () => { - if (!incidentId) return; - const updatedIncident = await fetchIncidentById(incidentId); - if (updatedIncident) { - setIncidentData(updatedIncident); - } - }; - - const renderContent = () => { - if (isLoading) { - return ( - - ); - } - - if (!incidentData) { - return ( - - ); - } - - return ( - - - - - - - - {isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")} - - - - - - {t("incidentsPage.incidentItemMonitor")} - - - {incidentData?.monitor?.name || t("incidentsPage.unknownMonitor")} - - - - - {t("incidentsPage.URL")} - - - {incidentData?.monitor?.url || "-"} - - - - - - - - - - - - - - {t("incidentsPage.timeline")} - - - - - {!isActive && ( - - )} - - - - - - - - - - - {isActive - ? t("incidentsPage.errorDetails") - : t("incidentsPage.resolutionDetails")} - - - - - - - - - {!isActive && ( - <> - - {incidentData?.incident?.resolutionType === "manual" && ( - - )} - - )} - - - - - - - - ); - }; - - return ( - - - {renderContent()} - {!isActive && - incidentData?.incident?.resolutionType === "manual" && - incidentData?.user?.email && ( - - {t("incidentsPage.resolvedBy")}: {incidentData.user.email} - - )} - - {isActive && ( - - )} - - - - - - - ); -}; - -IncidentDetailsModal.propTypes = { - open: PropTypes.bool.isRequired, - incidentId: PropTypes.string, - onClose: PropTypes.func.isRequired, - onResolved: PropTypes.func, -}; - -export default IncidentDetailsModal; diff --git a/client/src/Pages/Incidents/Components/IncidentItem.tsx b/client/src/Pages/Incidents/Components/IncidentItem.tsx deleted file mode 100644 index 13bff2a77..000000000 --- a/client/src/Pages/Incidents/Components/IncidentItem.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Divider from "@mui/material/Divider"; -import Grid from "@mui/material/Grid"; -import Typography from "@mui/material/Typography"; -import { ValueLabel } from "@/Components/v2/design-elements"; -import { Globe } from "lucide-react"; - -import type { Incident } from "@/Types/Incident"; -import type { Monitor } from "@/Types/Monitor"; -import { useTheme } from "@mui/material"; -import { getIncidentsDuration } from "@/Pages/Incidents/utils"; - -export const IncidentItem = ({ - incident, - monitor, -}: { - incident: Incident; - monitor?: Monitor | null; -}) => { - const theme = useTheme(); - const duration = getIncidentsDuration(incident); - return ( - - - - - {monitor ? monitor.name : "N/A"} - - - - - - - - - {duration} - - - ); -}; - -interface SummaryItemProps { - icon: React.ReactNode; - label: string; - value: string | number; -} - -export const SummaryItem = ({ icon, label, value }: SummaryItemProps) => { - const theme = useTheme(); - return ( - - - {icon} - - {label}: {value} - - - - - ); -}; diff --git a/client/src/Pages/Incidents/Components/IncidentTable/IncidentActionsMenu.jsx b/client/src/Pages/Incidents/Components/IncidentTable/IncidentActionsMenu.jsx deleted file mode 100644 index d125a9fdb..000000000 --- a/client/src/Pages/Incidents/Components/IncidentTable/IncidentActionsMenu.jsx +++ /dev/null @@ -1,156 +0,0 @@ -// Components -import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Icon from "@/Components/v1/Icon/index.jsx"; -import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js"; -import { useNavigate } from "react-router-dom"; -import PropTypes from "prop-types"; - -const IncidentActionsMenu = ({ incident, monitor, onResolve, onOpenDetails }) => { - const [anchorEl, setAnchorEl] = useState(null); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const theme = useTheme(); - const { t } = useTranslation(); - const navigate = useNavigate(); - const openMenu = (event) => { - event.preventDefault(); - event.stopPropagation(); - setAnchorEl(event.currentTarget); - }; - - const closeMenu = (e) => { - if (e) { - e.stopPropagation(); - } - setAnchorEl(null); - }; - - const openResolveDialog = (e) => { - closeMenu(e); - setIsDialogOpen(true); - }; - - const closeDialog = (e) => { - if (e) { - e.stopPropagation(); - } - setIsDialogOpen(false); - }; - - const isActive = incident.status === true; - const handleGoToDetails = (e) => { - e.stopPropagation(); - closeMenu(e); - if (onOpenDetails) { - onOpenDetails(incident.id); - } - }; - - const handleGoToMonitor = async (e) => { - e.stopPropagation(); - closeMenu(e); - try { - if (monitor) { - const path = TypeToPathMap[monitor.type]; - if (path && monitor.id) { - navigate(`/${path}/${monitor.id}`); - } else { - console.error(`Monitor type not found`); - } - } else { - console.error("Monitor information not found in incident data"); - } - } catch (error) { - console.error("Error fetching incident details:", error); - } - }; - - return ( - <> - { - event.stopPropagation(); - openMenu(event); - }} - sx={{ - "&:focus": { - outline: "none", - }, - "& svg path": { - stroke: theme.palette.primary.contrastTextTertiary, - }, - }} - > - - - - - - {t("incidentsPage.incidentsTableActionDetails", "Details")} - - - {t("incidentsPage.incidentsTableActionGoToMonitor", "Go to monitor")} - - {isActive && ( - { - e.stopPropagation(); - openResolveDialog(e); - }} - > - {t("incidentsPage.incidentsTableActionResolveManually")} - - )} - - - - ); -}; - -IncidentActionsMenu.propTypes = { - incident: PropTypes.shape({ - id: PropTypes.string.isRequired, - status: PropTypes.bool.isRequired, - }).isRequired, - monitor: PropTypes.shape({ - id: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - }).isRequired, - onResolve: PropTypes.func.isRequired, - onOpenDetails: PropTypes.func, -}; - -export default IncidentActionsMenu; diff --git a/client/src/Pages/Incidents/Components/IncidentTable/index.jsx b/client/src/Pages/Incidents/Components/IncidentTable/index.jsx deleted file mode 100644 index 2a03a8822..000000000 --- a/client/src/Pages/Incidents/Components/IncidentTable/index.jsx +++ /dev/null @@ -1,193 +0,0 @@ -//Components -import DataTable from "@/Components/v1/Table/index.jsx"; -import TableSkeleton from "@/Components/v1/Table/skeleton.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx"; -import IncidentActionsMenu from "./IncidentActionsMenu.jsx"; - -//Utils -import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; -import { useSelector } from "react-redux"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; -import { Typography, useTheme } from "@mui/material"; - -const IncidentTable = ({ - monitors = [], - incidents = [], - incidentsCount = 0, - isLoading = false, - networkError = false, - page = 0, - rowsPerPage = 10, - handleChangePage, - handleChangeRowsPerPage, - resolveIncident, - handleUpdateTrigger, - onOpenDetails, -}) => { - const uiTimezone = useSelector((state) => state.ui.timezone); - - const { t } = useTranslation(); - const theme = useTheme(); - - const handleResolveIncident = async (incidentId, options = {}) => { - try { - await resolveIncident(incidentId, options); - handleUpdateTrigger(); - } catch (error) { - console.error(t("incidentsPage.errorResolvingIncident"), error); - } - }; - - const headers = [ - { - id: "monitorName", - content: t("incidentsTableMonitorName"), - render: (row) => { - const monitor = monitors.find((monitor) => monitor.id === row.monitorId); - return monitor ? monitor.name : "N/A"; - }, - }, - { - id: "status", - content: t("incidentsTableStatus"), - render: (row) => { - const status = row.status === true ? "down" : "up"; - const statusText = - row.status === true ? t("incidentsPage.active") : t("incidentsPage.resolved"); - return ( - - ); - }, - }, - { - id: "startTime", - content: t("incidentsPage.startTime"), - render: (row) => { - const formattedDate = formatDateWithTz( - row.startTime || row.createdAt, - "YYYY-MM-DD HH:mm:ss A", - uiTimezone - ); - return formattedDate; - }, - }, - { - id: "endTime", - content: t("incidentsPage.endTime"), - render: (row) => { - if (row.endTime) { - return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone); - } - return "-"; - }, - }, - { - id: "resolutionType", - content: t("incidentsPage.resolutionType"), - render: (row) => { - if (row.resolutionType) { - return ( - - {row.resolutionType} - - ); - } - return "-"; - }, - }, - { - id: "statusCode", - content: t("incidentsTableStatusCode"), - render: (row) => , - }, - { - id: "message", - content: t("incidentsTableMessage"), - render: (row) => row.message || "-", - }, - { - id: "action", - content: t("actions"), - render: (row) => { - return ( - monitor.id === row.monitorId)} - onResolve={handleResolveIncident} - onOpenDetails={onOpenDetails} - /> - ); - }, - }, - ]; - - if (isLoading) return ; - - if (networkError) { - return ( - - - - ); - } - - if (!isLoading && !networkError && incidents?.length === 0) { - return ( - - {t("incidentsTableNoIncidents", "No incidents found")} - - ); - } - - const incidentsData = Array.isArray(incidents) ? incidents : []; - - return ( - <> - - - - ); -}; - -IncidentTable.propTypes = { - incidents: PropTypes.array.isRequired, // Array of incident objects - incidentsCount: PropTypes.number.isRequired, // Total count for pagination - isLoading: PropTypes.bool.isRequired, // Loading state - networkError: PropTypes.bool, // Network error object - page: PropTypes.number.isRequired, // Current page number - rowsPerPage: PropTypes.number.isRequired, // Number of rows per page - handleChangePage: PropTypes.func.isRequired, // Handler for page change - handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change - resolveIncident: PropTypes.func.isRequired, - handleUpdateTrigger: PropTypes.func.isRequired, - onOpenDetails: PropTypes.func, -}; - -export default IncidentTable; diff --git a/client/src/Pages/Incidents/Components/IncidentsSummaryPanel/index.jsx b/client/src/Pages/Incidents/Components/IncidentsSummaryPanel/index.jsx deleted file mode 100644 index 33ba21cd1..000000000 --- a/client/src/Pages/Incidents/Components/IncidentsSummaryPanel/index.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useState } from "react"; -// Hooks -import useFetchIncidents from "../../hooks/useFetchIncidents.js"; -import PropTypes from "prop-types"; -// Components -import { Grid } from "@mui/material"; -import ActiveIncidentsPanel from "../ActiveIncidentsPanel/index.jsx"; -import LatestIncidentsPanel from "../LatestIncidentsPanel/index.jsx"; -import StatisticsPanel from "../StatisticsPanel/index.jsx"; - -const IncidentsSummaryPanel = ({ updateTrigger }) => { - const { fetchIncidentSummary } = useFetchIncidents(); - const [summary, setSummary] = useState({ - totalActive: 0, - avgResolutionTimeHours: 0, - total: 0, - topMonitor: null, - totalManualResolutions: 0, - totalAutomaticResolutions: 0, - latestIncidents: [], - }); - - const [isLoadingSummary, setIsLoadingSummary] = useState(false); - const [summaryError, setSummaryError] = useState(null); - - useEffect(() => { - const fetchSummary = async () => { - setIsLoadingSummary(true); - setSummaryError(null); - - try { - const summaryData = await fetchIncidentSummary({ - limit: 3, - }); - - if (summaryData) { - setSummary(summaryData); - } else { - setSummaryError(new Error("Failed to fetch summary")); - } - } catch (error) { - console.error("Error fetching incident summary:", error); - setSummaryError(error); - } finally { - setIsLoadingSummary(false); - } - }; - - fetchSummary(); - }, [fetchIncidentSummary, updateTrigger]); - - return ( - <> - - - - - - - - - - - - - - ); -}; - -IncidentsSummaryPanel.propTypes = { - updateTrigger: PropTypes.bool.isRequired, -}; -export default IncidentsSummaryPanel; diff --git a/client/src/Pages/Incidents/Components/IncidentsSummaryPanel/skeleton.jsx b/client/src/Pages/Incidents/Components/IncidentsSummaryPanel/skeleton.jsx deleted file mode 100644 index 153cd7c25..000000000 --- a/client/src/Pages/Incidents/Components/IncidentsSummaryPanel/skeleton.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Skeleton } from "@mui/material"; -import SummaryCard from "../SummaryCard/index.jsx"; - -/** - * Simple skeleton loader for summary panels - * Reusable for ActiveIncidentsPanel, LatestIncidentsPanel, and StatisticsPanel - */ -const PanelSkeleton = () => { - return ( - - - - ); -}; - -export default PanelSkeleton; diff --git a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx deleted file mode 100644 index e8001087b..000000000 --- a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from "prop-types"; -import { Box, Stack, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js"; -import Icon from "@/Components/v1/Icon"; -import { useTranslation } from "react-i18next"; -import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration"; -import { Grid } from "@mui/material"; - -const IncidentItem = ({ incident }) => { - const { t } = useTranslation(); - const theme = useTheme(); - const isActive = incident.status === true; - const duration = useGetIncidentsDuration(incident, isActive); - const iconWrapperStyle = { - display: "flex", - justifyContent: "center", - mx: theme.spacing(2), - color: theme.palette.primary.contrastTextTertiary, - "& svg": { - width: 18, - height: 18, - }, - "& svg path": { - stroke: "currentColor", - strokeWidth: 1.5, - }, - }; - - return ( - - - - - - - {incident.monitorName || t("incidentsPage.unknownMonitor")} - - - - - - - - - {duration} - - - ); -}; - -IncidentItem.propTypes = { - incident: PropTypes.shape({ - _id: PropTypes.string, - id: PropTypes.string, - monitorId: PropTypes.string, - monitorName: PropTypes.string, - status: PropTypes.bool, - startTime: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - endTime: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - resolutionType: PropTypes.oneOf(["automatic", "manual"]), - }), - onClick: PropTypes.func, -}; - -export default IncidentItem; diff --git a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx deleted file mode 100644 index ff1ef9417..000000000 --- a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import PropTypes from "prop-types"; -import { Box, Stack, Typography, Divider } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; -import IncidentItem from "./IncidentItem.jsx"; -import SummaryCard from "../SummaryCard/index.jsx"; -import Icon from "@/Components/v1/Icon"; - -/** - * LatestIncidentsPanel Component - * - * Displays a quick overview of recent incidents (both active and resolved). - * Shows only essential information: monitor name, status, duration, and resolution type. - * - * @param {Object} props - * @param {Array} props.incidents - Array of recent incidents - * @param {boolean} props.isLoading - Loading state - * @param {Object} props.error - Error object if any - */ -const LatestIncidentsPanel = ({ incidents = [], isLoading = false, error = null }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - if (isLoading) { - return ; - } - - if (error) { - return ( - - - - {t("incidentsPage.incidentsLatestPanelError")} - - - - ); - } - - return ( - - {!incidents || incidents.length === 0 ? ( - - - - - - - {t("incidentsPage.incidentsLatestPanelEmpty")} - - - ) : ( - - {incidents.map((incident, index) => ( - - - {index < incidents.length - 1 && } - - ))} - - )} - - ); -}; - -LatestIncidentsPanel.propTypes = { - incidents: PropTypes.arrayOf( - PropTypes.shape({ - _id: PropTypes.string, - monitorId: PropTypes.string, - monitorName: PropTypes.string, - status: PropTypes.bool, - duration: PropTypes.string, - resolutionType: PropTypes.oneOf(["automatic", "manual"]), - }) - ), - isLoading: PropTypes.bool, - error: PropTypes.object, - onIncidentClick: PropTypes.func, -}; - -export default LatestIncidentsPanel; diff --git a/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx b/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx deleted file mode 100644 index 8d7439576..000000000 --- a/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx +++ /dev/null @@ -1,139 +0,0 @@ -// Components -import { Stack, Typography, Button, ButtonGroup } from "@mui/material"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import PropTypes from "prop-types"; - -//Utils -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; - -const OptionsHeader = ({ - shouldRender, - selectedMonitor = 0, - setSelectedMonitor, - monitors, - filter = "all", - setFilter, - dateRange = "all", - setDateRange, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : []; - - const filterOptions = [ - { _id: "all", name: t("incidentsPage.incidentsOptionsHeaderFilterAll") }, - { _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") }, - { _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") }, - { _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") }, - { _id: "automatic", name: t("incidentsPage.incidentsOptionsHeaderFilterAutomatic") }, - ]; - - const stackStyles = { - direction: "row", - alignItems: "center", - gap: theme.spacing(6), - }; - - if (!shouldRender) return; - - return ( - - - - {t("incidentsOptionsHeader")} - - setFilter(e.target.value)} - items={filterOptions} - sx={{ - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastTextSecondary, - }} - /> - - - - {t("incidentsOptionsHeaderShow")} - - - - - - - - - - ); -}; - -OptionsHeader.propTypes = { - shouldRender: PropTypes.bool, - selectedMonitor: PropTypes.string, - setSelectedMonitor: PropTypes.func, - monitors: PropTypes.object, - filter: PropTypes.string, - setFilter: PropTypes.func, - dateRange: PropTypes.string, - setDateRange: PropTypes.func, -}; - -export default OptionsHeader; diff --git a/client/src/Pages/Incidents/Components/ResolveIncidentDialog/index.jsx b/client/src/Pages/Incidents/Components/ResolveIncidentDialog/index.jsx deleted file mode 100644 index 3e868b2dc..000000000 --- a/client/src/Pages/Incidents/Components/ResolveIncidentDialog/index.jsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect, useState } from "react"; -import PropTypes from "prop-types"; -import { Button, Stack } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "@emotion/react"; -import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; - -const ResolveIncidentDialog = ({ - open, - incidentId, - onClose, - onResolve, - onAfterResolve, - onResolved, - isLoading = false, -}) => { - const { t } = useTranslation(); - const theme = useTheme(); - const [comment, setComment] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - - useEffect(() => { - if (open) { - setComment(""); - } - }, [open]); - - const handleConfirm = async () => { - if (!incidentId) return; - const trimmed = comment.trim(); - try { - setIsSubmitting(true); - await onResolve(incidentId, trimmed ? { comment: trimmed } : {}); - if (onAfterResolve) { - await onAfterResolve(); - } - onResolved?.(); - onClose(); - } catch (error) { - console.error("Error resolving incident:", error); - } finally { - setIsSubmitting(false); - } - }; - - return ( - - - setComment(e.target.value)} - maxWidth="100%" - /> - - - - - - - ); -}; - -ResolveIncidentDialog.propTypes = { - open: PropTypes.bool.isRequired, - incidentId: PropTypes.string, - onClose: PropTypes.func.isRequired, - onResolve: PropTypes.func.isRequired, - onAfterResolve: PropTypes.func, - isLoading: PropTypes.bool, - onResolved: PropTypes.func, -}; - -export default ResolveIncidentDialog; diff --git a/client/src/Pages/Incidents/Components/StatisticsPanel/index.jsx b/client/src/Pages/Incidents/Components/StatisticsPanel/index.jsx deleted file mode 100644 index 7f07f688f..000000000 --- a/client/src/Pages/Incidents/Components/StatisticsPanel/index.jsx +++ /dev/null @@ -1,168 +0,0 @@ -import PropTypes from "prop-types"; -import { Box, Stack, Typography, Divider } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; -import { useTranslation } from "react-i18next"; -import Icon from "@/Components/v1/Icon"; -import SummaryCard from "../SummaryCard/index.jsx"; - -/** - * StatisticsPanel Component - * - * Displays key metrics and statistics about incidents. - * - * @param {Object} props - * @param {Object} props.statistics - Statistics data object - * @param {number} props.statistics.total - Total incidents count - * @param {number} props.statistics.totalTrend - Trend percentage for total (optional) - * @param {string} props.statistics.mttr - Mean Time To Resolve (formatted string, e.g., "2h 15m") - * @param {number} props.statistics.mttrTrend - Trend percentage for MTTR (optional) - * @param {Object} props.statistics.topMonitor - Top monitor with most incidents - * @param {string} props.statistics.topMonitor.name - Monitor name - * @param {number} props.statistics.topMonitor.count - Incident count - * @param {number} props.statistics.topMonitor.percentage - Percentage of total - * @param {string} props.statistics.uptimeAffected - Total downtime (formatted string, e.g., "12h 45m") - * @param {number} props.statistics.availability - Availability percentage - * @param {boolean} props.isLoading - Loading state - * @param {Object} props.error - Error object if any - */ -const StatisticsPanel = ({ isLoading = false, error = null, summary = {} }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const totalResolutions = - (summary?.totalManualResolutions || 0) + (summary?.totalAutomaticResolutions || 0); - - const iconWrapperStyle = { - display: "flex", - justifyContent: "center", - mx: theme.spacing(2), - color: theme.palette.primary.contrastTextTertiary, - "& svg": { - width: 18, - height: 18, - }, - "& svg path": { - stroke: "currentColor", - strokeWidth: 1.5, - }, - }; - if (isLoading) { - return ; - } - - if (error) { - return ( - - - - {t("incidentsPage.incidentsStatisticsPanelError")} - - - - ); - } - - const getMostAffectedMonitor = () => { - if (!summary.total || summary.total === 0) { - return t("incidentsPage.none"); - } - return summary.topMonitor?.monitorName || t("incidentsPage.none"); - }; - - const rowStyle = { - py: theme.spacing(0.5), - minHeight: 32, - }; - - return ( - - - - - - - - - {t("incidentsPage.totalIncidents")}: {summary.total || 0} - - - - - - - - - - - {t("incidentsPage.mostAffectedMonitor")}: {getMostAffectedMonitor()} - - - - - - - - - - {t("incidentsPage.avgResolutionTime")}:{" "} - {summary.total > 0 - ? `${summary.avgResolutionTimeHours || 0} ${t("incidentsPage.hours")}` - : "N/A"} - - - - - ); -}; - -StatisticsPanel.propTypes = { - summary: PropTypes.object, - isLoading: PropTypes.bool, - error: PropTypes.object, -}; - -export default StatisticsPanel; diff --git a/client/src/Pages/Incidents/Components/SummaryCard/index.jsx b/client/src/Pages/Incidents/Components/SummaryCard/index.jsx deleted file mode 100644 index 3a8589dc1..000000000 --- a/client/src/Pages/Incidents/Components/SummaryCard/index.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Paper } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import PropTypes from "prop-types"; -import { Box, Stack, Typography, Divider } from "@mui/material"; -const SummaryCard = ({ children, isHighPriority = false, sx = {}, title = null }) => { - const theme = useTheme(); - return ( - - {title && ( - - - - {title} - - - - - )} - - {children} - - - ); -}; -SummaryCard.propTypes = { - children: PropTypes.node, - isHighPriority: PropTypes.bool, - sx: PropTypes.object, - title: PropTypes.string, -}; -export default SummaryCard; diff --git a/client/src/Pages/Incidents/hooks/useFetchIncidents.js b/client/src/Pages/Incidents/hooks/useFetchIncidents.js deleted file mode 100644 index e308c9319..000000000 --- a/client/src/Pages/Incidents/hooks/useFetchIncidents.js +++ /dev/null @@ -1,215 +0,0 @@ -import { useState, useCallback } from "react"; -import { networkService } from "../../../main.jsx"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; -/** - * Hook to fetch and manage incidents - * - * Provides multiple functions to fetch incidents with different filters. - * - * @returns {Object} { - * incidents, - * incidentsCount, - * isLoading, - * networkError, - * fetchIncidents, - * fetchActiveIncidents, - * fetchResolvedIncidents, - * fetchIncidentsByTeam, - * fetchIncidentById, - * resolveIncident, - * fetchIncidentSummary - * } - */ -const useFetchIncidents = () => { - const [incidents, setIncidents] = useState(null); - const [incidentsCount, setIncidentsCount] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [networkError, setNetworkError] = useState(false); - const { t } = useTranslation(); - /** - * Generic function to fetch incidents by team with custom config - * - * @param {Object} config - Configuration object - * @param {string} [config.monitorId] - Filter by monitor ID - * @param {boolean} [config.status] - Filter by status (true=active, false=resolved) - * @param {string} [config.resolutionType] - Filter by resolution type (automatic/manual) - * @param {string} [config.sortOrder] - Sort order (asc/desc) - * @param {string} [config.dateRange] - Date range filter - * @param {number} [config.page] - Page number - * @param {number} [config.rowsPerPage] - Rows per page - */ - const fetchIncidentsByTeam = useCallback(async (config = {}) => { - try { - setIsLoading(true); - setNetworkError(false); - - const res = await networkService.getIncidentsByTeam(config); - setIncidents(res.data?.data?.incidents || []); - setIncidentsCount(res.data?.data?.count || 0); - } catch (error) { - setNetworkError(true); - console.error(t("incidentsPage.errorFetchingIncidents"), error); - createToast({ body: error.message || t("incidentsPage.errorFetchingIncidents") }); - } finally { - setIsLoading(false); - } - }, []); - - /** - * Fetch active (open) incidents - Default function - * - * @param {Object} config - Additional configuration - */ - const fetchActiveIncidents = useCallback( - async (config = {}) => { - await fetchIncidentsByTeam({ - status: true, - sortOrder: "desc", - ...config, - }); - }, - [fetchIncidentsByTeam] - ); - - const fetchIncidentsByResolutionType = useCallback( - async (resolutionType, config = {}) => { - await fetchIncidentsByTeam({ - resolutionType, - sortOrder: "desc", - ...config, - }); - }, - [fetchIncidentsByTeam] - ); - /** - * Fetch resolved incidents - * - * @param {Object} config - Additional configuration - */ - const fetchResolvedIncidents = useCallback( - async (config = {}) => { - await fetchIncidentsByTeam({ - status: false, - sortOrder: "desc", - ...config, - }); - }, - [fetchIncidentsByTeam] - ); - - /** - * Fetch incidents with custom filters (alias for fetchIncidentsByTeam) - * - * @param {Object} config - Configuration object - */ - const fetchIncidents = useCallback( - async (config = {}) => { - await fetchIncidentsByTeam(config); - }, - [fetchIncidentsByTeam] - ); - - /** - * Fetch a single incident by ID - * - * @param {string} incidentId - The ID of the incident to fetch - * @returns {Promise} The incident object or null if not found - */ - const fetchIncidentById = useCallback(async (incidentId) => { - if (!incidentId) { - console.error(t("incidentsPage.noIncidentIdProvided")); - return null; - } - - try { - setIsLoading(true); - setNetworkError(false); - - const res = await networkService.getIncidentById(incidentId); - return res.data?.data || null; - } catch (error) { - setNetworkError(true); - console.error(t("incidentsPage.errorFetchingIncident"), error); - createToast({ body: error.message || t("incidentsPage.errorFetchingIncident") }); - return null; - } finally { - setIsLoading(false); - } - }, []); - - /** - * Resolve an incident manually - * - * @param {string} incidentId - The ID of the incident to resolve - * @param {Object} options - Resolution options - * @param {string} [options.comment] - Optional comment about the resolution - * @param {Function} [onSuccess] - Callback function to call on success - * @param {Function} [onError] - Callback function to call on error - */ - const resolveIncident = useCallback(async (incidentId, options = {}) => { - if (!incidentId) { - console.error(t("incidentsPage.noIncidentIdProvided")); - return; - } - - try { - setIsLoading(true); - setNetworkError(false); - - const res = await networkService.resolveIncidentManually(incidentId, options); - if (res.data?.success) { - createToast({ body: t("incidentsPage.incidentResolvedSuccessfully") }); - } else { - createToast({ body: t("incidentsPage.errorResolvingIncident") }); - } - } catch (error) { - setNetworkError(true); - console.error(t("incidentsPage.errorResolvingIncident"), error); - const errorMessage = - error.response?.data?.message || - error.message || - t("incidentsPage.errorResolvingIncident"); - createToast({ body: errorMessage }); - } finally { - setIsLoading(false); - } - }, []); - - /** - * Fetch incident summary - * - * @param {Object} config - Configuration object - * @param {number} [config.limit=10] - Number of latest incidents to return - * @returns {Promise} The summary object or null if error - */ - const fetchIncidentSummary = useCallback(async (config = {}) => { - try { - const res = await networkService.getIncidentSummary(config); - return res.data?.data || null; - } catch (error) { - console.error(t("incidentsPage.errorFetchingIncidentSummary"), error); - createToast({ - body: error.message || t("incidentsPage.errorFetchingIncidentSummary"), - }); - return null; - } - }, []); - - return { - incidents, - incidentsCount, - isLoading, - networkError, - fetchIncidents, - fetchActiveIncidents, - fetchResolvedIncidents, - fetchIncidentsByTeam, - fetchIncidentById, - resolveIncident, - fetchIncidentSummary, - fetchIncidentsByResolutionType, - }; -}; - -export default useFetchIncidents; diff --git a/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js b/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js deleted file mode 100644 index a4f434a80..000000000 --- a/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js +++ /dev/null @@ -1,25 +0,0 @@ -import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js"; -const useGetIncidentsDuration = (incident, isActive) => { - if (!incident?.startTime) { - return "-"; - } - const startTime = new Date(incident?.startTime); - const endTime = isActive - ? new Date() - : incident?.endTime - ? new Date(incident.endTime) - : null; - - if (!endTime) { - return "-"; - } - - const durationMs = endTime.getTime() - startTime.getTime(); - - if (durationMs < 0) { - return "-"; - } - - return getHumanReadableDuration(durationMs); -}; -export default useGetIncidentsDuration; diff --git a/client/src/Pages/Incidents/old.jsx b/client/src/Pages/Incidents/old.jsx deleted file mode 100644 index 0377405b7..000000000 --- a/client/src/Pages/Incidents/old.jsx +++ /dev/null @@ -1,178 +0,0 @@ -// Components -import { Stack } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import IncidentTable from "./Components/IncidentTable/index.jsx"; -import OptionsHeader from "./Components/OptionsHeader/index.jsx"; -import IncidentsSummaryPanel from "./Components/IncidentsSummaryPanel/index.jsx"; -import IncidentDetailsModal from "./Components/IncidentDetailsModal/index.jsx"; -//Utils -import { useTheme } from "@emotion/react"; -import { useState, useEffect } from "react"; -import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; - -// Hooks -import useFetchIncidents from "./hooks/useFetchIncidents.js"; -import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks.js"; - -const Incidents2 = () => { - const { monitorId } = useParams(); - const { t } = useTranslation(); - - const BREADCRUMBS = [ - { name: t("incidentsPageTitle", "Incidents"), path: "/incidents" }, - ]; - - const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); - const [filter, setFilter] = useState("all"); - const [dateRange, setDateRange] = useState("all"); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - const [monitorLookup, setMonitorLookup] = useState(undefined); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [isDetailsOpen, setIsDetailsOpen] = useState(false); - const [selectedIncidentId, setSelectedIncidentId] = useState(null); - const handleUpdateTrigger = () => { - setUpdateTrigger((prev) => !prev); - }; - - const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId( - {} - ); - - const { - incidents, - incidentsCount, - isLoading: isLoadingIncidents, - networkError: incidentsNetworkError, - fetchIncidents, - fetchActiveIncidents, - fetchResolvedIncidents, - resolveIncident, - fetchIncidentsByResolutionType, - } = useFetchIncidents(); - - const networkError = monitorsNetworkError || incidentsNetworkError; - - const theme = useTheme(); - - useEffect(() => { - setPage(0); - }, [selectedMonitor, filter, dateRange]); - - useEffect(() => { - const config = { - monitorId: selectedMonitor !== "0" ? selectedMonitor : undefined, - sortOrder: "desc", - dateRange, - page, - rowsPerPage, - }; - - if (filter === "active") { - fetchActiveIncidents(config); - } else if (filter === "resolved") { - fetchResolvedIncidents(config); - } else if (filter === "manual") { - fetchIncidentsByResolutionType("manual", config); - } else if (filter === "automatic") { - fetchIncidentsByResolutionType("automatic", config); - } else { - fetchIncidents(config); - } - }, [ - selectedMonitor, - filter, - dateRange, - page, - rowsPerPage, - updateTrigger, - fetchActiveIncidents, - fetchResolvedIncidents, - fetchIncidents, - fetchIncidentsByResolutionType, - ]); - - useEffect(() => { - const lookup = monitors?.reduce((acc, monitor) => { - acc[monitor.id] = { - id: monitor.id, - name: monitor.name, - type: monitor.type, - }; - return acc; - }, {}); - setMonitorLookup(lookup); - }, [monitors]); - - if (networkError) { - return ( - - - - ); - } - - const handleChangePage = (_, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; - const handleOpenDetails = (id) => { - if (!id) return; - setSelectedIncidentId(id); - setIsDetailsOpen(true); - }; - - const handleCloseDetails = () => { - setIsDetailsOpen(false); - setSelectedIncidentId(null); - }; - - return ( - - - - - - - - - - - ); -}; - -export default Incidents2; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b64d2b3bb..da604b395 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -8,7 +8,8 @@ "labels": { "responseTime": "Response time", "active": "Active", - "resolved": "Resolved" + "resolved": "Resolved", + "na": "N/A" }, "buttons": { "toggleTheme": "Toggles light & dark", @@ -406,7 +407,8 @@ }, "status": { "active": "Active", - "resolved": "Resolved" + "resolved": "Resolved", + "na": "N/A" }, "actions": { "details": "Details", @@ -452,20 +454,6 @@ } } }, - "incidentsTableNoIncidents": "No incidents recorded", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableMonitorName": "Monitor Name", - "incidentsTableStatus": "Status", - "incidentsTableStatusCode": "Status Code", - "incidentsTableMessage": "Message", - "incidentsOptionsHeader": "Incidents for:", - "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderShow": "Show:", - "incidentsOptionsHeaderLastHour": "Last hour", - "incidentsOptionsHeaderLastDay": "Last day", - "incidentsOptionsHeaderLastWeek": "Last week", - "incidentsOptionsPlaceholderAllServers": "All servers", "used": "Used", "total": "Total", "status": "Status", @@ -648,7 +636,6 @@ "statusBreadCrumbsDetails": "Details", "commonSaving": "Saving...", "navControls": "Controls", - "incidentsPageTitle": "Incidents", "passwordPanel": { "currentPassword": "Current password", "newPassword": "New password", @@ -1002,61 +989,9 @@ }, "chooseGame": "Choose game", "incidentsPage": { - "resolved": "Resolved", - "active": "Active", - "startTime": "Start Time", - "endTime": "End Time", - "resolutionType": "Resolution Type", - "incidentsActivePanelError": "Error loading active incidents", - "allSystemsAreOperational": "All Systems Are Operational", - "incidentsActivePanelTitle": "Total Active Incidents", - "incidentsLatestPanelError": "Error loading latest incidents", - "incidentsLatestPanelTitle": "Latest Incidents", - "incidentsLatestPanelEmpty": "No recent incidents", - "incidentItemMonitor": "Monitor", - "unknownMonitor": "Unknown Monitor", - "incidentsStatisticsPanelError": "Error loading statistics", - "incidentsStatisticsPanelTitle": "General Statistics", - "totalIncidents": "Total Incidents", - "mostAffectedMonitor": "Most Affected Monitor", - "avgResolutionTime": "Avg. Resolution Time", - "incidentResolvedSuccessfully": "Incident resolved successfully", - "errorFetchingIncidents": "Error fetching incidents", - "errorFetchingIncident": "Error fetching incident", - "noIncidentIdProvided": "No incident ID provided", - "errorFetchingIncidentSummary": "Error fetching incident summary", - "incidentsOptionsHeaderFilterManual": "Manual", - "incidentsOptionsHeaderFilterAutomatic": "Automatic", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterActive": "Active", - "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsTableActionResolveManually": "Resolve Manually", - "incidentsTableActionDetails": "Details", - "incidentsTableActionGoToMonitor": "Go to monitor", "resolveIncidentDialogTitle": "Resolve Incident", - "resolveIncidentDialogDescription": "Are you sure you want to resolve this incident manually?", "resolveIncidentDialogCommentLabel": "Comment (optional)", "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", - "resolveIncidentDialogConfirm": "Resolve", - "hours": "hours", - "incidentNotFound": "Incident not found", - "incidentDetails": "INCIDENT DETAILS", - "overview": "OVERVIEW", - "URL": "URL", - "incidentAnalysis": "INCIDENT ANALYSIS", - "timeline": "Timeline", - "startedAt": "Started at", - "endedAt": "Ended at", - "downtime": "Downtime", - "errorDetails": "Error details", - "resolutionDetails": "Resolution details", - "statusCode": "Status code", - "resolutionMethod": "Resolution method", - "resolvedBy": "Resolved by", - "comment": "Comment", - "noCommentProvided": "No comment provided", - "loadingIncidentDetails": "Loading incident details...", - "none": "None", - "message": "Message" + "resolveIncidentDialogConfirm": "Resolve" } } From 43fe66022bad7a486d783a4bc4481941b329225a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 18:45:52 +0000 Subject: [PATCH 113/392] format --- .../charts/HistogramPageSpeedDetailsTooltip.tsx | 5 +++-- .../src/Pages/Incidents/Components/CardDetails.tsx | 4 +++- .../src/Pages/Incidents/Components/CardSummary.tsx | 14 +++++++++++--- .../Components/ControlsIncidentFilter.tsx | 4 +++- client/src/locales/en.json | 2 +- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx b/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx index 7219ba821..1663ab3b5 100644 --- a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx +++ b/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx @@ -8,8 +8,9 @@ import type { TooltipProps } from "recharts"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; -interface HistogramPageSpeedScoresTooltipProps - extends Partial> { +interface HistogramPageSpeedScoresTooltipProps extends Partial< + TooltipProps +> { config: Record; } export const HistogramPageSpeedScoresTooltip = ({ diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index c1a0c1dd2..07ab49073 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -171,7 +171,9 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { - {incident.resolvedByEmail ?? incident.resolvedBy} + + {incident.resolvedByEmail ?? incident.resolvedBy} + )} diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx index 60b13bde7..c0e129dde 100644 --- a/client/src/Pages/Incidents/Components/CardSummary.tsx +++ b/client/src/Pages/Incidents/Components/CardSummary.tsx @@ -86,7 +86,9 @@ interface SummaryCardActiveIncidentsProps { summary?: IncidentSummary | null; } -export const SummaryCardActiveIncidents = ({ summary }: SummaryCardActiveIncidentsProps) => { +export const SummaryCardActiveIncidents = ({ + summary, +}: SummaryCardActiveIncidentsProps) => { const { t } = useTranslation(); const theme = useTheme(); @@ -184,7 +186,9 @@ interface SummaryCardLatestIncidentsProps { summary?: IncidentSummary | null; } -export const SummaryCardLatestIncidents = ({ summary }: SummaryCardLatestIncidentsProps) => { +export const SummaryCardLatestIncidents = ({ + summary, +}: SummaryCardLatestIncidentsProps) => { const { t } = useTranslation(); const theme = useTheme(); @@ -232,7 +236,11 @@ export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => { } label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")} - value={summary.total > 0 ? `${summary.avgResolutionTimeHours || 0} hours` : t("common.labels.na")} + value={ + summary.total > 0 + ? `${summary.avgResolutionTimeHours || 0} hours` + : t("common.labels.na") + } /> ); diff --git a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx index b2f452011..1298057ff 100644 --- a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx +++ b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx @@ -28,7 +28,9 @@ export const ControlsIncidentFilter = ({ const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const isFilterActive = selectedMonitor !== "0" || (selectedResolutionType !== "" && selectedResolutionType !== "all"); + const isFilterActive = + selectedMonitor !== "0" || + (selectedResolutionType !== "" && selectedResolutionType !== "all"); return ( Date: Mon, 2 Feb 2026 18:50:20 +0000 Subject: [PATCH 114/392] bump mui color --- client/package-lock.json | 1030 ++------------------------------------ client/package.json | 2 +- 2 files changed, 38 insertions(+), 994 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 474322df6..d289d8312 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -25,7 +25,7 @@ "i18next": "25.4.2", "joi": "17.13.3", "lucide-react": "^0.562.0", - "mui-color-input": "^6.0.0", + "mui-color-input": "^7.0.0", "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", @@ -58,8 +58,6 @@ }, "node_modules/@babel/code-frame": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -72,8 +70,6 @@ }, "node_modules/@babel/compat-data": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -81,9 +77,8 @@ }, "node_modules/@babel/core": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -111,14 +106,10 @@ }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, "node_modules/@babel/generator": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -133,8 +124,6 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -149,8 +138,6 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -158,8 +145,6 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -171,8 +156,6 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -188,8 +171,6 @@ }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -198,8 +179,6 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -207,8 +186,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -216,8 +193,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -225,8 +200,6 @@ }, "node_modules/@babel/helpers": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -238,8 +211,6 @@ }, "node_modules/@babel/parser": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -253,8 +224,6 @@ }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { @@ -269,8 +238,6 @@ }, "node_modules/@babel/plugin-transform-react-jsx-source": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { @@ -285,8 +252,6 @@ }, "node_modules/@babel/runtime": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -294,8 +259,6 @@ }, "node_modules/@babel/template": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -308,8 +271,6 @@ }, "node_modules/@babel/traverse": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -326,8 +287,6 @@ }, "node_modules/@babel/types": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -339,8 +298,6 @@ }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", - "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", "license": "MIT", "engines": { "node": ">=14" @@ -348,8 +305,6 @@ }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.16.7", @@ -367,8 +322,6 @@ }, "node_modules/@emotion/cache": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", @@ -380,14 +333,10 @@ }, "node_modules/@emotion/hash": { "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", - "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" @@ -395,15 +344,12 @@ }, "node_modules/@emotion/memoize": { "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", "license": "MIT" }, "node_modules/@emotion/react": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -425,8 +371,6 @@ }, "node_modules/@emotion/serialize": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", "dependencies": { "@emotion/hash": "^0.9.2", @@ -438,15 +382,12 @@ }, "node_modules/@emotion/sheet": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", "license": "MIT" }, "node_modules/@emotion/styled": { "version": "11.14.1", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", - "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -467,14 +408,10 @@ }, "node_modules/@emotion/unitless": { "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", "license": "MIT" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0" @@ -482,14 +419,10 @@ }, "node_modules/@emotion/utils": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", "license": "MIT" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { @@ -499,7 +432,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -516,7 +448,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -533,7 +464,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -550,7 +480,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -567,7 +496,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -584,7 +512,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -601,7 +528,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -618,7 +544,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -635,7 +560,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -652,7 +576,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -669,7 +592,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -686,7 +608,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -703,7 +624,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -720,7 +640,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -737,7 +656,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -754,7 +672,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -766,12 +683,9 @@ }, "node_modules/@esbuild/linux-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -788,7 +702,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -805,7 +718,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -822,7 +734,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -839,7 +750,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -856,7 +766,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -873,7 +782,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -890,7 +798,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -907,7 +814,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -924,7 +830,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -936,8 +841,6 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -955,8 +858,6 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -965,8 +866,6 @@ }, "node_modules/@eslint/eslintrc": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -989,8 +888,6 @@ }, "node_modules/@eslint/js": { "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", "engines": { @@ -999,14 +896,10 @@ }, "node_modules/@hapi/hoek": { "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", "license": "BSD-3-Clause" }, "node_modules/@hapi/topo": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" @@ -1014,8 +907,6 @@ }, "node_modules/@hello-pangea/dnd": { "version": "18.0.1", - "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", - "integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.26.7", @@ -1031,8 +922,6 @@ }, "node_modules/@hookform/resolvers": { "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", - "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" @@ -1043,9 +932,6 @@ }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1059,8 +945,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1073,16 +957,11 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", "dev": true, "license": "BSD-3-Clause" }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1091,8 +970,6 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1101,8 +978,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1110,14 +985,10 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1126,8 +997,6 @@ }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.7", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz", - "integrity": "sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg==", "license": "MIT", "funding": { "type": "opencollective", @@ -1136,9 +1005,6 @@ }, "node_modules/@mui/lab": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-7.0.0.tgz", - "integrity": "sha512-MezN09Gnl4zrouyZJ3JS3Gg83pnQm5XULyojYCGONK+nUy6hhR/irz7y/nEGXm6wH239fX8zBmUXxDLZf0VJJA==", - "deprecated": "Version 7.0.0 was accidentally released", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -1181,9 +1047,8 @@ }, "node_modules/@mui/material": { "version": "7.3.7", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz", - "integrity": "sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.7", @@ -1230,8 +1095,6 @@ }, "node_modules/@mui/private-theming": { "version": "7.3.7", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.7.tgz", - "integrity": "sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -1257,8 +1120,6 @@ }, "node_modules/@mui/styled-engine": { "version": "7.3.7", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.7.tgz", - "integrity": "sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -1291,9 +1152,8 @@ }, "node_modules/@mui/system": { "version": "7.3.7", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.7.tgz", - "integrity": "sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.7", @@ -1331,8 +1191,6 @@ }, "node_modules/@mui/types": { "version": "7.4.10", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.10.tgz", - "integrity": "sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4" @@ -1348,8 +1206,6 @@ }, "node_modules/@mui/utils": { "version": "7.3.7", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.7.tgz", - "integrity": "sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -1378,8 +1234,6 @@ }, "node_modules/@mui/x-charts": { "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.29.1.tgz", - "integrity": "sha512-5s9PX51HWhpMa+DCDa4RgjtODSaMe+PlTZUqoGIil2vaW/+4ouDLREXvyuVvIF93KfZwrPKAL2SJKSQS4YYB2w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1413,8 +1267,6 @@ }, "node_modules/@mui/x-charts-vendor": { "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-7.20.0.tgz", - "integrity": "sha512-pzlh7z/7KKs5o0Kk0oPcB+sY0+Dg7Q7RzqQowDQjpy5Slz6qqGsgOB5YUzn0L+2yRmvASc4Pe0914Ao3tMBogg==", "license": "MIT AND ISC", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1436,8 +1288,6 @@ }, "node_modules/@mui/x-date-pickers": { "version": "7.29.4", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz", - "integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1502,8 +1352,6 @@ }, "node_modules/@mui/x-internals": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", - "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1522,8 +1370,6 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { @@ -1536,8 +1382,6 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { @@ -1546,8 +1390,6 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { @@ -1560,8 +1402,6 @@ }, "node_modules/@popperjs/core": { "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", "funding": { "type": "opencollective", @@ -1570,8 +1410,6 @@ }, "node_modules/@react-spring/animated": { "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", - "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", "license": "MIT", "dependencies": { "@react-spring/shared": "~9.7.5", @@ -1583,8 +1421,6 @@ }, "node_modules/@react-spring/core": { "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", - "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", "license": "MIT", "dependencies": { "@react-spring/animated": "~9.7.5", @@ -1601,14 +1437,10 @@ }, "node_modules/@react-spring/rafz": { "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", - "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", "license": "MIT" }, "node_modules/@react-spring/shared": { "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", - "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", "license": "MIT", "dependencies": { "@react-spring/rafz": "~9.7.5", @@ -1620,14 +1452,10 @@ }, "node_modules/@react-spring/types": { "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", - "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", "license": "MIT" }, "node_modules/@react-spring/web": { "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", - "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", "license": "MIT", "dependencies": { "@react-spring/animated": "~9.7.5", @@ -1642,8 +1470,6 @@ }, "node_modules/@reduxjs/toolkit": { "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.7.0.tgz", - "integrity": "sha512-XVwolG6eTqwV0N8z/oDlN93ITCIGIop6leXlGJI/4EKy+0POYkR+ABHRSdGXY+0MQvJBP8yAzh+EYFxTuvmBiQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1668,8 +1494,6 @@ }, "node_modules/@remix-run/router": { "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1677,15 +1501,11 @@ }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, "license": "MIT" }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -1711,7 +1531,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1725,7 +1544,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1739,7 +1557,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1753,7 +1570,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1767,7 +1583,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1781,7 +1596,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1795,7 +1609,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1809,7 +1622,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1823,7 +1635,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1837,7 +1648,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1851,7 +1661,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1865,7 +1674,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1879,7 +1687,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1893,7 +1700,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1907,7 +1713,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1921,7 +1726,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1935,7 +1739,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1944,12 +1747,9 @@ }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1963,7 +1763,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1977,7 +1776,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1991,7 +1789,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2005,7 +1802,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2019,7 +1815,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2033,7 +1828,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2047,7 +1841,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2056,8 +1849,6 @@ }, "node_modules/@sideway/address": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" @@ -2065,32 +1856,22 @@ }, "node_modules/@sideway/formula": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", "license": "BSD-3-Clause" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "license": "BSD-3-Clause" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@standard-schema/utils": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", "license": "MIT", "engines": { "node": ">=14" @@ -2105,8 +1886,6 @@ }, "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", "license": "MIT", "engines": { "node": ">=14" @@ -2121,8 +1900,6 @@ }, "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", "license": "MIT", "engines": { "node": ">=14" @@ -2137,8 +1914,6 @@ }, "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", "license": "MIT", "engines": { "node": ">=14" @@ -2153,8 +1928,6 @@ }, "node_modules/@svgr/babel-plugin-svg-dynamic-title": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", "license": "MIT", "engines": { "node": ">=14" @@ -2169,8 +1942,6 @@ }, "node_modules/@svgr/babel-plugin-svg-em-dimensions": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", "license": "MIT", "engines": { "node": ">=14" @@ -2185,8 +1956,6 @@ }, "node_modules/@svgr/babel-plugin-transform-react-native-svg": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", "license": "MIT", "engines": { "node": ">=14" @@ -2201,8 +1970,6 @@ }, "node_modules/@svgr/babel-plugin-transform-svg-component": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", "license": "MIT", "engines": { "node": ">=12" @@ -2217,8 +1984,6 @@ }, "node_modules/@svgr/babel-preset": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", "license": "MIT", "dependencies": { "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", @@ -2243,8 +2008,6 @@ }, "node_modules/@svgr/core": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", "dependencies": { "@babel/core": "^7.21.3", @@ -2263,8 +2026,6 @@ }, "node_modules/@svgr/core/node_modules/cosmiconfig": { "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "license": "MIT", "dependencies": { "import-fresh": "^3.3.0", @@ -2289,8 +2050,6 @@ }, "node_modules/@svgr/hast-util-to-babel-ast": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", "license": "MIT", "dependencies": { "@babel/types": "^7.21.3", @@ -2306,8 +2065,6 @@ }, "node_modules/@svgr/plugin-jsx": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", "license": "MIT", "dependencies": { "@babel/core": "^7.21.3", @@ -2328,8 +2085,6 @@ }, "node_modules/@types/babel__core": { "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", "dependencies": { @@ -2342,8 +2097,6 @@ }, "node_modules/@types/babel__generator": { "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -2352,8 +2105,6 @@ }, "node_modules/@types/babel__template": { "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, "license": "MIT", "dependencies": { @@ -2363,8 +2114,6 @@ }, "node_modules/@types/babel__traverse": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2373,32 +2122,22 @@ }, "node_modules/@types/d3-array": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, "node_modules/@types/d3-delaunay": { "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -2406,14 +2145,10 @@ }, "node_modules/@types/d3-path": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "license": "MIT", "dependencies": { "@types/d3-time": "*" @@ -2421,8 +2156,6 @@ }, "node_modules/@types/d3-shape": { "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -2430,50 +2163,37 @@ }, "node_modules/@types/d3-time": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/node": { "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.12.0" } }, "node_modules/@types/parse-json": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2481,8 +2201,6 @@ }, "node_modules/@types/react-dom": { "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2491,8 +2209,6 @@ }, "node_modules/@types/react-transition-group": { "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -2500,21 +2216,15 @@ }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { @@ -2534,10 +2244,9 @@ }, "node_modules/acorn": { "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2547,8 +2256,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2557,8 +2264,6 @@ }, "node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -2574,8 +2279,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -2584,8 +2287,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -2600,14 +2301,10 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -2623,8 +2320,6 @@ }, "node_modules/array-includes": { "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2646,8 +2341,6 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2667,8 +2360,6 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -2686,8 +2377,6 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2705,8 +2394,6 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -2722,8 +2409,6 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2744,8 +2429,6 @@ }, "node_modules/async-function": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", "engines": { @@ -2754,14 +2437,10 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2776,8 +2455,6 @@ }, "node_modules/axios": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -2787,8 +2464,6 @@ }, "node_modules/babel-plugin-macros": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", @@ -2802,15 +2477,11 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -2818,8 +2489,6 @@ }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -2827,8 +2496,6 @@ }, "node_modules/brace-expansion": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2838,8 +2505,6 @@ }, "node_modules/browserslist": { "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -2855,6 +2520,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2871,8 +2537,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { @@ -2890,8 +2554,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2903,8 +2565,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { @@ -2920,8 +2580,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", "engines": { "node": ">=6" @@ -2929,8 +2587,6 @@ }, "node_modules/camelcase": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "license": "MIT", "engines": { "node": ">=10" @@ -2941,8 +2597,6 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "funding": [ { "type": "opencollective", @@ -2961,8 +2615,6 @@ }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -2978,8 +2630,6 @@ }, "node_modules/clsx": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -2987,8 +2637,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3000,15 +2648,11 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3019,21 +2663,15 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, "node_modules/cosmiconfig": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", @@ -3046,10 +2684,17 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -3063,8 +2708,6 @@ }, "node_modules/css-box-model": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", - "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", "license": "MIT", "dependencies": { "tiny-invariant": "^1.0.6" @@ -3072,8 +2715,6 @@ }, "node_modules/css-line-break": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", "license": "MIT", "dependencies": { "utrie": "^1.0.2" @@ -3081,14 +2722,10 @@ }, "node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/d3-array": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -3099,8 +2736,6 @@ }, "node_modules/d3-color": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "license": "ISC", "engines": { "node": ">=12" @@ -3108,8 +2743,6 @@ }, "node_modules/d3-delaunay": { "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", "license": "ISC", "dependencies": { "delaunator": "5" @@ -3120,8 +2753,6 @@ }, "node_modules/d3-ease": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "license": "BSD-3-Clause", "engines": { "node": ">=12" @@ -3129,8 +2760,6 @@ }, "node_modules/d3-format": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -3138,8 +2767,6 @@ }, "node_modules/d3-interpolate": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -3150,8 +2777,6 @@ }, "node_modules/d3-path": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "license": "ISC", "engines": { "node": ">=12" @@ -3159,8 +2784,6 @@ }, "node_modules/d3-scale": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -3175,8 +2798,6 @@ }, "node_modules/d3-shape": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -3187,8 +2808,6 @@ }, "node_modules/d3-time": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -3199,8 +2818,6 @@ }, "node_modules/d3-time-format": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -3211,8 +2828,6 @@ }, "node_modules/d3-timer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "license": "ISC", "engines": { "node": ">=12" @@ -3220,8 +2835,6 @@ }, "node_modules/data-view-buffer": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3238,8 +2851,6 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3256,8 +2867,6 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3274,14 +2883,11 @@ }, "node_modules/dayjs": { "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3297,21 +2903,15 @@ }, "node_modules/decimal.js-light": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -3328,8 +2928,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -3346,8 +2944,6 @@ }, "node_modules/delaunator": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -3355,8 +2951,6 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3364,8 +2958,6 @@ }, "node_modules/dequal": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", "engines": { "node": ">=6" @@ -3373,8 +2965,6 @@ }, "node_modules/doctrine": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3386,8 +2976,6 @@ }, "node_modules/dom-helpers": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", @@ -3396,8 +2984,6 @@ }, "node_modules/dot-case": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", "license": "MIT", "dependencies": { "no-case": "^3.0.4", @@ -3406,8 +2992,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3420,14 +3004,10 @@ }, "node_modules/electron-to-chromium": { "version": "1.5.283", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", - "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", "license": "ISC" }, "node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3438,8 +3018,6 @@ }, "node_modules/error-ex": { "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -3447,8 +3025,6 @@ }, "node_modules/es-abstract": { "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -3516,8 +3092,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3525,8 +3099,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3534,8 +3106,6 @@ }, "node_modules/es-iterator-helpers": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", "dev": true, "license": "MIT", "dependencies": { @@ -3562,8 +3132,6 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3574,8 +3142,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3589,8 +3155,6 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3602,8 +3166,6 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { @@ -3620,9 +3182,6 @@ }, "node_modules/esbuild": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3662,8 +3221,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -3671,8 +3228,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", "engines": { "node": ">=10" @@ -3683,11 +3238,9 @@ }, "node_modules/eslint": { "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3740,8 +3293,6 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -3773,8 +3324,6 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", "engines": { @@ -3786,8 +3335,6 @@ }, "node_modules/eslint-plugin-react-refresh": { "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3796,8 +3343,6 @@ }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3809,8 +3354,6 @@ }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", "dependencies": { @@ -3827,8 +3370,6 @@ }, "node_modules/eslint-scope": { "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3844,8 +3385,6 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3857,8 +3396,6 @@ }, "node_modules/espree": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3875,8 +3412,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3888,8 +3423,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3901,8 +3434,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3911,14 +3442,10 @@ }, "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3927,21 +3454,15 @@ }, "node_modules/eventemitter3": { "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-equals": { "version": "5.4.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", - "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3949,22 +3470,16 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fastq": { "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -3973,9 +3488,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3991,8 +3503,6 @@ }, "node_modules/file-entry-cache": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", "dependencies": { @@ -4004,14 +3514,10 @@ }, "node_modules/find-root": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -4027,14 +3533,10 @@ }, "node_modules/flag-icons": { "version": "7.3.2", - "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.3.2.tgz", - "integrity": "sha512-QkaZ6Zvai8LIjx+UNAHUJ5Dhz9OLZpBDwCRWxF6YErxIcR16jTkIFm3bFu54EkvKQy4+wicW+Gm7/0631wVQyQ==", "license": "MIT" }, "node_modules/flat-cache": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", "dependencies": { @@ -4048,15 +3550,11 @@ }, "node_modules/flatted": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -4075,8 +3573,6 @@ }, "node_modules/for-each": { "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4091,8 +3587,6 @@ }, "node_modules/form-data": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4107,8 +3601,6 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC" }, @@ -4116,7 +3608,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4129,8 +3620,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4138,8 +3627,6 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4159,8 +3646,6 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { @@ -4169,8 +3654,6 @@ }, "node_modules/generator-function": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "dev": true, "license": "MIT", "engines": { @@ -4179,8 +3662,6 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4188,8 +3669,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4212,8 +3691,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4225,8 +3702,6 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { @@ -4243,9 +3718,6 @@ }, "node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -4265,8 +3737,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -4278,8 +3748,6 @@ }, "node_modules/globals": { "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4294,8 +3762,6 @@ }, "node_modules/globalthis": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4311,8 +3777,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4323,15 +3787,11 @@ }, "node_modules/graphemer": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, "node_modules/has-bigints": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -4343,8 +3803,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -4353,8 +3811,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -4366,8 +3822,6 @@ }, "node_modules/has-proto": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4382,8 +3836,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4394,8 +3846,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4409,8 +3859,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4421,8 +3869,6 @@ }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" @@ -4430,14 +3876,10 @@ }, "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/html-parse-stringify": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "license": "MIT", "dependencies": { "void-elements": "3.1.0" @@ -4445,8 +3887,6 @@ }, "node_modules/html2canvas": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", "license": "MIT", "dependencies": { "css-line-break": "^2.1.0", @@ -4458,8 +3898,6 @@ }, "node_modules/human-interval": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/human-interval/-/human-interval-2.0.1.tgz", - "integrity": "sha512-r4Aotzf+OtKIGQCB3odUowy4GfUDTy3aTWTfLd7ZF2gBCy3XW3v/dJLRefZnOFFnjqs5B1TypvS8WarpBkYUNQ==", "license": "MIT", "dependencies": { "numbered": "^1.1.0" @@ -4467,8 +3905,6 @@ }, "node_modules/i18next": { "version": "25.4.2", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.4.2.tgz", - "integrity": "sha512-gD4T25a6ovNXsfXY1TwHXXXLnD/K2t99jyYMCSimSCBnBRJVQr5j+VAaU83RJCPzrTGhVQ6dqIga66xO2rtd5g==", "funding": [ { "type": "individual", @@ -4484,6 +3920,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -4498,8 +3935,6 @@ }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -4508,8 +3943,6 @@ }, "node_modules/immer": { "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", "funding": { "type": "opencollective", @@ -4518,8 +3951,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4534,8 +3965,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -4544,9 +3973,6 @@ }, "node_modules/inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", "dependencies": { @@ -4556,15 +3982,11 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, "license": "ISC" }, "node_modules/internal-slot": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -4578,8 +4000,6 @@ }, "node_modules/internmap": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "license": "ISC", "engines": { "node": ">=12" @@ -4587,8 +4007,6 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -4605,14 +4023,10 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, "node_modules/is-async-function": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4631,8 +4045,6 @@ }, "node_modules/is-bigint": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4647,8 +4059,6 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -4664,8 +4074,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -4677,8 +4085,6 @@ }, "node_modules/is-core-module": { "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4692,8 +4098,6 @@ }, "node_modules/is-data-view": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { @@ -4710,8 +4114,6 @@ }, "node_modules/is-date-object": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -4727,8 +4129,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -4737,8 +4137,6 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { @@ -4753,8 +4151,6 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { @@ -4773,8 +4169,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -4786,8 +4180,6 @@ }, "node_modules/is-map": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -4799,8 +4191,6 @@ }, "node_modules/is-negative-zero": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { @@ -4812,8 +4202,6 @@ }, "node_modules/is-number-object": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -4829,8 +4217,6 @@ }, "node_modules/is-path-inside": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", "engines": { @@ -4839,8 +4225,6 @@ }, "node_modules/is-regex": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -4858,8 +4242,6 @@ }, "node_modules/is-set": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -4871,8 +4253,6 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -4887,8 +4267,6 @@ }, "node_modules/is-string": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -4904,8 +4282,6 @@ }, "node_modules/is-symbol": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4922,8 +4298,6 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4938,8 +4312,6 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -4951,8 +4323,6 @@ }, "node_modules/is-weakref": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { @@ -4967,8 +4337,6 @@ }, "node_modules/is-weakset": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4984,22 +4352,16 @@ }, "node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -5016,8 +4378,6 @@ }, "node_modules/joi": { "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -5029,14 +4389,10 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5047,8 +4403,6 @@ }, "node_modules/jsesc": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -5059,35 +4413,25 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -5098,8 +4442,6 @@ }, "node_modules/jsx-ast-utils": { "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5114,8 +4456,6 @@ }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -5124,8 +4464,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5138,14 +4476,10 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -5160,21 +4494,15 @@ }, "node_modules/lodash": { "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -5185,8 +4513,6 @@ }, "node_modules/lower-case": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", "license": "MIT", "dependencies": { "tslib": "^2.0.3" @@ -5194,8 +4520,6 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -5203,8 +4527,6 @@ }, "node_modules/lucide-react": { "version": "0.562.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", - "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5212,8 +4534,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5221,8 +4541,6 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -5230,8 +4548,6 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5242,8 +4558,6 @@ }, "node_modules/minimatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -5255,14 +4569,12 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mui-color-input": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/mui-color-input/-/mui-color-input-6.0.0.tgz", - "integrity": "sha512-F9n/RqPLq7fnPWeF53/qssbIHvart7clpOfE+f+dsM+3ddfawwg98+PEhzv3cdAYlJLE9zfVViVTsp/ckUUd3g==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/mui-color-input/-/mui-color-input-7.0.0.tgz", + "integrity": "sha512-b9pHCCoRjD9CDTaw2b5S+UbBhJjAsB1NzdEqjF/9NzRFD0lD8zrlMxMwvI/NqsHkNxj2PteIFh/mzo2gBqnG9g==", "license": "MIT", "dependencies": { "@ctrl/tinycolor": "^4.1.0" @@ -5270,7 +4582,7 @@ "peerDependencies": { "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", - "@mui/material": "^6.0.0", + "@mui/material": "^7.0.0", "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" @@ -5283,9 +4595,6 @@ }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -5302,15 +4611,11 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/no-case": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", "license": "MIT", "dependencies": { "lower-case": "^2.0.2", @@ -5319,20 +4624,14 @@ }, "node_modules/node-releases": { "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/numbered": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/numbered/-/numbered-1.1.0.tgz", - "integrity": "sha512-pv/ue2Odr7IfYOO0byC1KgBI10wo5YDauLhxY6/saNzAdAs0r1SotGCPzzCLNPL0xtrAwWRialLu23AAu9xO1g==", "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5340,8 +4639,6 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -5353,8 +4650,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -5363,8 +4658,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -5384,8 +4677,6 @@ }, "node_modules/object.entries": { "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { @@ -5400,8 +4691,6 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5419,8 +4708,6 @@ }, "node_modules/object.values": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -5438,8 +4725,6 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -5448,8 +4733,6 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -5466,8 +4749,6 @@ }, "node_modules/own-keys": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, "license": "MIT", "dependencies": { @@ -5484,8 +4765,6 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5500,8 +4779,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -5516,8 +4793,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -5528,8 +4803,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -5546,8 +4819,6 @@ }, "node_modules/parse-ms": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "license": "MIT", "engines": { "node": ">=18" @@ -5558,8 +4829,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -5568,8 +4837,6 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", "engines": { @@ -5578,8 +4845,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -5588,14 +4853,10 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", "engines": { "node": ">=8" @@ -5603,14 +4864,10 @@ }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -5621,8 +4878,6 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -5631,9 +4886,6 @@ }, "node_modules/postcss": { "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5660,8 +4912,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -5670,8 +4920,6 @@ }, "node_modules/prettier": { "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -5686,8 +4934,6 @@ }, "node_modules/pretty-ms": { "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", "license": "MIT", "dependencies": { "parse-ms": "^4.0.0" @@ -5701,8 +4947,6 @@ }, "node_modules/prop-types": { "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5712,20 +4956,14 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/proxy-from-env": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -5734,8 +4972,6 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -5755,14 +4991,10 @@ }, "node_modules/raf-schd": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", - "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", "license": "MIT" }, "node_modules/react": { "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -5773,9 +5005,8 @@ }, "node_modules/react-dom": { "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5786,9 +5017,8 @@ }, "node_modules/react-hook-form": { "version": "7.71.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", - "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5802,8 +5032,6 @@ }, "node_modules/react-i18next": { "version": "15.7.4", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", - "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.27.6", @@ -5828,8 +5056,6 @@ }, "node_modules/react-icons": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", "license": "MIT", "peerDependencies": { "react": "*" @@ -5837,14 +5063,10 @@ }, "node_modules/react-is": { "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -5866,8 +5088,6 @@ }, "node_modules/react-refresh": { "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", "engines": { @@ -5876,8 +5096,6 @@ }, "node_modules/react-router": { "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.2" @@ -5891,8 +5109,6 @@ }, "node_modules/react-router-dom": { "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", "dependencies": { "@remix-run/router": "1.23.2", @@ -5908,8 +5124,6 @@ }, "node_modules/react-smooth": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", "license": "MIT", "dependencies": { "fast-equals": "^5.0.1", @@ -5923,8 +5137,6 @@ }, "node_modules/react-toastify": { "version": "10.0.6", - "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz", - "integrity": "sha512-yYjp+omCDf9lhZcrZHKbSq7YMuK0zcYkDFTzfRFgTXkTFHZ1ToxwAonzA4JI5CxA91JpjFLmwEsZEgfYfOqI1A==", "license": "MIT", "dependencies": { "clsx": "^2.1.0" @@ -5936,8 +5148,6 @@ }, "node_modules/react-transition-group": { "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", @@ -5952,8 +5162,6 @@ }, "node_modules/recharts": { "version": "2.15.2", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz", - "integrity": "sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==", "license": "MIT", "dependencies": { "clsx": "^2.0.0", @@ -5975,8 +5183,6 @@ }, "node_modules/recharts-scale": { "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", "license": "MIT", "dependencies": { "decimal.js-light": "^2.4.1" @@ -5984,20 +5190,15 @@ }, "node_modules/recharts/node_modules/react-is": { "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, "node_modules/redux": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-persist": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", - "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", "license": "MIT", "peerDependencies": { "redux": ">4.0.0" @@ -6005,8 +5206,6 @@ }, "node_modules/redux-thunk": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", "license": "MIT", "peerDependencies": { "redux": "^5.0.0" @@ -6014,8 +5213,6 @@ }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { @@ -6037,8 +5234,6 @@ }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -6058,14 +5253,10 @@ }, "node_modules/reselect": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, "node_modules/resolve": { "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -6084,8 +5275,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", "engines": { "node": ">=4" @@ -6093,8 +5282,6 @@ }, "node_modules/reusify": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -6104,9 +5291,6 @@ }, "node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -6121,16 +5305,12 @@ }, "node_modules/robust-predicates": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, "node_modules/rollup": { "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6172,8 +5352,6 @@ }, "node_modules/run-parallel": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -6196,8 +5374,6 @@ }, "node_modules/safe-array-concat": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6216,8 +5392,6 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { @@ -6233,8 +5407,6 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -6251,8 +5423,6 @@ }, "node_modules/scheduler": { "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -6260,8 +5430,6 @@ }, "node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6269,8 +5437,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -6287,8 +5453,6 @@ }, "node_modules/set-function-name": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6303,8 +5467,6 @@ }, "node_modules/set-proto": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { @@ -6318,8 +5480,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -6331,8 +5491,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -6341,8 +5499,6 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { @@ -6361,8 +5517,6 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, "license": "MIT", "dependencies": { @@ -6378,8 +5532,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { @@ -6397,8 +5549,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { @@ -6417,8 +5567,6 @@ }, "node_modules/snake-case": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", "license": "MIT", "dependencies": { "dot-case": "^3.0.4", @@ -6427,8 +5575,6 @@ }, "node_modules/source-map": { "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6436,9 +5582,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6446,8 +5589,6 @@ }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6460,8 +5601,6 @@ }, "node_modules/string.prototype.matchall": { "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, "license": "MIT", "dependencies": { @@ -6488,8 +5627,6 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -6499,8 +5636,6 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { @@ -6521,8 +5656,6 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6540,8 +5673,6 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { @@ -6558,8 +5689,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -6571,8 +5700,6 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -6584,14 +5711,10 @@ }, "node_modules/stylis": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -6603,8 +5726,6 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6615,14 +5736,10 @@ }, "node_modules/svg-parser": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "license": "MIT" }, "node_modules/swr": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz", - "integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==", "license": "MIT", "dependencies": { "dequal": "^2.0.3", @@ -6634,8 +5751,6 @@ }, "node_modules/text-segmentation": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", "license": "MIT", "dependencies": { "utrie": "^1.0.2" @@ -6643,22 +5758,15 @@ }, "node_modules/text-table": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, "license": "MIT" }, "node_modules/tiny-invariant": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6673,14 +5781,10 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -6692,8 +5796,6 @@ }, "node_modules/type-fest": { "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -6705,8 +5807,6 @@ }, "node_modules/typed-array-buffer": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { @@ -6720,8 +5820,6 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { @@ -6740,8 +5838,6 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6762,8 +5858,6 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -6783,10 +5877,9 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6797,8 +5890,6 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { @@ -6816,15 +5907,11 @@ }, "node_modules/undici-types": { "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -6853,8 +5940,6 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6863,8 +5948,6 @@ }, "node_modules/use-sync-external-store": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6872,8 +5955,6 @@ }, "node_modules/utrie": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", "license": "MIT", "dependencies": { "base64-arraybuffer": "^1.0.2" @@ -6881,8 +5962,6 @@ }, "node_modules/victory-vendor": { "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", @@ -6903,10 +5982,8 @@ }, "node_modules/vite": { "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6978,8 +6055,6 @@ }, "node_modules/vite-plugin-svgr": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz", - "integrity": "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==", "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.2.0", @@ -6992,8 +6067,6 @@ }, "node_modules/void-elements": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7001,8 +6074,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -7017,8 +6088,6 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -7037,8 +6106,6 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7065,8 +6132,6 @@ }, "node_modules/which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -7084,8 +6149,6 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -7106,8 +6169,6 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -7116,30 +6177,15 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { @@ -7151,8 +6197,6 @@ }, "node_modules/zod": { "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/client/package.json b/client/package.json index 45a2a2e22..a9a8054ff 100644 --- a/client/package.json +++ b/client/package.json @@ -30,7 +30,7 @@ "i18next": "25.4.2", "joi": "17.13.3", "lucide-react": "^0.562.0", - "mui-color-input": "^6.0.0", + "mui-color-input": "^7.0.0", "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", From 57c3bf32406b282159cec22e39a5da826daa9bf3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 19:00:03 +0000 Subject: [PATCH 115/392] findBYIds only --- server/src/controllers/statusPageController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index 18428d424..3271e39a1 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -81,7 +81,7 @@ class StatusPageController { const settings = await this.settingsService.getDBSettings(); const showURL = settings.showURL; - const monitors = await this.monitorsRepository.findByIdsWithChecks(statusPage.monitors); + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); const normalizedMonitors = monitors.map((monitor) => { const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); if (!showURL) { From 853e21623e9a3c08174f8a6d5850a1fbc54e8a73 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 19:20:59 +0000 Subject: [PATCH 116/392] new status pages --- .../Components/StatusPagesTable.tsx | 109 ++++++++++++++++++ .../Pages/StatusPage/StatusPages/index.tsx | 33 ++++++ .../StatusPages/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 10 +- client/src/Types/StatusPage.ts | 24 ++++ client/src/locales/en.json | 56 +++++++++ 6 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx create mode 100644 client/src/Pages/StatusPage/StatusPages/index.tsx rename client/src/Pages/StatusPage/StatusPages/{index.jsx => old.jsx} (100%) create mode 100644 client/src/Types/StatusPage.ts diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx new file mode 100644 index 000000000..1741aeadf --- /dev/null +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -0,0 +1,109 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { Table, type Header, ValueLabel } from "@/Components/v2/design-elements"; +import { ExternalLink } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; + +import type { StatusPage } from "@/Types/StatusPage"; + +interface StatusPagesTableProps { + data: StatusPage[]; +} + +export const StatusPagesTable = ({ data }: StatusPagesTableProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + + const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => { + if (row.isPublished) { + e.stopPropagation(); + const url = `/status/uptime/public/${row.url}`; + window.open(url, "_blank", "noopener,noreferrer"); + } + }; + + const getHeaders = (): Header[] => { + return [ + { + id: "name", + content: t("pages.statusPages.table.headers.name"), + render: (row) => row.companyName, + }, + { + id: "url", + content: t("pages.statusPages.table.headers.url"), + render: (row) => { + const content = row.isPublished + ? `/${row.url}` + : t("pages.statusPages.table.unpublished"); + return ( + handleUrlClick(e, row)} + sx={{ + ...(row.isPublished && { + display: "inline-flex", + ":hover": { + cursor: "pointer", + borderBottom: 1, + }, + }), + }} + > + {content} + {row.isPublished && } + + ); + }, + }, + { + id: "type", + content: t("common.table.headers.type"), + render: (row) => row.type, + }, + { + id: "status", + content: t("common.table.headers.status"), + render: (row) => { + return ( + + ); + }, + }, + ]; + }; + + const handleRowClick = (statusPage: StatusPage) => { + navigate(`/status/uptime/${statusPage.url}`); + }; + + return ( + +
+ + ); +}; + +export default StatusPagesTable; diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx new file mode 100644 index 000000000..ad1a39b29 --- /dev/null +++ b/client/src/Pages/StatusPage/StatusPages/index.tsx @@ -0,0 +1,33 @@ +import { BasePageWithStates } from "@/Components/v2/design-elements"; +import { StatusPagesTable } from "./Components/StatusPagesTable"; +import { useGet } from "@/Hooks/UseApi"; +import type { StatusPage } from "@/Types/StatusPage"; +import { useTranslation } from "react-i18next"; + +const StatusPages = () => { + const { t } = useTranslation(); + + const { + data: statusPages, + isLoading, + error, + } = useGet("/status-page/team"); + + return ( + + + + ); +}; + +export default StatusPages; diff --git a/client/src/Pages/StatusPage/StatusPages/index.jsx b/client/src/Pages/StatusPage/StatusPages/old.jsx similarity index 100% rename from client/src/Pages/StatusPage/StatusPages/index.jsx rename to client/src/Pages/StatusPage/StatusPages/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index ecda66cd1..aec872a0f 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -37,7 +37,7 @@ import Incidents from "../Pages/Incidents/"; // Status pages import CreateStatus from "../Pages/StatusPage/Create/index.jsx"; -import StatusPages from "../Pages/StatusPage/StatusPages/index.jsx"; +import StatusPages from "../Pages/StatusPage/StatusPages"; import Status from "../Pages/StatusPage/Status/index.jsx"; import Notifications from "../Pages/Notifications"; @@ -227,7 +227,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> Date: Mon, 2 Feb 2026 19:25:30 +0000 Subject: [PATCH 117/392] remove unused --- .../Components/StatusPagesTable/index.jsx | 107 ------------------ .../StatusPages/Hooks/useStatusPagesFetch.jsx | 32 ------ .../src/Pages/StatusPage/StatusPages/old.jsx | 43 ------- client/src/locales/en.json | 2 - 4 files changed, 184 deletions(-) delete mode 100644 client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx delete mode 100644 client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx delete mode 100644 client/src/Pages/StatusPage/StatusPages/old.jsx diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx deleted file mode 100644 index 9882239bd..000000000 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import DataTable from "@/Components/v1/Table/index.jsx"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import { Stack, Typography } from "@mui/material"; -import { useTranslation } from "react-i18next"; -const StatusPagesTable = ({ data }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const navigate = useNavigate(); - const headers = [ - { - id: "name", - content: t("statusPageName"), - render: (row) => { - return row.companyName; - }, - }, - { - id: "url", - content: t("publicURL"), - onClick: (e, row) => { - if (row.isPublished) { - e.stopPropagation(); - const url = `/status/uptime/public/${row.url}`; - window.open(url, "_blank", "noopener,noreferrer"); - } - }, - render: (row) => { - const content = row.isPublished ? `/${row.url}` : "Unpublished"; - return ( - - {content} - {row.isPublished && ( - - )} - - ); - }, - }, - { - id: "type", - content: t("type"), - render: (row) => { - return row.type; - }, - }, - { - id: "status", - content: t("status"), - render: (row) => { - const status = row.isPublished ? "published" : "unpublished"; - return ( - - ); - }, - }, - ]; - - const handleRowClick = (statusPage) => { - navigate(`/status/uptime/${statusPage.url}`); - }; - - return ( - { - handleRowClick(row); - }, - }} - headers={headers} - data={data} - /> - ); -}; - -export default StatusPagesTable; diff --git a/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx b/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx deleted file mode 100644 index 9336e5d31..000000000 --- a/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useState, useEffect } from "react"; -import { networkService } from "../../../../main.jsx"; -import { useSelector } from "react-redux"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; - -const useStatusPagesFetch = () => { - const { user } = useSelector((state) => state.auth); - - const [isLoading, setIsLoading] = useState(true); - const [networkError, setNetworkError] = useState(false); - const [statusPages, setStatusPages] = useState(undefined); - - useEffect(() => { - const fetchStatusPages = async () => { - try { - const res = await networkService.getStatusPagesByTeamId(); - setStatusPages(res?.data?.data || []); - } catch (error) { - setNetworkError(true); - createToast({ - body: error.message, - }); - } finally { - setIsLoading(false); - } - }; - fetchStatusPages(); - }, [user]); - return [isLoading, networkError, statusPages]; -}; - -export { useStatusPagesFetch }; diff --git a/client/src/Pages/StatusPage/StatusPages/old.jsx b/client/src/Pages/StatusPage/StatusPages/old.jsx deleted file mode 100644 index 7137d01ad..000000000 --- a/client/src/Pages/StatusPage/StatusPages/old.jsx +++ /dev/null @@ -1,43 +0,0 @@ -// Components -import { Stack } from "@mui/material"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import MonitorCreateHeader from "@/Components/v1/MonitorCreateHeader/index.jsx"; -import StatusPagesTable from "./Components/StatusPagesTable/index.jsx"; -import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { useStatusPagesFetch } from "./Hooks/useStatusPagesFetch.jsx"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -const BREADCRUMBS = [{ name: `Status Pages`, path: "" }]; - -const StatusPages = () => { - // Utils - const theme = useTheme(); - const isAdmin = useIsAdmin(); - const [isLoading, networkError, statusPages] = useStatusPagesFetch(); - - return ( - <> - - - - - - - - - ); -}; - -export default StatusPages; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 0282e5d2d..0b2594ccf 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -534,8 +534,6 @@ "cancel": "Cancel", "close": "Close", "type": "Type", - "statusPageName": "Status page name", - "publicURL": "Public URL", "repeat": "Repeat", "edit": "Edit", "createA": "Create a", From 0fd417381660a0c6b1685ac7b6f8ef090d21610a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 20:39:16 +0000 Subject: [PATCH 118/392] status bar --- .../v2/monitors/HeaderMonitorControls.tsx | 6 +- .../Components/HeaderStatusPageControls.tsx | 87 +++++++++++++++++++ .../Status/Components/StatusBar.tsx | 62 +++++++++++++ client/src/Pages/StatusPage/Status/index.tsx | 51 +++++++++++ .../StatusPage/Status/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 18 +++- client/src/Types/StatusPage.ts | 7 ++ client/src/locales/en.json | 34 ++------ 8 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx create mode 100644 client/src/Pages/StatusPage/Status/Components/StatusBar.tsx create mode 100644 client/src/Pages/StatusPage/Status/index.tsx rename client/src/Pages/StatusPage/Status/{index.jsx => old.jsx} (100%) diff --git a/client/src/Components/v2/monitors/HeaderMonitorControls.tsx b/client/src/Components/v2/monitors/HeaderMonitorControls.tsx index d1a0d078d..6d321cbb8 100644 --- a/client/src/Components/v2/monitors/HeaderMonitorControls.tsx +++ b/client/src/Components/v2/monitors/HeaderMonitorControls.tsx @@ -95,7 +95,7 @@ export const HeaderMonitorControls = ({ await refetch(); }} > - {monitor?.isActive ? t("pause") : t("resume")} + {monitor?.isActive ? t("common.buttons.pause") : t("common.buttons.resume")} )} {isAdmin && ( @@ -105,7 +105,7 @@ export const HeaderMonitorControls = ({ startIcon={} onClick={() => navigate(`/${path}/configure/${monitor.id}`)} > - Configure + {t("common.buttons.configure")} )} @@ -155,7 +155,7 @@ export const HeaderDeleteControls = ({ await refetch(); }} > - {monitor?.isActive ? t("pause") : t("resume")} + {monitor?.isActive ? t("common.buttons.pause") : t("common.buttons.resume")} )} {isAdmin && ( diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx new file mode 100644 index 000000000..87349d703 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx @@ -0,0 +1,87 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Icon } from "@/Components/v2/design-elements"; +import { Button } from "@/Components/v2/inputs"; +import { Settings, ExternalLink } from "lucide-react"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import type { StatusPage } from "@/Types/StatusPage"; + +interface HeaderStatusPageControlsProps { + isAdmin: boolean; + statusPage: StatusPage; + isPublic?: boolean; +} +export const HeaderStatusPageControls = ({ + isAdmin, + statusPage, + isPublic = false, +}: HeaderStatusPageControlsProps) => { + const theme = useTheme(); + const navigate = useNavigate(); + const { t } = useTranslation(); + return ( + + + + {statusPage?.companyName} + + {statusPage?.isPublished && !isPublic && ( + <> + { + window.open( + `/status/uptime/public/${statusPage.url}`, + "_blank", + "noopener,noreferrer" + ); + }} + sx={{ + borderBottom: 1, + borderColor: "transparent", + ":hover": { + cursor: "pointer", + borderBottom: 1, + }, + }} + > + {t("publicLink")} + + + + + + )} + + {isAdmin && ( + + )} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx new file mode 100644 index 000000000..c5aca4754 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx @@ -0,0 +1,62 @@ +import { AlertTriangle, CircleCheck } from "lucide-react"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; + +import { useTranslation } from "react-i18next"; +import type { Theme } from "@mui/material"; +import { useTheme } from "@mui/material"; +import type { Monitor } from "@/Types/Monitor"; + +const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => { + const monitorsStatus: Record = { + icon: , + }; + + if (monitors.every((monitor) => monitor.status === true)) { + monitorsStatus.msg = t("pages.statusPages.statusBar.allUp"); + monitorsStatus.color = theme.palette.success.main; + monitorsStatus.icon = ; + } + + if (monitors.every((monitor) => monitor.status === false)) { + monitorsStatus.msg = t("pages.statusPages.statusBar.allDown"); + monitorsStatus.color = theme.palette.error.main; + } + + if (monitors.some((monitor) => monitor.status === false)) { + monitorsStatus.msg = t("pages.statusPages.statusBar.degraded"); + monitorsStatus.color = theme.palette.warning.main; + } + + // Paused or unknown + if (monitors.some((monitor) => typeof monitor.status === "undefined")) { + monitorsStatus.msg = t("pages.statusPages.statusBar.unknown"); + monitorsStatus.color = theme.palette.warning.main; + } + return monitorsStatus; +}; + +interface StatusBarProps { + monitors: Monitor[]; +} + +export const StatusBar = ({ monitors }: StatusBarProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const monitorsStatus = getMonitorStatus(monitors, theme, t); + + return ( + + {monitorsStatus.icon} + {monitorsStatus.msg} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx new file mode 100644 index 000000000..d35503011 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -0,0 +1,51 @@ +import { BasePage } from "@/Components/v2/design-elements"; +import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar"; +import Typography from "@mui/material/Typography"; + +import { useTranslation } from "react-i18next"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import { useLocation, useParams } from "react-router-dom"; +import { useGet } from "@/Hooks/UseApi"; +import type { StatusPageResponse } from "@/Types/StatusPage"; +import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls"; + +const StatusPageView = () => { + const { t } = useTranslation(); + const { url } = useParams(); + const isAdmin = useIsAdmin(); + const location = useLocation(); + const isPublic = location.pathname.startsWith("/status/uptime/public"); + + const apiUrl = url ? `/status-page/${url}?type=uptime` : null; + + const { data, isLoading, error } = useGet(apiUrl); + + const statusPage = data?.statusPage; + const monitors = data?.monitors ?? []; + + if (!statusPage || !monitors) { + return null; + } + + if (monitors.length === 0) { + return "poo"; + } + + return ( + + + {t("statusPageStatusServiceStatus")} + +
 {JSON.stringify(data, null, 2)}
+
+ ); +}; + +export default StatusPageView; diff --git a/client/src/Pages/StatusPage/Status/index.jsx b/client/src/Pages/StatusPage/Status/old.jsx similarity index 100% rename from client/src/Pages/StatusPage/Status/index.jsx rename to client/src/Pages/StatusPage/Status/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index aec872a0f..bbba8eac0 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -38,7 +38,7 @@ import Incidents from "../Pages/Incidents/"; // Status pages import CreateStatus from "../Pages/StatusPage/Create/index.jsx"; import StatusPages from "../Pages/StatusPage/StatusPages"; -import Status from "../Pages/StatusPage/Status/index.jsx"; +import Status from "../Pages/StatusPage/Status"; import Notifications from "../Pages/Notifications"; import CreateNotifications from "../Pages/Notifications/create"; @@ -238,7 +238,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> { /> } + element={ + <> + + + + + } /> Date: Mon, 2 Feb 2026 20:42:27 +0000 Subject: [PATCH 119/392] monitors lis tskeleton --- .../StatusPage/Status/Components/MonitorsList.tsx | 11 +++++++++++ client/src/Pages/StatusPage/Status/index.tsx | 5 +++++ 2 files changed, 16 insertions(+) create mode 100644 client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx new file mode 100644 index 000000000..8425a2256 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx @@ -0,0 +1,11 @@ +import type { Monitor } from "@/Types/Monitor"; +import type { StatusPage } from "@/Types/StatusPage"; + +interface MontorsListProps { + stautsPage: StatusPage; + monitors: Monitor[]; +} + +export const MonitorsList = ({ stautsPage, monitors }: MontorsListProps) => { + return
Monitors List
; +}; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index d35503011..411234c15 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -1,5 +1,6 @@ import { BasePage } from "@/Components/v2/design-elements"; import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar"; +import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList"; import Typography from "@mui/material/Typography"; import { useTranslation } from "react-i18next"; @@ -43,6 +44,10 @@ const StatusPageView = () => { /> {t("statusPageStatusServiceStatus")} +
 {JSON.stringify(data, null, 2)}
); From 56ead03416be76bb10780e5d57e40345e0f084a4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 21:51:06 +0000 Subject: [PATCH 120/392] breadcrumbs --- .../v2/design-elements/BasePage.tsx | 2 +- .../v2/design-elements/Breadcrumb.tsx | 53 ++++++++- client/src/Components/v2/inputs/Switch.tsx | 14 ++- .../Components/HeaderStatusPageControls.tsx | 2 +- .../Status/Components/MonitorsList.tsx | 103 +++++++++++++++++- client/src/Pages/StatusPage/Status/index.tsx | 18 +-- client/src/Utils/MonitorUtils.ts | 7 ++ client/src/locales/en.json | 4 + 8 files changed, 186 insertions(+), 17 deletions(-) diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index de20fc005..e5091091e 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/v2/design-elements/BasePage.tsx @@ -91,7 +91,7 @@ export const BasePage = ({ spacing={theme.spacing(10)} {...props} > - + {children} ); diff --git a/client/src/Components/v2/design-elements/Breadcrumb.tsx b/client/src/Components/v2/design-elements/Breadcrumb.tsx index e0a310c9f..8ecfd6ec6 100644 --- a/client/src/Components/v2/design-elements/Breadcrumb.tsx +++ b/client/src/Components/v2/design-elements/Breadcrumb.tsx @@ -11,13 +11,64 @@ const isId = (segment: string): boolean => { const actionSegments = ["create", "configure"]; -export const Breadcrumb = () => { +export const Breadcrumb = ({ + breadcrumbOverride, +}: { + breadcrumbOverride?: string[] | undefined; +}) => { const { t } = useTranslation(); const theme = useTheme(); const location = useLocation(); + // If override is an empty array, hide entirely + if (breadcrumbOverride !== undefined && breadcrumbOverride.length === 0) { + return null; + } + + // If override has items, render them directly + if (breadcrumbOverride !== undefined && breadcrumbOverride.length > 0) { + return ( + + } + sx={{ + fontSize: "14px", + marginBottom: theme.spacing(6), + "& .MuiBreadcrumbs-separator": { + color: theme.palette.text.secondary, + }, + }} + > + {breadcrumbOverride.map((item, index) => { + const isLast = index === breadcrumbOverride.length - 1; + return ( + + {item} + + ); + })} + + ); + } + + // Default behavior: use location pathname const segments = location.pathname.split("/").filter((x) => x); + if (segments.length === 0) { + return null; + } + // Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create" const basePage = segments[0] || t("common.breadcrumbs.home"); diff --git a/client/src/Components/v2/inputs/Switch.tsx b/client/src/Components/v2/inputs/Switch.tsx index b8ae8c1ec..05be73a24 100644 --- a/client/src/Components/v2/inputs/Switch.tsx +++ b/client/src/Components/v2/inputs/Switch.tsx @@ -3,8 +3,12 @@ import Switch from "@mui/material/Switch"; import type { SwitchProps } from "@mui/material/Switch"; import { useTheme } from "@mui/material/styles"; -export const SwitchComponent = forwardRef( - function SwitchComponent({ sx, ...props }, ref) { +interface SwitchComponentProps extends SwitchProps { + dualOption?: boolean; +} + +export const SwitchComponent = forwardRef( + function SwitchComponent({ sx, dualOption = false, ...props }, ref) { const theme = useTheme(); const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; @@ -28,6 +32,12 @@ export const SwitchComponent = forwardRef( }, }, }, + ...(dualOption && { + "& .MuiSwitch-track": { + backgroundColor: theme.palette.primary.main, + opacity: 1, + }, + }), }, ...additionalSx, ]} diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx index 87349d703..382bf08e4 100644 --- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx +++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx @@ -72,7 +72,7 @@ export const HeaderStatusPageControls = ({ )} - {isAdmin && ( + {isAdmin && !isPublic && ( From 85190992c705334aad52bbdc40710460ea4c197f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 22:02:14 +0000 Subject: [PATCH 123/392] format --- .../StatusPage/Status/Components/MonitorsList.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx index fca54ae4f..d559e7c82 100644 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx @@ -68,7 +68,16 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { mb={statusPage.showCharts !== false ? theme.spacing(4) : 0} > - {monitor.name} + + {monitor.name} + {showURL && ( Date: Mon, 2 Feb 2026 22:15:57 +0000 Subject: [PATCH 124/392] cleanup --- client/src/Pages/StatusPage/Create/index.jsx | 11 +- .../Status/Components/AdminLink/index.jsx | 36 ---- .../Components/ControlsHeader/index.jsx | 141 --------------- .../Status/Components/MonitorsList/index.jsx | 69 -------- .../Status/Components/Skeleton/index.jsx | 14 -- .../Status/Components/StatusBar/index.jsx | 77 -------- client/src/Pages/StatusPage/Status/old.jsx | 166 ------------------ .../Pages/StatusPage/StatusPages/index.tsx | 8 + client/src/locales/en.json | 4 - 9 files changed, 13 insertions(+), 513 deletions(-) delete mode 100644 client/src/Pages/StatusPage/Status/Components/AdminLink/index.jsx delete mode 100644 client/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx delete mode 100644 client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx delete mode 100644 client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx delete mode 100644 client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx delete mode 100644 client/src/Pages/StatusPage/Status/old.jsx diff --git a/client/src/Pages/StatusPage/Create/index.jsx b/client/src/Pages/StatusPage/Create/index.jsx index 4eb171232..c5aee4b0a 100644 --- a/client/src/Pages/StatusPage/Create/index.jsx +++ b/client/src/Pages/StatusPage/Create/index.jsx @@ -17,7 +17,6 @@ import { useNavigate } from "react-router-dom"; import { useStatusPageFetch } from "../Status/Hooks/useStatusPageFetch.jsx"; import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { useStatusPageDelete } from "../Status/Hooks/useStatusPageDelete.jsx"; //Constants const ERROR_TAB_MAPPING = [ ["companyName", "url", "timezone", "color", "isPublished", "logo"], @@ -60,7 +59,7 @@ const CreateStatusPage = () => { const [createStatusPage] = useCreateStatusPage(isCreate); const [statusPage, statusPageMonitors, statusPageIsLoading, , fetchStatusPage] = useStatusPageFetch(isCreate, url); - const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url); + // const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url); // Handlers const handleFormChange = (e) => { @@ -133,7 +132,7 @@ const CreateStatusPage = () => { const handleDelete = async () => { setIsDeleteOpen(false); // Start deletion process but don't wait for it - deleteStatusPage(); + // deleteStatusPage(); // Immediately navigate away to prevent additional fetches for the deleted page navigate("/status"); }; @@ -252,7 +251,7 @@ const CreateStatusPage = () => { justifyContent="flex-end" > - - - ); -}; - -Controls.propTypes = { - type: PropTypes.string, - url: PropTypes.string, -}; - -const ControlsHeader = ({ statusPage, isPublic, url, type = "uptime" }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const publicUrl = `/status/uptime/public/${url}`; - - return ( - - - - - {statusPage?.companyName} - - {statusPage?.isPublished && !isPublic && ( - { - window.open(publicUrl, "_blank", "noopener,noreferrer"); - }} - sx={{ - display: "inline-flex", - ":hover": { - cursor: "pointer", - borderBottom: 1, - }, - }} - > - {t("publicLink")} - - - )} - - - {isPublic && } - - ); -}; - -ControlsHeader.propTypes = { - url: PropTypes.string, - statusPage: PropTypes.object, - isPublic: PropTypes.bool, - type: PropTypes.string, -}; - -export default ControlsHeader; diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx deleted file mode 100644 index 33f771233..000000000 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx +++ /dev/null @@ -1,69 +0,0 @@ -// Components -import { Stack, Box } from "@mui/material"; -import Host from "@/Components/v1/Host/index.jsx"; -import StatusPageBarChart from "@/Components/v1/Charts/StatusPageBarChart/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; - -//Utils -import { useTheme } from "@mui/material/styles"; -import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js"; -import PropTypes from "prop-types"; - -import { useSelector } from "react-redux"; - -const MonitorsList = ({ - isLoading = false, - shouldRender = true, - monitors = [], - statusPage = {}, -}) => { - const theme = useTheme(); - const { determineState } = useMonitorUtils(); - - const { showURL } = useSelector((state) => state.ui); - - return ( - <> - {monitors?.map((monitor) => { - const status = determineState(monitor); - return ( - - - - {statusPage.showCharts !== false && ( - - - - )} - - - ); - })} - - ); -}; - -MonitorsList.propTypes = { - monitors: PropTypes.array.isRequired, - statusPage: PropTypes.object, -}; - -export default MonitorsList; diff --git a/client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx b/client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx deleted file mode 100644 index 482c490fb..000000000 --- a/client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx b/client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx deleted file mode 100644 index 7b9460068..000000000 --- a/client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx +++ /dev/null @@ -1,77 +0,0 @@ -// Components -import { Stack, Typography } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; - -// Utils -import { useTheme } from "@mui/material/styles"; -import PropTypes from "prop-types"; - -const getMonitorStatus = (monitors, theme) => { - const monitorsStatus = { - icon: ( - - ), - }; - if (monitors.every((monitor) => monitor.status === true)) { - monitorsStatus.msg = "All systems operational"; - monitorsStatus.color = theme.palette.success.lowContrast; - monitorsStatus.icon = ( - - ); - } - - if (monitors.every((monitor) => monitor.status === false)) { - monitorsStatus.msg = "All systems down"; - monitorsStatus.color = theme.palette.error.lowContrast; - } - - if (monitors.some((monitor) => monitor.status === false)) { - monitorsStatus.msg = "Degraded performance"; - monitorsStatus.color = theme.palette.warning.lowContrast; - } - - // Paused or unknown - if (monitors.some((monitor) => typeof monitor.status === "undefined")) { - monitorsStatus.msg = "Unknown status"; - monitorsStatus.color = theme.palette.warning.lowContrast; - } - return monitorsStatus; -}; - -const StatusBar = ({ monitors }) => { - const theme = useTheme(); - - if (typeof monitors === "undefined") return; - - const monitorsStatus = getMonitorStatus(monitors, theme); - return ( - - {monitorsStatus.icon} - {/* CAIO_REVIEW */} - {monitorsStatus.msg} - - ); -}; - -export default StatusBar; - -StatusBar.propTypes = { - status: PropTypes.object, -}; diff --git a/client/src/Pages/StatusPage/Status/old.jsx b/client/src/Pages/StatusPage/Status/old.jsx deleted file mode 100644 index adcd9c2c7..000000000 --- a/client/src/Pages/StatusPage/Status/old.jsx +++ /dev/null @@ -1,166 +0,0 @@ -// Components -import { Typography, Stack } from "@mui/material"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import AdminLink from "./Components/AdminLink/index.jsx"; -import ControlsHeader from "./Components/ControlsHeader/index.jsx"; -import SkeletonLayout from "./Components/Skeleton/index.jsx"; -import StatusBar from "./Components/StatusBar/index.jsx"; -import MonitorsList from "./Components/MonitorsList/index.jsx"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import TextLink from "@/Components/v1/TextLink/index.jsx"; - -// Utils -import { useStatusPageFetch } from "./Hooks/useStatusPageFetch.jsx"; -import { useTheme } from "@emotion/react"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useLocation } from "react-router-dom"; -import { useParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - -const PublicStatus = () => { - const { url } = useParams(); - - // Utils - const theme = useTheme(); - const { t } = useTranslation(); - const location = useLocation(); - const isAdmin = useIsAdmin(); - - const [statusPage, monitors, isLoading, networkError] = useStatusPageFetch(false, url); - - // Breadcrumbs - const crumbs = [ - { name: t("statusBreadCrumbsStatusPages"), path: "/status" }, - { name: t("statusBreadCrumbsDetails"), path: `/status/uptime/${statusPage?.url}` }, - ]; - - // Setup - let sx = { paddingLeft: theme.spacing(20), paddingRight: theme.spacing(20) }; - let link = undefined; - const isPublic = location.pathname.startsWith("/status/uptime/public"); - // Public status page - if (isPublic && statusPage && statusPage.showAdminLoginLink === true) { - sx = { - paddingTop: theme.spacing(20), - paddingLeft: "20vw", - paddingRight: "20vw", - }; - link = ; - } - - // Loading - if (isLoading) { - return ; - } - - if (monitors?.length === 0) { - return ( - - - {"Theres nothing here yet"} - - {isAdmin && ( - - )} - - ); - } - - // Error fetching data - if (networkError === true) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - - // Public status page fallback - if (!isLoading && typeof statusPage === "undefined" && isPublic) { - return ( - - - - {t("statusPageStatus")} - - {t("statusPageStatusContactAdmin")} - - - ); - } - - // Finished loading, but status page is not public - if (!isLoading && isPublic && statusPage.isPublished === false) { - return ( - - - - {t("statusPageStatusNotPublic")} - - {t("statusPageStatusContactAdmin")} - - - ); - } - - // Status page doesn't exist - if (!isLoading && typeof statusPage === "undefined") { - return ( - - - {t("statusPageStatusNoPage")} - - {t("statusPageStatusContactAdmin")} - - ); - } - - return ( - - {!isPublic && } - - {t("statusPageStatusServiceStatus")} - - - {link} - - ); -}; - -export default PublicStatus; diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx index ad1a39b29..6a34e587d 100644 --- a/client/src/Pages/StatusPage/StatusPages/index.tsx +++ b/client/src/Pages/StatusPage/StatusPages/index.tsx @@ -3,6 +3,8 @@ import { StatusPagesTable } from "./Components/StatusPagesTable"; import { useGet } from "@/Hooks/UseApi"; import type { StatusPage } from "@/Types/StatusPage"; import { useTranslation } from "react-i18next"; +import { HeaderCreate } from "@/Components/v2/common"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; const StatusPages = () => { const { t } = useTranslation(); @@ -13,6 +15,8 @@ const StatusPages = () => { error, } = useGet("/status-page/team"); + const isAdmin = useIsAdmin(); + return ( { actionButtonText={t("pages.statusPages.fallback.actionButton")} actionLink="/status/uptime/create" > + ); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index affb70a73..88f1ffc7f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -546,10 +546,6 @@ "showCharts": "Show charts", "showUptimePercentage": "Show uptime percentage", "removeLogo": "Remove Logo", - "statusPageStatus": "A public status page is not set up.", - "statusPageStatusContactAdmin": "Please contact to your administrator", - "statusPageStatusNotPublic": "This status page is not public.", - "statusPageStatusNoPage": "There's no status page here.", "statusPageStatusServiceStatus": "Service status", "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", From 69f95723f1546a48b4ed60a0f54a9637ab3f2151 Mon Sep 17 00:00:00 2001 From: llpingll <94099281+llpingll@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:19:27 +0000 Subject: [PATCH 125/392] Combine useNavigate and useLocation into one import --- client/src/Pages/Account/components/PasswordPanel.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/Pages/Account/components/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx index 7deb6dc8c..1ac070035 100644 --- a/client/src/Pages/Account/components/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -11,10 +11,9 @@ import { useDispatch, useSelector } from "react-redux"; import { createToast } from "../../../Utils/toastUtils.jsx"; import { getTouchedFieldErrors } from "../../../Validation/error.js"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { clearAuthState } from "../../../Features/Auth/authSlice.js"; import TextLink from "@/Components/v1/TextLink/index.jsx"; -import { useLocation } from "react-router-dom"; const defaultPasswordsState = { password: "", From f2363a7a09ba66e175ba906c0324047b981775f7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 23:01:35 +0000 Subject: [PATCH 126/392] form initial commit --- .../src/Components/v2/inputs/ImageUpload.tsx | 164 ++++++++++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Pages/StatusPage/Create/index.tsx | 46 +++++ .../StatusPage/Create/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 10 +- client/src/locales/en.json | 46 +++++ 6 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 client/src/Components/v2/inputs/ImageUpload.tsx create mode 100644 client/src/Pages/StatusPage/Create/index.tsx rename client/src/Pages/StatusPage/Create/{index.jsx => old.jsx} (100%) diff --git a/client/src/Components/v2/inputs/ImageUpload.tsx b/client/src/Components/v2/inputs/ImageUpload.tsx new file mode 100644 index 000000000..6c9fc1783 --- /dev/null +++ b/client/src/Components/v2/inputs/ImageUpload.tsx @@ -0,0 +1,164 @@ +import { Box, Stack, Typography, IconButton } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { Upload, X } from "lucide-react"; +import { useState, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +interface FileObject { + src: string; + name: string; + file: File; +} + +interface ImageUploadProps { + src?: string; + onChange?: (file: FileObject | undefined) => void; + maxSize?: number; + accept?: string[]; + error?: string; +} + +export const ImageUpload = ({ + src, + onChange, + maxSize = 3 * 1024 * 1024, + accept = ["jpg", "jpeg", "png"], + error, +}: ImageUploadProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [localError, setLocalError] = useState(null); + const [preview, setPreview] = useState(null); + + const handleFile = useCallback( + (file: File | undefined) => { + if (!file) return; + const isValidType = accept.some((type) => file.type.includes(type)); + const isValidSize = file.size <= maxSize; + if (!isValidType) { + setLocalError(t("common.errors.invalidFileFormat")); + return; + } + if (!isValidSize) { + setLocalError(t("common.errors.invalidFileSize")); + return; + } + setLocalError(null); + const fileObj: FileObject = { + src: URL.createObjectURL(file), + name: file.name, + file, + }; + setPreview(fileObj); + onChange?.(fileObj); + }, + [maxSize, accept, onChange, t] + ); + + const handleClear = () => { + setPreview(null); + setLocalError(null); + onChange?.(undefined); + if (inputRef.current) inputRef.current.value = ""; + }; + + const displaySrc = src || preview?.src; + const displayError = localError || error; + + return ( + + {displaySrc ? ( + + + + + + + ) : ( + { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + handleFile(e.dataTransfer.files?.[0]); + }} + alignItems="center" + justifyContent="center" + gap={1} + sx={{ + position: "relative", + width: "100%", + minHeight: 150, + border: "2px dashed", + borderRadius: 1, + borderColor: isDragging ? theme.palette.primary.main : theme.palette.divider, + backgroundColor: isDragging ? theme.palette.action.hover : "transparent", + transition: "0.2s", + cursor: "pointer", + "&:hover": { + borderColor: theme.palette.primary.main, + backgroundColor: theme.palette.action.hover, + }, + }} + > + `.${ext}`).join(",")} + onChange={(e) => handleFile(e.target.files?.[0])} + style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }} + /> + + + + {t("common.imageUpload.clickToUpload")} + {" "} + {t("common.imageUpload.orDragAndDrop")} + + + {accept.join(", ").toUpperCase()} • {t("common.imageUpload.maxSize")}{" "} + {Math.round(maxSize / 1024 / 1024)}MB + + + )} + {displayError && ( + + {displayError} + + )} + + ); +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 1618daf76..ad7c984bd 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -12,3 +12,4 @@ export { DialogInput as Dialog } from "./Dialog"; export * from "./Radio"; export * from "./Switch"; export * from "./Slider"; +export * from "./ImageUpload"; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx new file mode 100644 index 000000000..25c4d03f8 --- /dev/null +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -0,0 +1,46 @@ +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { ImageUpload, SwitchComponent } from "@/Components/v2/inputs"; + +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; + +const CreateStatusPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + + field.onChange(e.target.checked)} + /> + + {t("pages.statusPages.form.access.option.published.name")} + + + } + /> + + + + } + /> + + ); +}; + +export default CreateStatusPage; diff --git a/client/src/Pages/StatusPage/Create/index.jsx b/client/src/Pages/StatusPage/Create/old.jsx similarity index 100% rename from client/src/Pages/StatusPage/Create/index.jsx rename to client/src/Pages/StatusPage/Create/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index bbba8eac0..0b24ac03e 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -36,7 +36,7 @@ import Checks from "../Pages/Checks/index"; import Incidents from "../Pages/Incidents/"; // Status pages -import CreateStatus from "../Pages/StatusPage/Create/index.jsx"; +import CreateStatus from "../Pages/StatusPage/Create/"; import StatusPages from "../Pages/StatusPage/StatusPages"; import Status from "../Pages/StatusPage/Status"; @@ -249,7 +249,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> Date: Mon, 2 Feb 2026 23:46:15 +0000 Subject: [PATCH 127/392] monitors --- client/src/Hooks/useStatusPageForm.ts | 27 +++ client/src/Pages/StatusPage/Create/index.tsx | 239 ++++++++++++++++++- client/src/Validation/statusPage.ts | 33 +++ client/src/locales/en.json | 35 +++ 4 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 client/src/Hooks/useStatusPageForm.ts create mode 100644 client/src/Validation/statusPage.ts diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts new file mode 100644 index 000000000..0ea128e0d --- /dev/null +++ b/client/src/Hooks/useStatusPageForm.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage"; +import type { StatusPage } from "@/Types/StatusPage"; + +interface UseStatusPageFormOptions { + data?: StatusPage | null; +} + +export const useStatusPageForm = ({ data = null }: UseStatusPageFormOptions = {}) => { + return useMemo(() => { + const defaults: StatusPageFormData = { + companyName: data?.companyName || "", + url: data?.url || "", + timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + color: data?.color || "#4169E1", + monitors: data?.monitors || [], + isPublished: data?.isPublished ?? false, + showCharts: data?.showCharts ?? true, + showUptimePercentage: data?.showUptimePercentage ?? true, + showAdminLoginLink: data?.showAdminLoginLink ?? false, + customCSS: data?.customCSS || "", + logo: data?.logo || null, + }; + + return { schema: statusPageSchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 25c4d03f8..7e4e94a9d 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,16 +1,68 @@ import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { ImageUpload, SwitchComponent } from "@/Components/v2/inputs"; +import IconButton from "@mui/material/IconButton"; +import Divider from "@mui/material/Divider"; +import { Trash2 } from "lucide-react"; +import { + ImageUpload, + SwitchComponent, + Button, + TextField, + Autocomplete, +} from "@/Components/v2/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; +import { useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; +import type { StatusPageFormData } from "@/Validation/statusPage"; +import { useGet } from "@/Hooks/UseApi"; +import type { Monitor } from "@/Types/Monitor"; +import timezones from "@/Utils/timezones.json"; + +interface TimezoneOption { + _id: string; + name: string; +} const CreateStatusPage = () => { const theme = useTheme(); const { t } = useTranslation(); + + const { schema, defaults } = useStatusPageForm(); + + const { data: monitorsResponse } = useGet( + "/monitors/team?type=http&type=ping&type=port&type=docker" + ); + const monitors = monitorsResponse ?? []; + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { control, reset, handleSubmit } = form; + + useEffect(() => { + reset(defaults); + }, [defaults, reset]); + + const onError = (errors: any) => { + console.log(errors); + }; + + const onSubmit = (data: StatusPageFormData) => { + console.log("Form Data Submitted: ", data); + }; + return ( - + { alignItems="center" spacing={theme.spacing(2)} > - field.onChange(e.target.checked)} + ( + field.onChange(e.target.checked)} + /> + )} /> {t("pages.statusPages.form.access.option.published.name")} @@ -30,15 +88,184 @@ const CreateStatusPage = () => { } /> + + ( + + )} + /> + ( + + )} + /> + + } + /> + { + const selectedMonitors = monitors.filter((m) => field.value.includes(m.id)); + return ( + + option.name} + value={selectedMonitors} + onChange={(_, newValue: Monitor[]) => { + field.onChange(newValue.map((m) => m.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + fieldLabel={t( + "pages.statusPages.form.monitors.option.monitors.label" + )} + renderInput={(params) => ( + + )} + /> + {selectedMonitors.length > 0 && ( + + {selectedMonitors.map((monitor, index) => ( + + {monitor.name} + { + field.onChange( + field.value.filter((id: string) => id !== monitor.id) + ); + }} + aria-label="Remove monitor" + > + + + {index < selectedMonitors.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + ( + option.name} + value={ + timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null + } + onChange={(_, newValue: TimezoneOption | null) => { + field.onChange(newValue?._id ?? ""); + }} + fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} + renderInput={(params) => ( + + )} + /> + )} + /> + } + /> - + ( + { + if (file) { + field.onChange({ + data: file.src, + contentType: file.file.type, + }); + } else { + field.onChange(null); + } + }} + /> + )} + /> } /> + + + ); }; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts new file mode 100644 index 000000000..cb148e633 --- /dev/null +++ b/client/src/Validation/statusPage.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export const statusPageSchema = z.object({ + companyName: z + .string() + .min(1, "Company name is required") + .max(100, "Company name must be at most 100 characters"), + url: z + .string() + .min(1, "URL is required") + .max(50, "URL must be at most 50 characters") + .regex( + /^[a-z0-9-]+$/, + "URL can only contain lowercase letters, numbers, and hyphens" + ), + timezone: z.string().optional(), + color: z.string().min(1, "Color is required"), + monitors: z.array(z.string()), + isPublished: z.boolean(), + showCharts: z.boolean(), + showUptimePercentage: z.boolean(), + showAdminLoginLink: z.boolean(), + customCSS: z.string().optional(), + logo: z + .object({ + data: z.string(), + contentType: z.string(), + }) + .nullable() + .optional(), +}); + +export type StatusPageFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2d1819696..2d3eb614c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -508,6 +508,41 @@ "title": "Appearance", "description": "Define the default look and feel of your public status page", "option": {} + }, + "basicInfo": { + "title": "Basic information", + "description": "Name and address of your status page", + "option": { + "name": { + "label": "Name", + "placeholder": "My status page" + }, + "url": { + "label": "URL", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "Monitors", + "description": "Select which monitors to display on your status page", + "noMonitors": "No monitors available. Create uptime monitors first.", + "option": { + "monitors": { + "label": "Monitors", + "placeholder": "Select monitors..." + } + } + }, + "timezone": { + "title": "Timezone", + "description": "Select the timezone for your status page", + "option": { + "timezone": { + "label": "Timezone", + "placeholder": "Search timezones..." + } + } } }, "settings": { From ae7524f507e2b7199a40c676f3e7891fa20056eb Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Feb 2026 23:57:14 +0000 Subject: [PATCH 128/392] monitors details --- client/src/Hooks/useStatusPageForm.ts | 4 +- client/src/Pages/StatusPage/Create/index.tsx | 101 +++++++++++++------ client/src/Validation/statusPage.ts | 2 +- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 0ea128e0d..69401138e 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -6,11 +6,13 @@ interface UseStatusPageFormOptions { data?: StatusPage | null; } +const generateDefaultUrl = () => Math.floor(Math.random() * 1000000).toString(); + export const useStatusPageForm = ({ data = null }: UseStatusPageFormOptions = {}) => { return useMemo(() => { const defaults: StatusPageFormData = { companyName: data?.companyName || "", - url: data?.url || "", + url: data?.url || generateDefaultUrl(), timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, color: data?.color || "#4169E1", monitors: data?.monitors || [], diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 7e4e94a9d..ec5245e41 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -2,8 +2,9 @@ import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; -import Divider from "@mui/material/Divider"; -import { Trash2 } from "lucide-react"; +import { Trash2, GripVertical } from "lucide-react"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import type { DropResult } from "@hello-pangea/dnd"; import { ImageUpload, SwitchComponent, @@ -135,8 +136,11 @@ const CreateStatusPage = () => { { - const selectedMonitors = monitors.filter((m) => field.value.includes(m.id)); + render={({ field, fieldState }) => { + // Preserve order from field.value (IDs) when building selectedMonitors + const selectedMonitors = field.value + .map((id: string) => monitors.find((m) => m.id === id)) + .filter((m): m is Monitor => m !== undefined); return ( { placeholder={t( "pages.statusPages.form.monitors.option.monitors.placeholder" )} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} /> )} /> {selectedMonitors.length > 0 && ( - { + if (!result.destination) return; + const reordered = Array.from(field.value); + const [removed] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, removed); + field.onChange(reordered); + }} > - {selectedMonitors.map((monitor, index) => ( - - {monitor.name} - { - field.onChange( - field.value.filter((id: string) => id !== monitor.id) - ); - }} - aria-label="Remove monitor" + + {(provided) => ( + - - - {index < selectedMonitors.length - 1 && } - - ))} - + {selectedMonitors.map((monitor, index) => ( + + {(provided) => ( + + + {monitor.name} + { + field.onChange( + field.value.filter( + (id: string) => id !== monitor.id + ) + ); + }} + aria-label="Remove monitor" + > + + + + )} + + ))} + {provided.placeholder} + + )} + + )} ); diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index cb148e633..fbe6a0690 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -15,7 +15,7 @@ export const statusPageSchema = z.object({ ), timezone: z.string().optional(), color: z.string().min(1, "Color is required"), - monitors: z.array(z.string()), + monitors: z.array(z.string()).min(1, "At least one monitor is required"), isPublished: z.boolean(), showCharts: z.boolean(), showUptimePercentage: z.boolean(), From 090d0025784d85305e8cd81eb8758512f1ea849b Mon Sep 17 00:00:00 2001 From: llpingll <94099281+llpingll@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:44:15 +0000 Subject: [PATCH 129/392] Add comment to PasswordPanel to encourage additional error handling and remove helperText comments | Remove explicit special characters from en.json --- client/src/Pages/Account/components/PasswordPanel.jsx | 4 +--- client/src/locales/en.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Account/components/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx index 1ac070035..841dca628 100644 --- a/client/src/Pages/Account/components/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -123,6 +123,7 @@ const PasswordPanel = () => { confirm: "", }); } else { + // TODO: Check for other errors? createToast({ body: t( "passwordPanel.passwordInputIncorrect", @@ -185,7 +186,6 @@ const PasswordPanel = () => { onChange={handleChange} error={errors[idToName["edit-current-password"]] ? true : false} helperText={ - //Only call t() if there is an error to prevent unnecessary localization calls errors[idToName["edit-current-password"]] ? t(errors[idToName["edit-current-password"]]) : "" @@ -216,7 +216,6 @@ const PasswordPanel = () => { onChange={handleChange} error={errors[idToName["edit-new-password"]] ? true : false} helperText={ - //Only call t() if there is an error to prevent unnecessary localization calls errors[idToName["edit-new-password"]] ? t(errors[idToName["edit-new-password"]]) : "" @@ -247,7 +246,6 @@ const PasswordPanel = () => { onChange={handleChange} error={errors[idToName["edit-confirm-password"]] ? true : false} helperText={ - //Only call t() if there is an error to prevent unnecessary localization calls errors[idToName["edit-confirm-password"]] ? t(errors[idToName["edit-confirm-password"]]) : "" diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 52ec0f3cc..419c38608 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -557,7 +557,7 @@ "newPassword": "New password", "enterNewPassword": "Enter your new password", "confirmNewPassword": "Confirm new password", - "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`).", + "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character.", "resetPasswordText": "Reset password", "sessionExpired": "Your session has expired, please log in again to continue." }, From 63e42b727638ed8734ef9dba1ae90f12fc1ca570 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Tue, 3 Feb 2026 22:36:19 +0530 Subject: [PATCH 130/392] Fix: Send Email Invite --- .../v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx | 5 ++--- server/src/controllers/inviteController.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx b/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx index 1663ab3b5..7219ba821 100644 --- a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx +++ b/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx @@ -8,9 +8,8 @@ import type { TooltipProps } from "recharts"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; -interface HistogramPageSpeedScoresTooltipProps extends Partial< - TooltipProps -> { +interface HistogramPageSpeedScoresTooltipProps + extends Partial> { config: Record; } export const HistogramPageSpeedScoresTooltip = ({ diff --git a/server/src/controllers/inviteController.ts b/server/src/controllers/inviteController.ts index c3d63aa80..664d84d98 100755 --- a/server/src/controllers/inviteController.ts +++ b/server/src/controllers/inviteController.ts @@ -37,7 +37,7 @@ class InviteController { await inviteBodyValidation.validateAsync(inviteRequest); const inviteToken = await this.inviteService.sendInviteEmail({ - inviteRequest, + invite: inviteRequest, firstName: req?.user?.firstName, }); return res.status(200).json({ From 3b25c50247a6e9f6068d0f6de9d7b18af5cdccc1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 17:41:31 +0000 Subject: [PATCH 131/392] Fix validation --- server/src/validation/joi.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index ea151424c..4ce471c79 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -162,12 +162,10 @@ const createMonitorBodyValidation = joi.object({ port: joi.number(), isActive: joi.boolean(), interval: joi.number(), - thresholds: joi.object().keys({ - usage_cpu: joi.number(), - usage_memory: joi.number(), - usage_disk: joi.number(), - usage_temperature: joi.number(), - }), + cpuAlertThreshold: joi.number(), + memoryAlertThreshold: joi.number(), + diskAlertThreshold: joi.number(), + tempAlertThreshold: joi.number(), notifications: joi.array().items(joi.string()), secret: joi.string(), jsonPath: joi.string().allow(""), @@ -200,12 +198,10 @@ const editMonitorBodyValidation = joi expectedValue: joi.string().allow(""), matchMethod: joi.string().allow(null, ""), port: joi.number().min(1).max(65535), - thresholds: joi.object().keys({ - usage_cpu: joi.number(), - usage_memory: joi.number(), - usage_disk: joi.number(), - usage_temperature: joi.number(), - }), + cpuAlertThreshold: joi.number(), + memoryAlertThreshold: joi.number(), + diskAlertThreshold: joi.number(), + tempAlertThreshold: joi.number(), gameId: joi.string().allow(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), From 6fdea04416102f88685c0a92ae49c37a27852373 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 17:51:32 +0000 Subject: [PATCH 132/392] Revert "remove unused strings" This reverts commit a7c5b0c7fbd1243247453d23b9c60738e4ebe9bc. --- client/src/locales/en.json | 696 ++++++++++++++++++++++++++++++++++++- 1 file changed, 680 insertions(+), 16 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 88f1ffc7f..4f5eaf63f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,6 +1,9 @@ { "common": { "appName": "Checkmate", + "monitoringAgentName": "Capture", + "see": "See", + "forDocumentation": "for query language documentation.", "breadcrumbs": { "home": "Home", "details": "Details" @@ -45,6 +48,7 @@ "table": { "headers": { "actions": "Actions", + "active": "Active", "message": "Message", "monitor": "Monitor", "name": "Name", @@ -65,6 +69,8 @@ } }, "submit": "Submit", + "title": "Title", + "settingsDisabled": "Disabled", "settingsSuccessSaved": "Settings saved successfully", "settingsFailedToSave": "Failed to save settings", "settingsStatsCleared": "Stats cleared successfully", @@ -73,18 +79,146 @@ "settingsFailedToDeleteMonitors": "Failed to delete all monitors", "starPromptTitle": "Star Checkmate", "starPromptDescription": "See the latest releases and help grow the community on GitHub", + "https": "HTTPS", + "http": "HTTP", + "monitor": "monitor", "aboutus": "About Us", + "now": "Now", "delete": "Delete", "configure": "Configure", + "responseTime": "Response time", + "responseTimeBreakdown": "Response time breakdown", + "dnsLookup": "DNS lookup", + "tcpConnection": "TCP connection", + "tlsHandshake": "TLS handshake", + "waitTime": "Wait time", + "timeToFirstByte": "Time to first byte", + "download": "Download", + "ms": "ms", + "bar": "Bar", + "area": "Area", + "country": "COUNTRY", + "city": "CITY", + "incidentsTableNoIncidents": "No incidents recorded", + "incidentsTablePaginationLabel": "incidents", + "incidentsTableMonitorName": "Monitor Name", + "incidentsTableStatus": "Status", + "incidentsTableDateTime": "Date & Time", + "incidentsTableStatusCode": "Status Code", + "incidentsTableMessage": "Message", + "incidentsOptionsHeader": "Incidents for:", + "incidentsOptionsHeaderFilterBy": "Filter by:", + "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterDown": "Down", + "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", + "incidentsOptionsHeaderShow": "Show:", + "incidentsOptionsHeaderLastHour": "Last hour", + "incidentsOptionsHeaderLastDay": "Last day", + "incidentsOptionsHeaderLastWeek": "Last week", + "incidentsOptionsPlaceholderAllServers": "All servers", + "infrastructureCreateYour": "Create your", + "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", + "infrastructureServerRequirement": "The server you are monitoring must be running the", + "infrastructureCustomizeAlerts": "Customize alerts", + "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", + "infrastructureCreateMonitor": "Create Infrastructure Monitor", + "infrastructureProtocol": "Protocol", + "infrastructureServerUrlLabel": "Server URL", + "infrastructureDisplayNameLabel": "Display name", + "infrastructureAuthorizationSecretLabel": "Authorization secret", + "gb": "GB", + "mb": "MB", + "mem": "Mem", + "memoryUsage": "Memory usage", + "cpu": "CPU", + "cpuUsage": "CPU usage", + "cpuTemperature": "CPU Temperature", + "diskUsage": "Disk Usage", + "response": "RESPONSE", + "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", + "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", + "webhookSendSuccess": "Webhook notification sent successfully", + "webhookSendError": "Error sending webhook notification to {platform}", + "webhookUnsupportedPlatform": "Unsupported platform: {platform}", + "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", + "distributedUptimeCreateChecks": "Checks to perform", + "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", + "distributedUptimeCreateAdvancedSettings": "Advanced settings", + "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", "notifications": { + "enableNotifications": "Enable {{platform}} notifications", + "testNotification": "Test notification", + "addOrEditNotifications": "Add or edit notifications", + "slack": { + "label": "Slack", + "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/...", + "webhookRequired": "Slack webhook URL is required" + }, + "discord": { + "label": "Discord", + "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://discord.com/api/webhooks/...", + "webhookRequired": "Discord webhook URL is required" + }, + "telegram": { + "label": "Telegram", + "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", + "tokenLabel": "Your bot token", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + "chatIdLabel": "Your Chat ID", + "chatIdPlaceholder": "-1001234567890", + "fieldsRequired": "Telegram token and chat ID are required" + }, + "webhook": { + "label": "Webhooks", + "description": "You can set up a custom webhook to receive notifications when incidents occur.", + "urlLabel": "Webhook URL", + "urlPlaceholder": "https://your-server.com/webhook", + "urlRequired": "Webhook URL is required" + }, + "testNotificationDevelop": "Test notification 2", + "integrationButton": "Notification Integration", + "testSuccess": "Test notification sent successfully!", + "testFailed": "Failed to send test notification", + "unsupportedType": "Unsupported notification type", + "networkError": "Network error occurred", + "fallback": { + "title": "A notification channel is used to:", + "checks": [ + "Alert teams about downtime or performance issues", + "Let engineers know when incidents happen", + "Keep administrators informed of system changes" + ], + "actionButton": "Let's create your first notification channel!" + }, + "createButton": "Create notification channel", + "createTitle": "Notification channel", + "create": { + "success": "Notification created successfully", + "failed": "Failed to create notification" + }, "fetch": { + "success": "Notifications fetched successfully", "failed": "Failed to fetch notifications" }, + "delete": { + "success": "Notification deleted successfully", + "failed": "Failed to delete notification" + }, + "edit": { + "success": "Notification updated successfully", + "failed": "Failed to update notification" + }, "test": { - "success": "Test notification sent successfully" + "success": "Test notification sent successfully", + "failed": "Failed to send test notification" } }, "add": "Add", + "monitors": "monitors", "components": { "headerTimeRange": { "labels": { @@ -132,15 +266,34 @@ "form": { "type": { "description": "Select the type of check to perform", + "optionType": "Type", "title": "Type", "optionHttp": "HTTP(S)", + "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", "optionPing": "Ping", + "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", "optionDocker": "Docker", + "optionDockerDescription": "Use Docker to monitor if a container is running.", "optionPort": "Port", - "optionGame": "Game" + "optionPortDescription": "Monitor if a specific port on a server is open.", + "optionGame": "Game", + "optionGameDescription": "Monitor if a specific game server is online.", + "optionPagespeed": "PageSpeed", + "optionPagespeedDescription": "Analyze page performance using Google PageSpeed Insights.", + "optionHardware": "Hardware", + "optionHardwareDescription": "Monitor server hardware metrics like CPU, memory, and disk." }, "general": { "title": "General settings", + "description": { + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", + "pagespeed": "Enter the URL of the page to analyze with Google PageSpeed and add a clear display name that appears on the dashboard.", + "hardware": "Enter the URL of your infrastructure agent, the authorization secret, and a clear display name that appears on the dashboard." + }, "option": { "url": { "label": "URL", @@ -159,13 +312,19 @@ "placeholder": "my-app or abcd1234" }, "secret": { - "label": "Authorization secret" + "label": "Authorization secret", + "placeholder": "Enter your secret key" } } }, "frequency": { "title": "Check frequency", - "description": "How often do you want to check the status of this monitor?" + "description": "How often do you want to check the status of this monitor?", + "option": { + "frequency": { + "label": "Check frequency" + } + } }, "incidents": { "title": "Incidents", @@ -173,6 +332,9 @@ "option": { "checks": { "label": "Number of checks in sliding window" + }, + "percentage": { + "label": "What percentage of checks in the sliding window fail/succeed before monitor status changes?" } } }, @@ -193,7 +355,18 @@ "title": "Advanced settings", "description": "Optional settings for advanced use cases", "option": { + "advancedMatching": { + "label": "Use advanced matching" + }, + "matchMethod": { + "label": "Match method" + }, + "expectedValue": { + "label": "Expected value", + "description": "The expected value is used to match against response result, and the match determines the status." + }, "jsonPath": { + "label": "JSONPath expression", "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value. See jmespath.org for query language documentation." } } @@ -206,6 +379,16 @@ "responseTime": "Response time" } }, + "fallback": { + "title": "An uptime monitor is used to:", + "actionButton": "Create a monitor!", + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ] + }, "filters": { "search": { "placeholder": "Search monitors..." @@ -351,14 +534,17 @@ "title": "Channel type" }, "homeServer": { + "description": "Select the home server for this notification channel.", "optionHomeServer": "Home server", - "placeholder": "example.com" + "placeholder": "example.com", + "title": "Home server" }, "roomId": { "optionRoomId": "Room ID", "placeholder": "!abcdefg:matrix.org" }, "accessToken": { + "optionAccessToken": "Access token", "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" }, "address": { @@ -488,8 +674,20 @@ }, "used": "Used", "total": "Total", + "cores": "Cores", + "frequency": "Frequency", + "device": "Device", + "mountedOn": "Mounted on", "status": "Status", + "cpuPhysical": "CPU (Physical)", + "cpuLogical": "CPU (Logical)", + "cpuFrequency": "CPU Frequency", + "avgCpuTemperature": "Average CPU Temperature", + "memory": "Memory", + "disk": "Disk", "uptime": "Uptime", + "os": "OS", + "host": "Host", "actions": "Actions", "integrations": "Integrations", "integrationsPrism": "Connect Prism to your favorite service.", @@ -500,14 +698,28 @@ "integrationsZapier": "Zapier", "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", "commonSave": "Save", + "createYour": "Create your", + "createMonitor": "Create monitor", "pause": "Pause", "resume": "Resume", + "editing": "Editing...", + "url": "URL", "access": "Access", "timezone": "Timezone", "features": "Features", "administrator": "Administrator?", "loginHere": "Login here", + "displayName": "Display name", + "urlMonitor": "URL to monitor", "portToMonitor": "Port to monitor", + "websiteMonitoring": "Website monitoring", + "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", + "pingMonitoring": "Ping monitoring", + "pingMonitoringDescription": "Check whether your server is available or not.", + "dockerContainerMonitoring": "Docker container monitoring", + "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", + "portMonitoring": "Port monitoring", + "portMonitoringDescription": "Check whether your port is open or not.", "createMaintenanceWindow": "Create maintenance window", "createMaintenance": "Create maintenance", "editMaintenance": "Edit maintenance", @@ -521,6 +733,11 @@ "window": "window", "cancel": "Cancel", "close": "Close", + "message": "Message", + "low": "low", + "high": "high", + "statusCode": "Status code", + "date&Time": "Date & Time", "type": "Type", "repeat": "Repeat", "edit": "Edit", @@ -532,7 +749,24 @@ "monitorsToApply": "Monitors to apply maintenance window to", "nextWindow": "Next window", "notFoundButton": "Go to the main dashboard", + "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", + "monitorDisplayName": "Monitor display name", + "whenNewIncident": "When there is a new incident,", + "notifySMS": "Notify via SMS (coming soon)", + "notifyEmails": "Also notify via email to multiple addresses (coming soon)", + "seperateEmails": "You can separate multiple emails with a comma", + "checkFrequency": "Check frequency", + "matchMethod": "Match Method", + "expectedValue": "Expected value", + "deleteDialogTitle": "Do you really want to delete this monitor?", + "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", + "pageSpeedMonitor": "PageSpeed monitor", + "shown": "Shown", + "ago": "ago", "companyName": "Company name", + "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", + "pageSpeedDetailsPerformanceReportCalculator": "See calculator", + "checkingEvery": "Checking every", "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", "basicInformation": "Basic Information", "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", @@ -550,7 +784,16 @@ "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", + "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", + "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", + "uptimeCreateJsonPathQuery": "for query language documentation.", "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", + "infrastructureEditYour": "Edit your", + "infrastructureEditMonitor": "Save Infrastructure Monitor", + "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", + "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", + "errorInvalidTypeId": "Invalid notification type provided", + "errorInvalidFieldId": "Invalid field ID provided", "inviteNoTokenFound": "No invite token found", "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", "pageSpeedLearnMoreLink": "Click here", @@ -576,7 +819,9 @@ "selectFileTips": "Select CSV file to upload", "selectFileDescription": "You can download our or sample", "selectFile": "Select File", + "parsingFailed": "Parsing failed", "uploadSuccess": "Monitors created successfully!", + "validationFailed": "Validation failed", "noFileSelected": "No file selected", "fallbackPage": "Import a file to upload a list of servers in bulk", "invalidFileType": "Invalid file type", @@ -585,7 +830,11 @@ "DeleteAccountTitle": "Remove account", "DeleteAccountButton": "Remove account", "publicLink": "Public link", + "maskedPageSpeedKeyPlaceholder": "*************************************", "reset": "Reset", + "ignoreTLSError": "Ignore TLS/SSL error", + "tlsErrorIgnored": "TLS/SSL errors ignored", + "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", "createNew": "Create new", "greeting": { "prepend": "Hey there", @@ -632,7 +881,41 @@ }, "register": "Register a team member", "registerToast": { - "success": "User created, share credentials with the member securely." + "success": "User created, share credentials with the member securely.", + "dbUserExists": "User already exists.", + "unknownError": "Unknown error occurred." + }, + "registerTeamMember": { + "title": "Register team member", + "auth": { + "common": { + "inputs": { + "firstName": { + "errors": { + "empty": "Please enter a name", + "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" + } + }, + "lastName": { + "errors": { + "empty": "Please enter a surname", + "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" + } + }, + "email": { + "errors": { + "empty": "To continue, please enter an email address", + "invalid": "Please recheck validity of entered email address" + } + }, + "role": { + "errors": { + "empty": "Role is required" + } + } + } + } + } }, "role": "Role", "changeTeamPassword": { @@ -642,6 +925,11 @@ "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way." } }, + "monitorState": { + "paused": "Paused", + "resumed": "Resumed", + "active": "Active" + }, "menu": { "uptime": "Uptime", "pagespeed": "Pagespeed", @@ -649,6 +937,7 @@ "incidents": "Incidents", "statusPages": "Status pages", "maintenance": "Maintenance", + "integrations": "Integrations", "settings": "Settings", "support": "Support", "discussions": "Discussions", @@ -662,29 +951,59 @@ "logs": "Logs", "checks": "Checks" }, + "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", + "state": "State", "statusBreadCrumbsStatusPages": "Status Pages", "statusBreadCrumbsDetails": "Details", "commonSaving": "Saving...", "navControls": "Controls", + "incidentsPageTitle": "Incidents", + "checksPageTitle": "Checks", "passwordPanel": { + "passwordChangedSuccess": "Your password was changed successfully.", + "passwordInputIncorrect": "Your password input was incorrect.", "currentPassword": "Current password", + "enterCurrentPassword": "Enter your current password", "newPassword": "New password", "enterNewPassword": "Enter your new password", - "confirmNewPassword": "Confirm new password" + "confirmNewPassword": "Confirm new password", + "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`).", + "saving": "Saving..." }, + "emailSent": "Email sent successfully", "failedToSendEmail": "Failed to send email", "settingsTestEmailSuccess": "Test email sent successfully", "settingsTestEmailFailed": "Failed to send test email", + "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", "settingsTestEmailUnknownError": "Unknown error", + "statusMsg": { + "paused": "Monitoring is paused.", + "up": "Your site is up.", + "down": "Your site is down.", + "pending": "Pending..." + }, + "uptimeGeneralInstructions": { + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", + "https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard." + }, "auth": { "common": { "navigation": { - "continue": "Continue" + "continue": "Continue", + "back": "Back" }, "inputs": { "email": { "label": "Email", - "placeholder": "jordan.ellis@domain.com" + "placeholder": "jordan.ellis@domain.com", + "errors": { + "empty": "To continue, please enter your email address", + "invalid": "Please recheck validity of entered email address" + } }, "password": { "label": "Password", @@ -713,27 +1032,66 @@ "beginning": "Passwords", "highlighted": "must match" } + }, + "errors": { + "empty": "Please enter your password", + "length": "Password must be at least 8 characters long", + "uppercase": "Password must contain at least 1 uppercase letter", + "lowercase": "Password must contain at least 1 lowercase letter", + "number": "Password must contain at least 1 number", + "special": "Password must contain at least 1 special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", + "incorrect": "The password you provided does not match our records" } }, "passwordConfirm": { "label": "Confirm password", - "placeholder": "Re-enter password to confirm" + "placeholder": "Re-enter password to confirm", + "errors": { + "empty": "Please enter your password again for confirmation (helps with typos)", + "different": "Entered passwords don't match, so one of them is probably mistyped" + } }, "firstName": { "label": "Name", - "placeholder": "Jordan" + "placeholder": "Jordan", + "errors": { + "empty": "Please enter your name", + "length": "Name must be less than 50 characters", + "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" + } }, "lastName": { "label": "Surname", - "placeholder": "Ellis" + "placeholder": "Ellis", + "errors": { + "empty": "Please enter your surname", + "length": "Surname must be less than 50 characters", + "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" + } } }, "errors": { "validation": "Error validating data." + }, + "fields": { + "password": { + "errors": { + "incorrect": "The password you provided does not match our records" + } + }, + "role": { + "errors": { + "min": "At least one role is required" + } + } } }, "login": { "heading": "Log in to continue", + "subheadings": { + "stepOne": "Enter your email", + "stepTwo": "Enter your password" + }, "links": { "forgotPassword": "Forgot password?", "register": "Do not have an account?", @@ -753,12 +1111,26 @@ }, "registration": { "heading": { + "superAdmin": "Create super admin", "user": "Sign Up" }, + "subheadings": { + "stepOne": "Enter your personal details", + "stepTwo": "Enter your email", + "stepThree": "Create your password" + }, "description": { "superAdmin": "Create your super admin account to get started", "user": "Sign up as a user and ask super admin for access to your monitors" }, + "gettingStartedButton": { + "superAdmin": "Create super admin account", + "user": "Sign up regular user" + }, + "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", + "links": { + "login": "Already have an account? Log In" + }, "toasts": { "success": "Welcome! Your account was created successfully." }, @@ -776,6 +1148,12 @@ "openEmail": "Open email app", "resetPassword": "Reset password" }, + "imageAlts": { + "passwordKey": "Password key icon", + "email": "Email icon", + "lock": "Lock icon", + "passwordConfirm": "Password confirm icon" + }, "links": { "login": "Go back to Log In", "resend": "Didn't receive the email? Click to resend" @@ -803,15 +1181,80 @@ } } }, + "createNotifications": { + "title": "Create notification channel", + "nameSettings": { + "title": "Name", + "description": "A descriptive name for your integration.", + "nameLabel": "Name", + "namePlaceholder": "e.g. Slack notifications" + }, + "typeSettings": { + "title": "Type", + "description": "Select the type of notification channel you want to create.", + "typeLabel": "Type" + }, + "emailSettings": { + "title": "Email", + "description": "Destination email addresses.", + "emailLabel": "Email address", + "emailPlaceholder": "e.g. john@example.com" + }, + "slackSettings": { + "title": "Slack", + "description": "Configure your Slack webhook here", + "webhookLabel": "Slack webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/..." + }, + "pagerdutySettings": { + "title": "PagerDuty", + "description": "Configure your PagerDuty integration here", + "integrationKeyLabel": "Integration key", + "integrationKeyPlaceholder": "1234567890" + }, + "discordSettings": { + "title": "Discord", + "description": "Configure your Discord webhook here", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "webhookSettings": { + "title": "Webhook", + "description": "Configure your webhook here", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "testNotification": "Test notification", + "dialogDeleteTitle": "Are you sure you want to delete this notification?", + "dialogDeleteConfirm": "Delete" + }, + "notificationConfig": { + "title": "Notifications", + "description": "Select the notifications channels you want to use", + "placeholder": "Search notifications..." + }, + "monitorStatus": { + "checkingEvery": "Checking every {{interval}}", + "withCaptureAgent": "with Capture agent {{version}}", + "up": "up", + "down": "down", + "paused": "paused" + }, + "advancedMatching": "Advanced matching", + "sendTestNotifications": "Send test notifications", "selectAll": "Select all", "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", "logsPage": { "title": "Logs", + "description": "System logs - last 1000 lines", "tabs": { "queue": "Job queue", "logs": "Server logs", "diagnostics": "Diagnostics" }, + "toast": { + "fetchLogsSuccess": "Logs fetched successfully" + }, "logLevelSelect": { "title": "Log level", "values": { @@ -837,6 +1280,7 @@ "noLogs": "No logs found" }, "queuePage": { + "title": "Queue", "refreshButton": "Refresh", "flushButton": "Flush queue", "jobTable": { @@ -867,9 +1311,23 @@ "failReasonHeader": "Fail reason" } }, + "export": { + "title": "Export Monitors", + "success": "Monitors exported successfully!", + "failed": "Failed to export monitors" + }, + "monitorActions": { + "title": "Export/Import", + "import": "Import Monitors", + "export": "Export Monitors", + "deleteSuccess": "Monitor deleted successfully", + "deleteFailed": "Failed to delete monitor", + "details": "Details" + }, "settingsPage": { "aboutSettings": { "labelDevelopedBy": "Developed by Bluewave Labs", + "labelVersion": "Version", "title": "About" }, "demoMonitorsSettings": { @@ -884,9 +1342,14 @@ "labelAddress": "Email address - Used for authentication", "labelConnectionHost": "Email connection host - Hostname to use in the HELO/EHLO greeting", "labelHost": "Email host - Hostname or IP address to connect to", + "labelIgnoreTLS": "Disable STARTTLS: Don't use TLS even if the server supports it", "labelPassword": "Email password - Password for authentication", "labelPasswordSet": "Password is set. Click Reset to change it.", + "labelPool": "Enable connection pooling: Reuse existing connections to improve performance", "labelPort": "Email port - Port to connect to", + "labelRejectUnauthorized": "Reject invalid certificates: Reject connections with self-signed or untrusted certificates", + "labelRequireTLS": "Force STARTTLS: Require TLS upgrade, fail if not supported", + "labelSecure": "Use SSL (recommended): Encrypt the connection using SSL/TLS", "labelTLSServername": "TLS Servername - Optional Hostname for TLS Validation when host is an IP", "labelUser": "Email user - Username for authentication, overrides email address if specified", "linkTransport": "See specifications here", @@ -904,20 +1367,25 @@ "statsSettings": { "clearAllStatsButton": "Clear all stats", "clearAllStatsDescription": "Clear all stats. This is irreversible.", + "clearAllStatsDialogConfirm": "Yes, clear all stats", "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", "clearAllStatsDialogTitle": "Do you want to clear all stats?", "description": "Define how long you want to retain historical data. You can also clear all existing data.", + "labelTTL": "The days you want to keep monitoring history.", + "labelTTLOptional": "0 for infinite", "title": "Monitor history" }, "systemResetSettings": { "buttonRemoveAllMonitors": "Remove all monitors", "description": "Remove all monitors from your system.", "dialogConfirm": "Yes, remove all monitors", + "dialogDescription": "Once removed, the monitors cannot be retrieved.", "dialogTitle": "Do you want to remove all monitors?", "title": "System reset" }, "timezoneSettings": { "description": "Select the timezone used to display dates and times throughout the application.", + "label": "Display timezone", "title": "Display timezone" }, "title": "Settings", @@ -938,33 +1406,54 @@ "title": "Monitor IP/URL on Status Page" }, "globalThresholds": { - "title": "Global Thresholds" + "title": "Global Thresholds", + "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." } }, "statusPageCreate": { "buttonSave": "Save" }, + "incidentsOptionsHeaderFilterResolved": "Resolved", + "settingsSave": "Save", "statusPageCreateAppearanceTitle": "Appearance", "confirmPassword": "Confirm password", "monitorHooks": { "failureAddDemoMonitors": "Failed to add demo monitors", "successAddDemoMonitors": "Successfully added demo monitors" }, + "settingsAppearance": "Appearance", "settingsDisplayTimezone": "Display timezone", "settingsGeneralSettings": "General settings", + "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "statusPage": { "deleteSuccess": "Status page deleted successfully", "deleteFailed": "Failed to delete status page", "createSuccess": "Status page created successfully", "updateSuccess": "Status page updated successfully", "generalSettings": "General settings", - "contents": "Contents" + "contents": "Contents", + "fallback": { + "checks": [ + "Monitor and display the health of your services in real time", + "Track multiple services and share their status", + "Keep users informed about outages and performance" + ], + "title": "A status page is used to:", + "actionButton": "Let's create your first status page!" + } }, + "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", + "incidentsTableResolvedAt": "Resolved at", + "incidentsTableActionResolve": "Resolve", "checkHooks": { - "failureResolveOne": "Failed to resolve incident." + "failureResolveOne": "Failed to resolve incident.", + "failureResolveAll": "Failed to resolve all incidents.", + "failureResolveMonitor": "Failed to resolve monitor incidents." }, + "checkFormError": "Please check the form for errors.", "diagnosticsPage": { "diagnosticDescription": "System diagnostics", + "statsDescription": "System statistics", "gauges": { "heapAllocationTitle": "Heap allocation", "heapAllocationSubtitle": "% of available memory", @@ -983,6 +1472,7 @@ "osMemoryLimitTitle": "OS Memory Limit" } }, + "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", "time": { "fifteenSeconds": "15 seconds", "thirtySeconds": "30 seconds", @@ -991,6 +1481,10 @@ "threeMinutes": "3 minutes", "fiveMinutes": "5 minutes", "tenMinutes": "10 minutes", + "twentyMinutes": "20 minutes", + "oneHour": "1 hour", + "oneDay": "1 day", + "oneWeek": "1 week", "fourMinutes": "4 minutes", "oneMinute": "1 minute", "twoMinutes": "2 minutes" @@ -998,6 +1492,51 @@ "general": { "noOptionsFound": "No {{unit}} found" }, + "infrastructureMonitor": { + "fallback": { + "checks": [ + "Track the performance of your servers", + "Identify bottlenecks and optimize usage", + "Ensure reliability with real-time monitoring" + ], + "title": "An infrastructure monitor is used to:", + "actionButton": "Let's create your first infrastructure monitor!" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [ + "Mark your maintenance periods", + "Eliminate any misunderstandings", + "Stop sending alerts in maintenance windows" + ], + "title": "A maintenance window is used to:", + "actionButton": "Let's create your first maintenance window!" + } + }, + "pageSpeed": { + "fallback": { + "checks": [ + "Report on the user experience of a page", + "Help analyze webpage speed", + "Give suggestions on how the page can be improved" + ], + "title": "A PageSpeed monitor is used to:", + "actionButton": "Let's create your first PageSpeed monitor!" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ], + "title": "An uptime monitor is used to:", + "actionButton": "Let's create your first uptime monitor!" + } + }, "editUserPage": { "form": { "email": "Email", @@ -1020,13 +1559,138 @@ "validationErrors": "Validation errors" } }, + "incidentsPageActionResolveMonitor": "Resolve monitor incidents", + "checksPageActionResolveMonitor": "Resolve monitor checks", + "incidentsPageActionResolveAll": "Resolve all incidents", + "checksPageActionResolveAllMonitor": "Resolve all checks", "matchMethodOptions": { "equal": "Equal", + "equalPlaceholder": "success", "include": "Include", - "regex": "Regex" + "includePlaceholder": "ok", + "regex": "Regex", + "regexPlaceholder": "^(success|ok)$", + "text": "Match Method" }, + "monitorType": { + "docker": { + "label": "Container Name/ID", + "namePlaceholder": "My Container", + "placeholder": "my-app or abcd1234" + }, + "http": { + "label": "URL to monitor", + "namePlaceholder": "Google", + "placeholder": "google.com" + }, + "ping": { + "label": "IP address to monitor", + "namePlaceholder": "Google", + "placeholder": "1.1.1.1" + }, + "port": { + "label": "URL to monitor", + "namePlaceholder": "Localhost:5173", + "placeholder": "localhost" + }, + "game": { + "label": "URL to monitor", + "namePlaceholder": "localhost:5173", + "placeholder": "localhost" + } + }, + "uptimeAdvancedMatching": { + "jsonPath": "JSON Path" + }, + "bytesPerSecond": "Bytes per second", + "bytesReceived": "Bytes Received", + "bytesSent": "Bytes Sent", "chooseGame": "Choose game", + "createMonitorPage": { + "incidentConfigDescription": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", + "incidentConfigStatusWindowLabel": "How many checks should be in the sliding window?", + "incidentConfigStatusWindowThresholdLabel": "What percentage of checks in the sliding window fail/succeed before monitor status changes?", + "incidentConfigTitle": "Incidents", + "incidentConfigDescriptionV2": "Number of consecutive checks needed to change monitor status. Maximum 25", + "incidentConfigStatusCheckNumber": "How many checks before status change?", + "intervalTitle": "Check interval", + "intervalDescription": "How often the monitor should be checked." + }, + "dataRate": "Data Rate", + "dataReceived": "Data Received", + "dataSent": "Data Sent", + "details": "Details", + "drops": "Drops", + "errors": "Errors", + "errorsIn": "Errors In", + "errorsOut": "Errors Out", + "gameServerMonitoring": "Game Server Monitoring", + "gameServerMonitoringDescription": "Check whether your game server is running or not", + "network": "Network", + "networkDrops": "Network Drops", + "networkErrors": "Network Errors", + "networkInterface": "Network Interface", + "noNetworkStatsAvailable": "No network stats available.", + "packetsPerSecond": "Packets per second", + "packetsReceived": "Packets Received", + "packetsReceivedRate": "Packets Received Rate", + "packetsSent": "Packets Sent", + "rate": "Rate", + "selectInterface": "Select Interface", + "v1": { + "infrastructure": { + "disk_selection_title": "Disk Selection", + "disk_selection_info": "No disk detected for the moment.", + "disk_selection_description": "Select the specific disks you want to monitor." + } + }, "incidentsPage": { + "title": "Incidents", + "description": "Manage incidents for your monitors. You can resolve incidents here.", + "resolved": "Resolved", + "active": "Active", + "startTime": "Start Time", + "endTime": "End Time", + "resolutionType": "Resolution Type", + "unresolved": "Unresolved", + "all": "All", + "filterByMonitor": "Filter by monitor", + "filterByStatus": "Filter by status", + "filterByType": "Filter by type", + "filterByDate": "Filter by date", + "filterByPriority": "Filter by priority", + "incidentsActivePanelError": "Error loading active incidents", + "allSystemsAreOperational": "All Systems Are Operational", + "incidentsActivePanelTitle": "Total Active Incidents", + "incidentsLatestPanelError": "Error loading latest incidents", + "incidentsLatestPanelTitle": "Latest Incidents", + "incidentsLatestPanelEmpty": "No recent incidents", + "incidentItemMonitor": "Monitor", + "incidentItemStatus": "Status", + "unknownMonitor": "Unknown Monitor", + "incidentsStatisticsPanelError": "Error loading statistics", + "incidentsStatisticsPanelTitle": "General Statistics", + "totalIncidents": "Total Incidents", + "mostAffectedMonitor": "Most Affected Monitor", + "avgResolutionTime": "Avg. Resolution Time", + "resolutions": "Resolutions", + "automatic": "Automatic", + "manual": "Manual", + "incidentResolvedSuccessfully": "Incident resolved successfully", + "incidentResolvedFailed": "Error resolving incident", + "errorFetchingIncidents": "Error fetching incidents", + "errorFetchingIncident": "Error fetching incident", + "noIncidentIdProvided": "No incident ID provided", + "errorFetchingIncidentSummary": "Error fetching incident summary", + "incidentsOptionsHeaderFilterManual": "Manual", + "incidentsOptionsHeaderFilterAutomatic": "Automatic", + "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterActive": "Active", + "incidentsOptionsHeaderFilterResolved": "Resolved", + "incidentsTableResolved": "Closed", + "incidentsTableActionResolveManually": "Resolve Manually", + "incidentsTableActionDetails": "Details", + "incidentsTableActionGoToMonitor": "Go to monitor", "resolveIncidentDialogTitle": "Resolve Incident", "resolveIncidentDialogCommentLabel": "Comment (optional)", "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", From 080c91284dbb27be15091959e166f09882f1e08a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 17:53:45 +0000 Subject: [PATCH 133/392] sort --- client/src/locales/en.json | 2806 ++++++++++++++++++------------------ 1 file changed, 1403 insertions(+), 1403 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 4f5eaf63f..ad387a215 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,408 +1,871 @@ { + "aboutus": "About Us", + "access": "Access", + "actions": "Actions", + "add": "Add", + "addMonitors": "Add monitors", + "administrator": "Administrator?", + "advancedMatching": "Advanced matching", + "ago": "ago", + "area": "Area", + "auth": { + "common": { + "errors": { + "validation": "Error validating data." + }, + "fields": { + "password": { + "errors": { + "incorrect": "The password you provided does not match our records" + } + }, + "role": { + "errors": { + "min": "At least one role is required" + } + } + }, + "inputs": { + "email": { + "errors": { + "empty": "To continue, please enter your email address", + "invalid": "Please recheck validity of entered email address" + }, + "label": "Email", + "placeholder": "jordan.ellis@domain.com" + }, + "firstName": { + "errors": { + "empty": "Please enter your name", + "length": "Name must be less than 50 characters", + "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" + }, + "label": "Name", + "placeholder": "Jordan" + }, + "lastName": { + "errors": { + "empty": "Please enter your surname", + "length": "Surname must be less than 50 characters", + "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" + }, + "label": "Surname", + "placeholder": "Ellis" + }, + "password": { + "errors": { + "empty": "Please enter your password", + "incorrect": "The password you provided does not match our records", + "length": "Password must be at least 8 characters long", + "lowercase": "Password must contain at least 1 lowercase letter", + "number": "Password must contain at least 1 number", + "special": "Password must contain at least 1 special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", + "uppercase": "Password must contain at least 1 uppercase letter" + }, + "label": "Password", + "rules": { + "length": { + "beginning": "Must be at least", + "highlighted": "8 characters long" + }, + "lowercase": { + "beginning": "Must contain at least", + "highlighted": "one lower character" + }, + "match": { + "beginning": "Passwords", + "highlighted": "must match" + }, + "number": { + "beginning": "Must contain at least", + "highlighted": "one number" + }, + "special": { + "beginning": "Must contain at least", + "highlighted": "one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" + }, + "uppercase": { + "beginning": "Must contain at least", + "highlighted": "one upper character" + } + } + }, + "passwordConfirm": { + "errors": { + "different": "Entered passwords don't match, so one of them is probably mistyped", + "empty": "Please enter your password again for confirmation (helps with typos)" + }, + "label": "Confirm password", + "placeholder": "Re-enter password to confirm" + } + }, + "navigation": { + "back": "Back", + "continue": "Continue" + } + }, + "forgotPassword": { + "buttons": { + "openEmail": "Open email app", + "resetPassword": "Reset password" + }, + "heading": "Forgot password?", + "imageAlts": { + "email": "Email icon", + "lock": "Lock icon", + "passwordConfirm": "Password confirm icon", + "passwordKey": "Password key icon" + }, + "links": { + "login": "Go back to Log In", + "resend": "Didn't receive the email? Click to resend" + }, + "subheadings": { + "stepFour": "Your password has been successfully reset. Click below to log in magically.", + "stepOne": "No worries, we'll send you reset instructions.", + "stepThree": "Your new password must be different from previously used passwords.", + "stepTwo": "We sent a password reset link to " + }, + "toasts": { + "emailNotFound": "Email not found.", + "error": "Unable to reset password. Please try again later or contact support.", + "redirect": "Redirecting in ...", + "sent": "Instructions sent to .", + "success": "Your password was reset successfully." + } + }, + "login": { + "errors": { + "password": { + "incorrect": "The password you provided does not match our records" + } + }, + "heading": "Log in to continue", + "links": { + "forgotPassword": "Forgot password?", + "forgotPasswordLink": "Reset password", + "register": "Do not have an account?", + "registerLink": "Register here" + }, + "subheadings": { + "stepOne": "Enter your email", + "stepTwo": "Enter your password" + }, + "toasts": { + "incorrectPassword": "Incorrect password", + "success": "Welcome back! You're successfully logged in." + }, + "welcome": "Welcome back to Checkmate!" + }, + "registration": { + "description": { + "superAdmin": "Create your super admin account to get started", + "user": "Sign up as a user and ask super admin for access to your monitors" + }, + "gettingStartedButton": { + "superAdmin": "Create super admin account", + "user": "Sign up regular user" + }, + "heading": { + "superAdmin": "Create super admin", + "user": "Sign Up" + }, + "links": { + "login": "Already have an account? Log In" + }, + "subheadings": { + "stepOne": "Enter your personal details", + "stepThree": "Create your password", + "stepTwo": "Enter your email" + }, + "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", + "toasts": { + "success": "Welcome! Your account was created successfully." + }, + "welcome": "Welcome to Checkmate!" + } + }, + "avgCpuTemperature": "Average CPU Temperature", + "bar": "Bar", + "basicInformation": "Basic Information", + "bulkImport": { + "fallbackPage": "Import a file to upload a list of servers in bulk", + "invalidFileType": "Invalid file type", + "noFileSelected": "No file selected", + "parsingFailed": "Parsing failed", + "selectFile": "Select File", + "selectFileDescription": "You can download our or sample", + "selectFileTips": "Select CSV file to upload", + "title": "Bulk Import", + "uploadFailed": "Upload failed", + "uploadSuccess": "Monitors created successfully!", + "validationFailed": "Validation failed" + }, + "bytesPerSecond": "Bytes per second", + "bytesReceived": "Bytes Received", + "bytesSent": "Bytes Sent", + "cancel": "Cancel", + "checkFormError": "Please check the form for errors.", + "checkFrequency": "Check frequency", + "checkHooks": { + "failureResolveAll": "Failed to resolve all incidents.", + "failureResolveMonitor": "Failed to resolve monitor incidents.", + "failureResolveOne": "Failed to resolve incident." + }, + "checkingEvery": "Checking every", + "checksPageActionResolveAllMonitor": "Resolve all checks", + "checksPageActionResolveMonitor": "Resolve monitor checks", + "checksPageTitle": "Checks", + "chooseGame": "Choose game", + "city": "CITY", + "ClickUpload": "Click to upload", + "close": "Close", "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "see": "See", - "forDocumentation": "for query language documentation.", - "breadcrumbs": { - "home": "Home", - "details": "Details" - }, - "labels": { - "responseTime": "Response time", - "active": "Active", - "resolved": "Resolved", - "na": "N/A" - }, - "buttons": { - "toggleTheme": "Toggles light & dark", - "testNotifications": "Test notifications", - "create": "Create", - "incidents": "Incidents", - "pause": "Pause", - "resume": "Resume", - "delete": "Delete", - "cancel": "Cancel", - "confirm": "Confirm", - "save": "Save", - "test": "Test", - "configure": "Configure" - }, - "alerts": { "pageSpeedApiKey": { "content": "Warning: You haven't added a Google PageSpeed API key yet. Visit Settings to add one. Without it, the PageSpeed monitor won't function." } }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Details", + "home": "Home" + }, + "buttons": { + "cancel": "Cancel", + "configure": "Configure", + "confirm": "Confirm", + "create": "Create", + "delete": "Delete", + "incidents": "Incidents", + "pause": "Pause", + "resume": "Resume", + "save": "Save", + "test": "Test", + "testNotifications": "Test notifications", + "toggleTheme": "Toggles light & dark" + }, + "charts": { + "labels": { + "averageResponseTime": "Average response time", + "downtime": "Downtime", + "high": "High", + "low": "Low", + "uptime": "Uptime" + } + }, "dialogs": { "delete": { "description": "This action cannot be undone.", "title": "Are you sure you want to delete this?" } }, - "toasts": { - "networkError": "Network error", - "checkConnection": "Please check your connection", - "unknownError": "Unknown error" + "forDocumentation": "for query language documentation.", + "labels": { + "active": "Active", + "na": "N/A", + "resolved": "Resolved", + "responseTime": "Response time" }, + "monitoringAgentName": "Capture", + "see": "See", "table": { + "empty": "Nothing here", "headers": { "actions": "Actions", "active": "Active", + "dateTime": "Date & time", "message": "Message", "monitor": "Monitor", "name": "Name", "status": "Status", - "type": "Type", - "dateTime": "Date & time" - }, - "empty": "Nothing here" + "type": "Type" + } }, - "charts": { + "toasts": { + "checkConnection": "Please check your connection", + "networkError": "Network error", + "unknownError": "Unknown error" + } + }, + "commonSave": "Save", + "commonSaving": "Saving...", + "companyName": "Company name", + "components": { + "headerTimeRange": { + "description": { + "day": "Showing statistics for past 24 hours.", + "month": "Showing statistics for past 30 days.", + "recent": "Showing statistics for past 2 hours.", + "week": "Showing statistics for past 7 days." + }, "labels": { - "uptime": "Uptime", - "downtime": "Downtime", - "averageResponseTime": "Average response time", - "low": "Low", - "high": "High" + "day": "Day", + "month": "Month", + "recent": "Recent", + "week": "Week" } } }, - "submit": "Submit", - "title": "Title", - "settingsDisabled": "Disabled", - "settingsSuccessSaved": "Settings saved successfully", - "settingsFailedToSave": "Failed to save settings", - "settingsStatsCleared": "Stats cleared successfully", - "settingsFailedToClearStats": "Failed to clear stats", - "settingsMonitorsDeleted": "Successfully deleted all monitors", - "settingsFailedToDeleteMonitors": "Failed to delete all monitors", - "starPromptTitle": "Star Checkmate", - "starPromptDescription": "See the latest releases and help grow the community on GitHub", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitor", - "aboutus": "About Us", - "now": "Now", - "delete": "Delete", "configure": "Configure", - "responseTime": "Response time", - "responseTimeBreakdown": "Response time breakdown", - "dnsLookup": "DNS lookup", - "tcpConnection": "TCP connection", - "tlsHandshake": "TLS handshake", - "waitTime": "Wait time", - "timeToFirstByte": "Time to first byte", - "download": "Download", - "ms": "ms", - "bar": "Bar", - "area": "Area", + "confirmPassword": "Confirm password", + "cores": "Cores", "country": "COUNTRY", - "city": "CITY", - "incidentsTableNoIncidents": "No incidents recorded", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableMonitorName": "Monitor Name", - "incidentsTableStatus": "Status", - "incidentsTableDateTime": "Date & Time", - "incidentsTableStatusCode": "Status Code", - "incidentsTableMessage": "Message", - "incidentsOptionsHeader": "Incidents for:", - "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterDown": "Down", - "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", - "incidentsOptionsHeaderShow": "Show:", - "incidentsOptionsHeaderLastHour": "Last hour", - "incidentsOptionsHeaderLastDay": "Last day", - "incidentsOptionsHeaderLastWeek": "Last week", - "incidentsOptionsPlaceholderAllServers": "All servers", - "infrastructureCreateYour": "Create your", - "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", - "infrastructureServerRequirement": "The server you are monitoring must be running the", - "infrastructureCustomizeAlerts": "Customize alerts", - "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", - "infrastructureCreateMonitor": "Create Infrastructure Monitor", - "infrastructureProtocol": "Protocol", - "infrastructureServerUrlLabel": "Server URL", - "infrastructureDisplayNameLabel": "Display name", - "infrastructureAuthorizationSecretLabel": "Authorization secret", - "gb": "GB", - "mb": "MB", - "mem": "Mem", - "memoryUsage": "Memory usage", "cpu": "CPU", - "cpuUsage": "CPU usage", + "cpuFrequency": "CPU Frequency", + "cpuLogical": "CPU (Logical)", + "cpuPhysical": "CPU (Physical)", "cpuTemperature": "CPU Temperature", + "cpuUsage": "CPU usage", + "createA": "Create a", + "createMaintenance": "Create maintenance", + "createMaintenanceWindow": "Create maintenance window", + "createMonitor": "Create monitor", + "createMonitorPage": { + "incidentConfigDescription": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", + "incidentConfigDescriptionV2": "Number of consecutive checks needed to change monitor status. Maximum 25", + "incidentConfigStatusCheckNumber": "How many checks before status change?", + "incidentConfigStatusWindowLabel": "How many checks should be in the sliding window?", + "incidentConfigStatusWindowThresholdLabel": "What percentage of checks in the sliding window fail/succeed before monitor status changes?", + "incidentConfigTitle": "Incidents", + "intervalDescription": "How often the monitor should be checked.", + "intervalTitle": "Check interval" + }, + "createNew": "Create new", + "createNotifications": { + "dialogDeleteConfirm": "Delete", + "dialogDeleteTitle": "Are you sure you want to delete this notification?", + "discordSettings": { + "description": "Configure your Discord webhook here", + "title": "Discord", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "emailSettings": { + "description": "Destination email addresses.", + "emailLabel": "Email address", + "emailPlaceholder": "e.g. john@example.com", + "title": "Email" + }, + "nameSettings": { + "description": "A descriptive name for your integration.", + "nameLabel": "Name", + "namePlaceholder": "e.g. Slack notifications", + "title": "Name" + }, + "pagerdutySettings": { + "description": "Configure your PagerDuty integration here", + "integrationKeyLabel": "Integration key", + "integrationKeyPlaceholder": "1234567890", + "title": "PagerDuty" + }, + "slackSettings": { + "description": "Configure your Slack webhook here", + "title": "Slack", + "webhookLabel": "Slack webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/..." + }, + "testNotification": "Test notification", + "title": "Create notification channel", + "typeSettings": { + "description": "Select the type of notification channel you want to create.", + "title": "Type", + "typeLabel": "Type" + }, + "webhookSettings": { + "description": "Configure your webhook here", + "title": "Webhook", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + } + }, + "createYour": "Create your", + "dataRate": "Data Rate", + "dataReceived": "Data Received", + "dataSent": "Data Sent", + "date&Time": "Date & Time", + "delete": "Delete", + "DeleteAccountButton": "Remove account", + "DeleteAccountTitle": "Remove account", + "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", + "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", + "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", + "deleteDialogTitle": "Do you really want to delete this monitor?", + "deleteStatusPage": "Do you want to delete this status page?", + "deleteStatusPageConfirm": "Yes, delete status page", + "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", + "DeleteWarningTitle": "Really remove this account?", + "details": "Details", + "device": "Device", + "diagnosticsPage": { + "diagnosticDescription": "System diagnostics", + "gauges": { + "heapAllocationSubtitle": "% of available memory", + "heapAllocationTitle": "Heap allocation", + "heapUsageSubtitle": "% of available memory", + "heapUsageTitle": "Heap usage", + "heapUtilizationSubtitle": "% of allocated", + "heapUtilizationTitle": "Heap utilization", + "instantCpuUsageSubtitle": "% of 1s used by CPU", + "instantCpuUsageTitle": "Instant CPU usage" + }, + "stats": { + "eventLoopDelayTitle": "Event loop delay", + "osMemoryLimitTitle": "OS Memory Limit", + "totalHeapSizeTitle": "Total heap size", + "uptimeTitle": "Uptime", + "usedHeapSizeTitle": "Used heap size" + }, + "statsDescription": "System statistics" + }, + "disk": "Disk", "diskUsage": "Disk Usage", - "response": "RESPONSE", - "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", - "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", - "webhookSendSuccess": "Webhook notification sent successfully", - "webhookSendError": "Error sending webhook notification to {platform}", - "webhookUnsupportedPlatform": "Unsupported platform: {platform}", - "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", + "displayName": "Display name", + "distributedUptimeCreateAdvancedSettings": "Advanced settings", "distributedUptimeCreateChecks": "Checks to perform", "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", - "distributedUptimeCreateAdvancedSettings": "Advanced settings", + "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", + "dnsLookup": "DNS lookup", + "dockerContainerMonitoring": "Docker container monitoring", + "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", + "download": "Download", + "DragandDrop": "drag and drop", + "drops": "Drops", + "duration": "Duration", + "edit": "Edit", + "editing": "Editing...", + "editMaintenance": "Edit maintenance", + "editUserPage": { + "form": { + "email": "Email", + "firstName": "First name", + "lastName": "Last name", + "role": "Roles", + "save": "Save" + }, + "table": { + "actionHeader": "Action", + "headers": { + "name": "Status page name", + "url": "Public URL" + }, + "roleHeader": "Role" + }, + "title": "Edit user", + "toast": { + "successUserUpdate": "User updated successfully", + "validationErrors": "Validation errors" + } + }, + "EmailDescriptionText": "This is your current email address — it cannot be changed.", + "emailSent": "Email sent successfully", + "errorInvalidFieldId": "Invalid field ID provided", + "errorInvalidTypeId": "Invalid notification type provided", + "errorPages": { + "serverUnreachable": { + "alertBox": "Server Connection Error", + "description": "We're unable to connect to the server. Please check your internet connection or verify your deployment configuration if the problem persists.", + "retryButton": { + "default": "Retry connection", + "processing": "Connecting..." + }, + "toasts": { + "reconnected": "Successfully reconnected to the server.", + "stillUnreachable": "Server is still unreachable. Please try again later." + } + } + }, + "errors": "Errors", + "errorsIn": "Errors In", + "errorsOut": "Errors Out", + "expectedValue": "Expected value", + "export": { + "failed": "Failed to export monitors", + "success": "Monitors exported successfully!", + "title": "Export Monitors" + }, + "failedToSendEmail": "Failed to send email", + "features": "Features", + "FirstName": "First name", + "frequency": "Frequency", + "friendlyNameInput": "Friendly name", + "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", + "gameServerMonitoring": "Game Server Monitoring", + "gameServerMonitoringDescription": "Check whether your game server is running or not", + "gb": "GB", + "general": { + "noOptionsFound": "No {{unit}} found" + }, + "greeting": { + "append": "The afternoon is your playground—let's make it epic!", + "overview": "Here's an overview of your {{type}} monitors.", + "prepend": "Hey there" + }, + "high": "high", + "host": "Host", + "http": "HTTP", + "https": "HTTPS", + "ignoreTLSError": "Ignore TLS/SSL error", + "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", + "incidentsOptionsHeader": "Incidents for:", + "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterBy": "Filter by:", + "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", + "incidentsOptionsHeaderFilterDown": "Down", + "incidentsOptionsHeaderFilterResolved": "Resolved", + "incidentsOptionsHeaderLastDay": "Last day", + "incidentsOptionsHeaderLastHour": "Last hour", + "incidentsOptionsHeaderLastWeek": "Last week", + "incidentsOptionsHeaderShow": "Show:", + "incidentsOptionsHeaderTotalIncidents": "Total Incidents", + "incidentsOptionsPlaceholderAllServers": "All servers", + "incidentsPage": { + "active": "Active", + "all": "All", + "allSystemsAreOperational": "All Systems Are Operational", + "automatic": "Automatic", + "avgResolutionTime": "Avg. Resolution Time", + "description": "Manage incidents for your monitors. You can resolve incidents here.", + "endTime": "End Time", + "errorFetchingIncident": "Error fetching incident", + "errorFetchingIncidents": "Error fetching incidents", + "errorFetchingIncidentSummary": "Error fetching incident summary", + "filterByDate": "Filter by date", + "filterByMonitor": "Filter by monitor", + "filterByPriority": "Filter by priority", + "filterByStatus": "Filter by status", + "filterByType": "Filter by type", + "incidentItemMonitor": "Monitor", + "incidentItemStatus": "Status", + "incidentResolvedFailed": "Error resolving incident", + "incidentResolvedSuccessfully": "Incident resolved successfully", + "incidentsActivePanelError": "Error loading active incidents", + "incidentsActivePanelTitle": "Total Active Incidents", + "incidentsLatestPanelEmpty": "No recent incidents", + "incidentsLatestPanelError": "Error loading latest incidents", + "incidentsLatestPanelTitle": "Latest Incidents", + "incidentsOptionsHeaderFilterActive": "Active", + "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterAutomatic": "Automatic", + "incidentsOptionsHeaderFilterManual": "Manual", + "incidentsOptionsHeaderFilterResolved": "Resolved", + "incidentsStatisticsPanelError": "Error loading statistics", + "incidentsStatisticsPanelTitle": "General Statistics", + "incidentsTableActionDetails": "Details", + "incidentsTableActionGoToMonitor": "Go to monitor", + "incidentsTableActionResolveManually": "Resolve Manually", + "incidentsTableResolved": "Closed", + "manual": "Manual", + "mostAffectedMonitor": "Most Affected Monitor", + "noIncidentIdProvided": "No incident ID provided", + "resolutions": "Resolutions", + "resolutionType": "Resolution Type", + "resolved": "Resolved", + "resolveIncidentDialogCommentLabel": "Comment (optional)", + "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", + "resolveIncidentDialogConfirm": "Resolve", + "resolveIncidentDialogTitle": "Resolve Incident", + "startTime": "Start Time", + "title": "Incidents", + "totalIncidents": "Total Incidents", + "unknownMonitor": "Unknown Monitor", + "unresolved": "Unresolved" + }, + "incidentsPageActionResolveAll": "Resolve all incidents", + "incidentsPageActionResolveMonitor": "Resolve monitor incidents", + "incidentsPageTitle": "Incidents", + "incidentsTableActionResolve": "Resolve", + "incidentsTableDateTime": "Date & Time", + "incidentsTableMessage": "Message", + "incidentsTableMonitorName": "Monitor Name", + "incidentsTableNoIncidents": "No incidents recorded", + "incidentsTablePaginationLabel": "incidents", + "incidentsTableResolvedAt": "Resolved at", + "incidentsTableStatus": "Status", + "incidentsTableStatusCode": "Status Code", + "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", + "infrastructureAuthorizationSecretLabel": "Authorization secret", + "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", + "infrastructureCreateMonitor": "Create Infrastructure Monitor", + "infrastructureCreateYour": "Create your", + "infrastructureCustomizeAlerts": "Customize alerts", + "infrastructureDisplayNameLabel": "Display name", + "infrastructureEditMonitor": "Save Infrastructure Monitor", + "infrastructureEditYour": "Edit your", + "infrastructureMonitor": { + "fallback": { + "actionButton": "Let's create your first infrastructure monitor!", + "checks": [ + "Track the performance of your servers", + "Identify bottlenecks and optimize usage", + "Ensure reliability with real-time monitoring" + ], + "title": "An infrastructure monitor is used to:" + } + }, + "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", + "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", + "infrastructureProtocol": "Protocol", + "infrastructureServerRequirement": "The server you are monitoring must be running the", + "infrastructureServerUrlLabel": "Server URL", + "integrations": "Integrations", + "integrationsDiscord": "Discord", + "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", + "integrationsPrism": "Connect Prism to your favorite service.", + "integrationsSlack": "Slack", + "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", + "integrationsZapier": "Zapier", + "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", + "invalidFileFormat": "Unsupported file format!", + "invalidFileSize": "File size is too large!", + "inviteNoTokenFound": "No invite token found", + "LastName": "Last name", + "loginHere": "Login here", + "logsPage": { + "description": "System logs - last 1000 lines", + "logLevelSelect": { + "title": "Log level", + "values": { + "all": "All", + "debug": "Debug", + "error": "Error", + "info": "Info", + "warn": "Warn" + } + }, + "noLogs": "No logs found", + "table": { + "headers": { + "name": "Status page name", + "url": "Public URL" + }, + "level": "Level", + "logs": "logs", + "message": "Message", + "method": "Method", + "service": "Service", + "timestamp": "Timestamp" + }, + "tabs": { + "diagnostics": "Diagnostics", + "logs": "Server logs", + "queue": "Job queue" + }, + "title": "Logs", + "toast": { + "fetchLogsSuccess": "Logs fetched successfully" + } + }, + "low": "low", + "maintenance": "maintenance", + "maintenanceRepeat": "Maintenance Repeat", + "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", + "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:" + } + }, + "maintenanceWindowDescription": "During maintenance windows, all monitoring is suspended for selected monitors. No network checks will be performed, preventing any status updates or notifications from being triggered. Your monitors will appear frozen at their last known status, and status pages will display a maintenance indicator. Once the maintenance window ends, monitoring automatically resumes, and alerts will trigger if issues are detected. Maintenance periods do not count against uptime calculations.", + "maintenanceWindowName": "Maintenance Window Name", + "maskedPageSpeedKeyPlaceholder": "*************************************", + "matchMethod": "Match Method", + "matchMethodOptions": { + "equal": "Equal", + "equalPlaceholder": "success", + "include": "Include", + "includePlaceholder": "ok", + "regex": "Regex", + "regexPlaceholder": "^(success|ok)$", + "text": "Match Method" + }, + "MaxSize": "Maximum Size", + "mb": "MB", + "mem": "Mem", + "memory": "Memory", + "memoryUsage": "Memory usage", + "menu": { + "changelog": "Changelog", + "checks": "Checks", + "discussions": "Discussions", + "docs": "Docs", + "incidents": "Incidents", + "infrastructure": "Infrastructure", + "integrations": "Integrations", + "logOut": "Log out", + "logs": "Logs", + "maintenance": "Maintenance", + "notifications": "Notifications", + "pagespeed": "Pagespeed", + "password": "Password", + "profile": "Profile", + "settings": "Settings", + "statusPages": "Status pages", + "support": "Support", + "team": "Team", + "uptime": "Uptime" + }, + "message": "Message", + "monitor": "monitor", + "monitorActions": { + "deleteFailed": "Failed to delete monitor", + "deleteSuccess": "Monitor deleted successfully", + "details": "Details", + "export": "Export Monitors", + "import": "Import Monitors", + "title": "Export/Import" + }, + "monitorDisplayName": "Monitor display name", + "monitorHooks": { + "failureAddDemoMonitors": "Failed to add demo monitors", + "successAddDemoMonitors": "Successfully added demo monitors" + }, + "monitors": "monitors", + "monitorState": { + "active": "Active", + "paused": "Paused", + "resumed": "Resumed" + }, + "monitorStatus": { + "checkingEvery": "Checking every {{interval}}", + "down": "down", + "paused": "paused", + "up": "up", + "withCaptureAgent": "with Capture agent {{version}}" + }, + "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", + "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", + "monitorsToApply": "Monitors to apply maintenance window to", + "monitorType": { + "docker": { + "label": "Container Name/ID", + "namePlaceholder": "My Container", + "placeholder": "my-app or abcd1234" + }, + "game": { + "label": "URL to monitor", + "namePlaceholder": "localhost:5173", + "placeholder": "localhost" + }, + "http": { + "label": "URL to monitor", + "namePlaceholder": "Google", + "placeholder": "google.com" + }, + "ping": { + "label": "IP address to monitor", + "namePlaceholder": "Google", + "placeholder": "1.1.1.1" + }, + "port": { + "label": "URL to monitor", + "namePlaceholder": "Localhost:5173", + "placeholder": "localhost" + } + }, + "mountedOn": "Mounted on", + "ms": "ms", + "navControls": "Controls", + "network": "Network", + "networkDrops": "Network Drops", + "networkErrors": "Network Errors", + "networkInterface": "Network Interface", + "nextWindow": "Next window", + "noNetworkStatsAvailable": "No network stats available.", + "notFoundButton": "Go to the main dashboard", + "notificationConfig": { + "description": "Select the notifications channels you want to use", + "placeholder": "Search notifications...", + "title": "Notifications" + }, "notifications": { - "enableNotifications": "Enable {{platform}} notifications", - "testNotification": "Test notification", "addOrEditNotifications": "Add or edit notifications", - "slack": { - "label": "Slack", - "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL is required" + "create": { + "failed": "Failed to create notification", + "success": "Notification created successfully" + }, + "createButton": "Create notification channel", + "createTitle": "Notification channel", + "delete": { + "failed": "Failed to delete notification", + "success": "Notification deleted successfully" }, "discord": { - "label": "Discord", "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", + "label": "Discord", "webhookLabel": "Discord Webhook URL", "webhookPlaceholder": "https://discord.com/api/webhooks/...", "webhookRequired": "Discord webhook URL is required" }, - "telegram": { - "label": "Telegram", - "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", - "tokenLabel": "Your bot token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Your Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "Telegram token and chat ID are required" + "edit": { + "failed": "Failed to update notification", + "success": "Notification updated successfully" }, - "webhook": { - "label": "Webhooks", - "description": "You can set up a custom webhook to receive notifications when incidents occur.", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL is required" - }, - "testNotificationDevelop": "Test notification 2", - "integrationButton": "Notification Integration", - "testSuccess": "Test notification sent successfully!", - "testFailed": "Failed to send test notification", - "unsupportedType": "Unsupported notification type", - "networkError": "Network error occurred", + "enableNotifications": "Enable {{platform}} notifications", "fallback": { - "title": "A notification channel is used to:", + "actionButton": "Let's create your first notification channel!", "checks": [ "Alert teams about downtime or performance issues", "Let engineers know when incidents happen", "Keep administrators informed of system changes" ], - "actionButton": "Let's create your first notification channel!" - }, - "createButton": "Create notification channel", - "createTitle": "Notification channel", - "create": { - "success": "Notification created successfully", - "failed": "Failed to create notification" + "title": "A notification channel is used to:" }, "fetch": { - "success": "Notifications fetched successfully", - "failed": "Failed to fetch notifications" + "failed": "Failed to fetch notifications", + "success": "Notifications fetched successfully" }, - "delete": { - "success": "Notification deleted successfully", - "failed": "Failed to delete notification" + "integrationButton": "Notification Integration", + "networkError": "Network error occurred", + "slack": { + "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", + "label": "Slack", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/...", + "webhookRequired": "Slack webhook URL is required" }, - "edit": { - "success": "Notification updated successfully", - "failed": "Failed to update notification" + "telegram": { + "chatIdLabel": "Your Chat ID", + "chatIdPlaceholder": "-1001234567890", + "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", + "fieldsRequired": "Telegram token and chat ID are required", + "label": "Telegram", + "tokenLabel": "Your bot token", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" }, "test": { - "success": "Test notification sent successfully", - "failed": "Failed to send test notification" - } - }, - "add": "Add", - "monitors": "monitors", - "components": { - "headerTimeRange": { - "labels": { - "recent": "Recent", - "day": "Day", - "week": "Week", - "month": "Month" - }, - "description": { - "recent": "Showing statistics for past 2 hours.", - "day": "Showing statistics for past 24 hours.", - "week": "Showing statistics for past 7 days.", - "month": "Showing statistics for past 30 days." - } + "failed": "Failed to send test notification", + "success": "Test notification sent successfully" + }, + "testFailed": "Failed to send test notification", + "testNotification": "Test notification", + "testNotificationDevelop": "Test notification 2", + "testSuccess": "Test notification sent successfully!", + "unsupportedType": "Unsupported notification type", + "webhook": { + "description": "You can set up a custom webhook to receive notifications when incidents occur.", + "label": "Webhooks", + "urlLabel": "Webhook URL", + "urlPlaceholder": "https://your-server.com/webhook", + "urlRequired": "Webhook URL is required" } }, + "notifyEmails": "Also notify via email to multiple addresses (coming soon)", + "notifySMS": "Notify via SMS (coming soon)", + "now": "Now", + "os": "OS", + "packetsPerSecond": "Packets per second", + "packetsReceived": "Packets Received", + "packetsReceivedRate": "Packets Received Rate", + "packetsSent": "Packets Sent", "pages": { - "common": { - "monitors": { - "status": { - "up": "up", - "down": "down", - "paused": "paused", - "total": "total", - "initializing": "initializing" - }, - "actions": { - "openSite": "Open site", - "details": "Details", - "incidents": "Incidents", - "configure": "Configure", - "pause": "Pause", - "resume": "Resume", - "delete": "Delete" - }, - "statBoxes": { - "activeFor": "Active for", - "lastCheck": "Last check", - "lastResponseTime": "Last response time", - "certificateExpiry": "Certificate expiry" - } - } - }, - "createMonitor": { - "form": { - "type": { - "description": "Select the type of check to perform", - "optionType": "Type", - "title": "Type", - "optionHttp": "HTTP(S)", - "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", - "optionPing": "Ping", - "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", - "optionDocker": "Docker", - "optionDockerDescription": "Use Docker to monitor if a container is running.", - "optionPort": "Port", - "optionPortDescription": "Monitor if a specific port on a server is open.", - "optionGame": "Game", - "optionGameDescription": "Monitor if a specific game server is online.", - "optionPagespeed": "PageSpeed", - "optionPagespeedDescription": "Analyze page performance using Google PageSpeed Insights.", - "optionHardware": "Hardware", - "optionHardwareDescription": "Monitor server hardware metrics like CPU, memory, and disk." - }, - "general": { - "title": "General settings", - "description": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", - "pagespeed": "Enter the URL of the page to analyze with Google PageSpeed and add a clear display name that appears on the dashboard.", - "hardware": "Enter the URL of your infrastructure agent, the authorization secret, and a clear display name that appears on the dashboard." - }, - "option": { - "url": { - "label": "URL", - "placeholder": "https://www.google.com" - }, - "name": { - "label": "Display name", - "placeholder": "e.g. My Website" - }, - "host": { - "label": "Host", - "placeholder": "192.168.1.100 or example.com" - }, - "container": { - "label": "Container name/ID", - "placeholder": "my-app or abcd1234" - }, - "secret": { - "label": "Authorization secret", - "placeholder": "Enter your secret key" - } - } - }, - "frequency": { - "title": "Check frequency", - "description": "How often do you want to check the status of this monitor?", - "option": { - "frequency": { - "label": "Check frequency" - } - } - }, - "incidents": { - "title": "Incidents", - "description": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", - "option": { - "checks": { - "label": "Number of checks in sliding window" - }, - "percentage": { - "label": "What percentage of checks in the sliding window fail/succeed before monitor status changes?" - } - } - }, - "notifications": { - "title": "Notifications", - "description": "Select the notification channels you want to use" - }, - "ignoreTls": { - "title": "TLS/SSL settings", - "description": "Configure TLS/SSL certificate validation for HTTPS connections.", - "option": { - "tls": { - "label": "Ignore TLS/SSL errors" - } - } - }, - "advanced": { - "title": "Advanced settings", - "description": "Optional settings for advanced use cases", - "option": { - "advancedMatching": { - "label": "Use advanced matching" - }, - "matchMethod": { - "label": "Match method" - }, - "expectedValue": { - "label": "Expected value", - "description": "The expected value is used to match against response result, and the match determines the status." - }, - "jsonPath": { - "label": "JSONPath expression", - "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value. See jmespath.org for query language documentation." - } - } - } - } - }, - "uptime": { - "table": { - "headers": { - "responseTime": "Response time" - } - }, - "fallback": { - "title": "An uptime monitor is used to:", - "actionButton": "Create a monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ] - }, - "filters": { - "search": { - "placeholder": "Search monitors..." - } - } - }, "checks": { - "table": { - "headers": { - "dateTime": "Date & time", - "statusCode": "Status code" - }, - "empty": "No down checks in this time range" - }, "selects": { "monitor": { "all": "All monitors" @@ -412,71 +875,246 @@ "down": "Down", "up": "Up" } - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:", - "actionButton": "Let's create your first PageSpeed monitor!" }, "table": { + "empty": "No down checks in this time range", "headers": { - "pageSpeedScore": "PageSpeed score" + "dateTime": "Date & time", + "statusCode": "Status code" + } + } + }, + "common": { + "monitors": { + "actions": { + "configure": "Configure", + "delete": "Delete", + "details": "Details", + "incidents": "Incidents", + "openSite": "Open site", + "pause": "Pause", + "resume": "Resume" + }, + "statBoxes": { + "activeFor": "Active for", + "certificateExpiry": "Certificate expiry", + "lastCheck": "Last check", + "lastResponseTime": "Last response time" + }, + "status": { + "down": "down", + "initializing": "initializing", + "paused": "paused", + "total": "total", + "up": "up" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "Optional settings for advanced use cases", + "option": { + "advancedMatching": { + "label": "Use advanced matching" + }, + "expectedValue": { + "description": "The expected value is used to match against response result, and the match determines the status.", + "label": "Expected value" + }, + "jsonPath": { + "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value. See jmespath.org for query language documentation.", + "label": "JSONPath expression" + }, + "matchMethod": { + "label": "Match method" + } + }, + "title": "Advanced settings" + }, + "frequency": { + "description": "How often do you want to check the status of this monitor?", + "option": { + "frequency": { + "label": "Check frequency" + } + }, + "title": "Check frequency" + }, + "general": { + "description": { + "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", + "hardware": "Enter the URL of your infrastructure agent, the authorization secret, and a clear display name that appears on the dashboard.", + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "pagespeed": "Enter the URL of the page to analyze with Google PageSpeed and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." + }, + "option": { + "container": { + "label": "Container name/ID", + "placeholder": "my-app or abcd1234" + }, + "host": { + "label": "Host", + "placeholder": "192.168.1.100 or example.com" + }, + "name": { + "label": "Display name", + "placeholder": "e.g. My Website" + }, + "secret": { + "label": "Authorization secret", + "placeholder": "Enter your secret key" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + } + }, + "title": "General settings" + }, + "ignoreTls": { + "description": "Configure TLS/SSL certificate validation for HTTPS connections.", + "option": { + "tls": { + "label": "Ignore TLS/SSL errors" + } + }, + "title": "TLS/SSL settings" + }, + "incidents": { + "description": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", + "option": { + "checks": { + "label": "Number of checks in sliding window" + }, + "percentage": { + "label": "What percentage of checks in the sliding window fail/succeed before monitor status changes?" + } + }, + "title": "Incidents" + }, + "notifications": { + "description": "Select the notification channels you want to use", + "title": "Notifications" + }, + "type": { + "description": "Select the type of check to perform", + "optionDocker": "Docker", + "optionDockerDescription": "Use Docker to monitor if a container is running.", + "optionGame": "Game", + "optionGameDescription": "Monitor if a specific game server is online.", + "optionHardware": "Hardware", + "optionHardwareDescription": "Monitor server hardware metrics like CPU, memory, and disk.", + "optionHttp": "HTTP(S)", + "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", + "optionPagespeed": "PageSpeed", + "optionPagespeedDescription": "Analyze page performance using Google PageSpeed Insights.", + "optionPing": "Ping", + "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", + "optionPort": "Port", + "optionPortDescription": "Monitor if a specific port on a server is open.", + "optionType": "Type", + "title": "Type" + } + } + }, + "incidents": { + "dialog": { + "details": { + "analysis": "Incident analysis", + "comment": "Comment:", + "detailsLabel": "Details", + "downtime": "Downtime:", + "message": "Message:", + "monitor": "Monitor:", + "overview": "Overview", + "resolutionDetails": "Resolution details", + "resolutionType": "Type:", + "resolutionTypes": { + "automatic": "Automatic", + "manual": "Manual" + }, + "resolve": "Resolve Incident", + "resolvedAt": "Resolved at:", + "resolvedBy": "Resolved by:", + "startedAt": "Started at:", + "status": "Status:", + "statusCode": "Status code:", + "timeline": "Timeline", + "title": "Incident details", + "url": "URL:" } }, - "charts": { - "common": { - "cls": "Cumulative Layout Shift (CLS)", - "fcp": "First Contentful Paint (FCP)", - "lcp": "Largest Contentful Paint (LCP)", - "si": "Speed Index (SI)", - "tbt": "Total Blocking Time (TBT)" + "filters": { + "allMonitors": "All monitors", + "clearFilters": "Clear Filters", + "monitor": "Monitor", + "resolutionType": "Resolution type", + "resolutionTypes": { + "automatic": "Automatic", + "manual": "Manual" + } + }, + "summaryCard": { + "activeIncidents": { + "active_one": "{{count}} Active incident", + "active_other": "{{count}} Active incidents", + "active_zero": "No active incidents", + "title": "Active Incidents" }, - "pie": { - "title": "Performance report" + "incidentStats": { + "avgResolutionTime": "Average resolution time", + "mostAffectedMonitor": "Most affected monitor", + "title": "Incident Statistics", + "totalIncidents": "Total incidents" }, - "legend": { - "title": "PageSpeed report", - "weight": "Weight" + "latestIncidents": { + "title": "Latest Incidents" + } + }, + "table": { + "actions": { + "details": "Details", + "goToMonitor": "Go to monitor", + "resolveManually": "Resolve Manually" + }, + "activeIncidents": "Active Incidents", + "headers": { + "endTime": "End Time", + "resolutionType": "Resolution Type", + "startTime": "Start Time", + "statusCode": "Status Code" + }, + "resolvedIncidents": "Resolved Incidents", + "status": { + "active": "Active", + "na": "N/A", + "resolved": "Resolved" } } }, "infrastructure": { + "charts": { + "labels": { + "cpu": "CPU usage", + "disk": "Disk usage", + "memory": "Memory usage", + "netBytesRecv": "{{name}} - Bytes Received", + "netBytesSent": "{{name}} - Bytes Sent", + "temp": "Temp" + } + }, "fallback": { + "actionButton": "Let's create your first infrastructure monitor!", "checks": [ "Track the performance of your servers", "Identify bottlenecks and optimize usage", "Ensure reliability with real-time monitoring" ], - "title": "An infrastructure monitor is used to:", - "actionButton": "Let's create your first infrastructure monitor!" - }, - "table": { - "headers": { - "cpu": "CPU", - "disk": "Disk", - "memory": "Memory" - } - }, - "tabs": { - "labels": { - "overview": "Overview", - "network": "Network" - } - }, - "statBoxes": { - "cpuPhysical": "CPU (Physical)", - "cpuLogical": "CPU (Logical)", - "cpuFrequency": "CPU Frequency", - "avgCpuTemperature": "Average CPU Temperature", - "memory": "Memory", - "disk": "Disk", - "os": "OS" + "title": "An infrastructure monitor is used to:" }, "gauges": { "cpu": { @@ -495,14 +1133,26 @@ "upperLabel": "Used" } }, - "charts": { + "statBoxes": { + "avgCpuTemperature": "Average CPU Temperature", + "cpuFrequency": "CPU Frequency", + "cpuLogical": "CPU (Logical)", + "cpuPhysical": "CPU (Physical)", + "disk": "Disk", + "memory": "Memory", + "os": "OS" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disk", + "memory": "Memory" + } + }, + "tabs": { "labels": { - "memory": "Memory usage", - "cpu": "CPU usage", - "temp": "Temp", - "disk": "Disk usage", - "netBytesSent": "{{name}} - Bytes Sent", - "netBytesRecv": "{{name}} - Bytes Received" + "network": "Network", + "overview": "Overview" } } }, @@ -516,33 +1166,7 @@ ], "title": "Notification channles are used to:" }, - "table": { - "headers": { - "destination": "Destination" - } - }, "form": { - "notificationName": { - "description": "A descriptive name for the notification channel", - "optionName": "Channel name", - "placeholder": "e.g. Production Alerts", - "title": "Channel name" - }, - "type": { - "description": "Select the type of notification channel to create.", - "optionType": "Type", - "title": "Channel type" - }, - "homeServer": { - "description": "Select the home server for this notification channel.", - "optionHomeServer": "Home server", - "placeholder": "example.com", - "title": "Home server" - }, - "roomId": { - "optionRoomId": "Room ID", - "placeholder": "!abcdefg:matrix.org" - }, "accessToken": { "optionAccessToken": "Access token", "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" @@ -554,102 +1178,77 @@ "placeholderWebhook": "https://your-server.com/webhook", "title": "Address" }, + "homeServer": { + "description": "Select the home server for this notification channel.", + "optionHomeServer": "Home server", + "placeholder": "example.com", + "title": "Home server" + }, + "matrix": { + "description": "Configure your Matrix homeserver connection for notifications.", + "title": "Matrix configuration" + }, + "notificationName": { + "description": "A descriptive name for the notification channel", + "optionName": "Channel name", + "placeholder": "e.g. Production Alerts", + "title": "Channel name" + }, "pagerDuty": { "description": "Your PagerDuty integration key for receiving alerts.", "optionIntegrationKey": "Integration key", "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "title": "Integration key" }, - "matrix": { - "description": "Configure your Matrix homeserver connection for notifications.", - "title": "Matrix configuration" + "roomId": { + "optionRoomId": "Room ID", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Select the type of notification channel to create.", + "optionType": "Type", + "title": "Channel type" + } + }, + "table": { + "headers": { + "destination": "Destination" } } }, - "incidents": { - "summaryCard": { - "activeIncidents": { - "title": "Active Incidents", - "active_zero": "No active incidents", - "active_one": "{{count}} Active incident", - "active_other": "{{count}} Active incidents" + "pageSpeed": { + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" }, - "latestIncidents": { "title": "Latest Incidents" }, - "incidentStats": { - "title": "Incident Statistics", - "totalIncidents": "Total incidents", - "mostAffectedMonitor": "Most affected monitor", - "avgResolutionTime": "Average resolution time" + "legend": { + "title": "PageSpeed report", + "weight": "Weight" + }, + "pie": { + "title": "Performance report" } }, - + "fallback": { + "actionButton": "Let's create your first PageSpeed monitor!", + "checks": [ + "Report on the user experience of a page", + "Help analyze webpage speed", + "Give suggestions on how the page can be improved" + ], + "title": "A PageSpeed monitor is used to:" + }, "table": { - "activeIncidents": "Active Incidents", - "resolvedIncidents": "Resolved Incidents", "headers": { - "startTime": "Start Time", - "endTime": "End Time", - "resolutionType": "Resolution Type", - "statusCode": "Status Code" - }, - "status": { - "active": "Active", - "resolved": "Resolved", - "na": "N/A" - }, - "actions": { - "details": "Details", - "goToMonitor": "Go to monitor", - "resolveManually": "Resolve Manually" - } - }, - "filters": { - "monitor": "Monitor", - "allMonitors": "All monitors", - "resolutionType": "Resolution type", - "resolutionTypes": { - "automatic": "Automatic", - "manual": "Manual" - }, - "clearFilters": "Clear Filters" - }, - "dialog": { - "details": { - "title": "Incident details", - "resolve": "Resolve Incident", - "overview": "Overview", - "status": "Status:", - "monitor": "Monitor:", - "url": "URL:", - "analysis": "Incident analysis", - "timeline": "Timeline", - "detailsLabel": "Details", - "startedAt": "Started at:", - "statusCode": "Status code:", - "downtime": "Downtime:", - "message": "Message:", - "resolutionDetails": "Resolution details", - "resolvedAt": "Resolved at:", - "resolutionType": "Type:", - "resolvedBy": "Resolved by:", - "comment": "Comment:", - "resolutionTypes": { - "automatic": "Automatic", - "manual": "Manual" - } + "pageSpeedScore": "PageSpeed score" } } }, "statusPages": { - "title": "Status Pages", - "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" - }, - "published": "Published", - "unpublished": "Unpublished" - }, "fallback": { "actionButton": "Create a status page!", "checks": [ @@ -659,671 +1258,149 @@ ], "title": "A status page is used to:" }, - "serviceStauts": "Service stauts", - "statusBar": { - "allUp": "All systems operational", - "allDown": "All systems down", - "degraded": "Degraded performance", - "unknown": "Unknown status" - }, "monitorsList": { "chartTypeHeatmap": "Heatmap", "chartTypeHistogram": "Histogram" - } - } - }, - "used": "Used", - "total": "Total", - "cores": "Cores", - "frequency": "Frequency", - "device": "Device", - "mountedOn": "Mounted on", - "status": "Status", - "cpuPhysical": "CPU (Physical)", - "cpuLogical": "CPU (Logical)", - "cpuFrequency": "CPU Frequency", - "avgCpuTemperature": "Average CPU Temperature", - "memory": "Memory", - "disk": "Disk", - "uptime": "Uptime", - "os": "OS", - "host": "Host", - "actions": "Actions", - "integrations": "Integrations", - "integrationsPrism": "Connect Prism to your favorite service.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", - "commonSave": "Save", - "createYour": "Create your", - "createMonitor": "Create monitor", - "pause": "Pause", - "resume": "Resume", - "editing": "Editing...", - "url": "URL", - "access": "Access", - "timezone": "Timezone", - "features": "Features", - "administrator": "Administrator?", - "loginHere": "Login here", - "displayName": "Display name", - "urlMonitor": "URL to monitor", - "portToMonitor": "Port to monitor", - "websiteMonitoring": "Website monitoring", - "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", - "pingMonitoring": "Ping monitoring", - "pingMonitoringDescription": "Check whether your server is available or not.", - "dockerContainerMonitoring": "Docker container monitoring", - "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", - "portMonitoring": "Port monitoring", - "portMonitoringDescription": "Check whether your port is open or not.", - "createMaintenanceWindow": "Create maintenance window", - "createMaintenance": "Create maintenance", - "editMaintenance": "Edit maintenance", - "maintenanceWindowName": "Maintenance Window Name", - "friendlyNameInput": "Friendly name", - "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", - "maintenanceRepeat": "Maintenance Repeat", - "maintenance": "maintenance", - "duration": "Duration", - "addMonitors": "Add monitors", - "window": "window", - "cancel": "Cancel", - "close": "Close", - "message": "Message", - "low": "low", - "high": "high", - "statusCode": "Status code", - "date&Time": "Date & Time", - "type": "Type", - "repeat": "Repeat", - "edit": "Edit", - "createA": "Create a", - "remove": "Remove", - "maintenanceWindowDescription": "During maintenance windows, all monitoring is suspended for selected monitors. No network checks will be performed, preventing any status updates or notifications from being triggered. Your monitors will appear frozen at their last known status, and status pages will display a maintenance indicator. Once the maintenance window ends, monitoring automatically resumes, and alerts will trigger if issues are detected. Maintenance periods do not count against uptime calculations.", - "startTime": "Start time", - "timeZoneInfo": "All dates and times are in GMT+0 time zone.", - "monitorsToApply": "Monitors to apply maintenance window to", - "nextWindow": "Next window", - "notFoundButton": "Go to the main dashboard", - "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", - "monitorDisplayName": "Monitor display name", - "whenNewIncident": "When there is a new incident,", - "notifySMS": "Notify via SMS (coming soon)", - "notifyEmails": "Also notify via email to multiple addresses (coming soon)", - "seperateEmails": "You can separate multiple emails with a comma", - "checkFrequency": "Check frequency", - "matchMethod": "Match Method", - "expectedValue": "Expected value", - "deleteDialogTitle": "Do you really want to delete this monitor?", - "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", - "pageSpeedMonitor": "PageSpeed monitor", - "shown": "Shown", - "ago": "ago", - "companyName": "Company name", - "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", - "pageSpeedDetailsPerformanceReportCalculator": "See calculator", - "checkingEvery": "Checking every", - "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", - "basicInformation": "Basic Information", - "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", - "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", - "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", - "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", - "statusPageCreateTabsContent": "Status page servers", - "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", - "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", - "showCharts": "Show charts", - "showUptimePercentage": "Show uptime percentage", - "removeLogo": "Remove Logo", - "statusPageStatusServiceStatus": "Service status", - "deleteStatusPage": "Do you want to delete this status page?", - "deleteStatusPageConfirm": "Yes, delete status page", - "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", - "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", - "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", - "uptimeCreateJsonPathQuery": "for query language documentation.", - "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "infrastructureEditYour": "Edit your", - "infrastructureEditMonitor": "Save Infrastructure Monitor", - "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", - "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", - "errorInvalidTypeId": "Invalid notification type provided", - "errorInvalidFieldId": "Invalid field ID provided", - "inviteNoTokenFound": "No invite token found", - "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", - "pageSpeedLearnMoreLink": "Click here", - "pageSpeedAddApiKey": "to add your API key.", - "update": "Update", - "invalidFileFormat": "Unsupported file format!", - "invalidFileSize": "File size is too large!", - "ClickUpload": "Click to upload", - "DragandDrop": "drag and drop", - "MaxSize": "Maximum Size", - "SupportedFormats": "Supported formats", - "FirstName": "First name", - "LastName": "Last name", - "EmailDescriptionText": "This is your current email address — it cannot be changed.", - "YourPhoto": "Profile photo", - "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "save": "Save", - "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", - "DeleteWarningTitle": "Really remove this account?", - "bulkImport": { - "title": "Bulk Import", - "selectFileTips": "Select CSV file to upload", - "selectFileDescription": "You can download our or sample", - "selectFile": "Select File", - "parsingFailed": "Parsing failed", - "uploadSuccess": "Monitors created successfully!", - "validationFailed": "Validation failed", - "noFileSelected": "No file selected", - "fallbackPage": "Import a file to upload a list of servers in bulk", - "invalidFileType": "Invalid file type", - "uploadFailed": "Upload failed" - }, - "DeleteAccountTitle": "Remove account", - "DeleteAccountButton": "Remove account", - "publicLink": "Public link", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Reset", - "ignoreTLSError": "Ignore TLS/SSL error", - "tlsErrorIgnored": "TLS/SSL errors ignored", - "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", - "createNew": "Create new", - "greeting": { - "prepend": "Hey there", - "append": "The afternoon is your playground—let's make it epic!", - "overview": "Here's an overview of your {{type}} monitors." - }, - "roles": { - "superAdmin": "Super admin", - "admin": "Admin", - "teamMember": "Team member", - "demoUser": "Demo user" - }, - "teamPanel": { - "teamMembers": "Team members", - "filter": { - "all": "All", - "member": "Member" - }, - "inviteTeamMember": "Invite a team member", - "inviteNewTeamMember": "Invite new team member", - "inviteDescription": "When you add a new team member, they will get access to all monitors.", - "email": "Email", - "selectRole": "Select role", - "inviteLink": "Invite link", - "cancel": "Cancel", - "noMembers": "There are no team members with this role", - "getToken": "Get token", - "emailToken": "E-mail token", - "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" }, - "name": "Name", - "email": "Email", - "role": "Role", - "created": "Created" + "serviceStauts": "Service stauts", + "statusBar": { + "allDown": "All systems down", + "allUp": "All systems operational", + "degraded": "Degraded performance", + "unknown": "Unknown status" + }, + "table": { + "headers": { + "name": "Status page name", + "url": "Public URL" + }, + "published": "Published", + "unpublished": "Unpublished" + }, + "title": "Status Pages" }, - "addTeamMember": { - "addMemberMenu": "Add Team Member", - "title": "Register new team member", - "description": "Create a new user and share the credentials with them. This method gives the member immediate access to all monitors.", - "addButton": "Add Member" - }, - "register": "Register a team member", - "registerToast": { - "success": "User created, share credentials with the member securely.", - "dbUserExists": "User already exists.", - "unknownError": "Unknown error occurred." - }, - "registerTeamMember": { - "title": "Register team member", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "Please enter a name", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "lastName": { - "errors": { - "empty": "Please enter a surname", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "email": { - "errors": { - "empty": "To continue, please enter an email address", - "invalid": "Please recheck validity of entered email address" - } - }, - "role": { - "errors": { - "empty": "Role is required" - } - } - } + "uptime": { + "fallback": { + "actionButton": "Create a monitor!", + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ], + "title": "An uptime monitor is used to:" + }, + "filters": { + "search": { + "placeholder": "Search monitors..." + } + }, + "table": { + "headers": { + "responseTime": "Response time" } } - }, - "role": "Role", - "changeTeamPassword": { - "changePasswordMenu": "Reset Password", - "title": "Reset team member password", - "description": "Create a new password for this team member. You will need to share the password with them securely.", - "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way." } }, - "monitorState": { - "paused": "Paused", - "resumed": "Resumed", - "active": "Active" + "pageSpeed": { + "fallback": { + "actionButton": "Let's create your first PageSpeed monitor!", + "checks": [ + "Report on the user experience of a page", + "Help analyze webpage speed", + "Give suggestions on how the page can be improved" + ], + "title": "A PageSpeed monitor is used to:" + } }, - "menu": { - "uptime": "Uptime", - "pagespeed": "Pagespeed", - "infrastructure": "Infrastructure", - "incidents": "Incidents", - "statusPages": "Status pages", - "maintenance": "Maintenance", - "integrations": "Integrations", - "settings": "Settings", - "support": "Support", - "discussions": "Discussions", - "docs": "Docs", - "changelog": "Changelog", - "profile": "Profile", - "password": "Password", - "team": "Team", - "logOut": "Log out", - "notifications": "Notifications", - "logs": "Logs", - "checks": "Checks" - }, - "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", - "state": "State", - "statusBreadCrumbsStatusPages": "Status Pages", - "statusBreadCrumbsDetails": "Details", - "commonSaving": "Saving...", - "navControls": "Controls", - "incidentsPageTitle": "Incidents", - "checksPageTitle": "Checks", + "pageSpeedAddApiKey": "to add your API key.", + "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", + "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", + "pageSpeedDetailsPerformanceReportCalculator": "See calculator", + "pageSpeedLearnMoreLink": "Click here", + "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", + "pageSpeedMonitor": "PageSpeed monitor", + "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", "passwordPanel": { - "passwordChangedSuccess": "Your password was changed successfully.", - "passwordInputIncorrect": "Your password input was incorrect.", + "confirmNewPassword": "Confirm new password", "currentPassword": "Current password", "enterCurrentPassword": "Enter your current password", - "newPassword": "New password", "enterNewPassword": "Enter your new password", - "confirmNewPassword": "Confirm new password", + "newPassword": "New password", + "passwordChangedSuccess": "Your password was changed successfully.", + "passwordInputIncorrect": "Your password input was incorrect.", "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`).", "saving": "Saving..." }, - "emailSent": "Email sent successfully", - "failedToSendEmail": "Failed to send email", - "settingsTestEmailSuccess": "Test email sent successfully", - "settingsTestEmailFailed": "Failed to send test email", - "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", - "settingsTestEmailUnknownError": "Unknown error", - "statusMsg": { - "paused": "Monitoring is paused.", - "up": "Your site is up.", - "down": "Your site is down.", - "pending": "Pending..." - }, - "uptimeGeneralInstructions": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", - "https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard." - }, - "auth": { - "common": { - "navigation": { - "continue": "Continue", - "back": "Back" - }, - "inputs": { - "email": { - "label": "Email", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "To continue, please enter your email address", - "invalid": "Please recheck validity of entered email address" - } - }, - "password": { - "label": "Password", - "rules": { - "length": { - "beginning": "Must be at least", - "highlighted": "8 characters long" - }, - "special": { - "beginning": "Must contain at least", - "highlighted": "one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" - }, - "number": { - "beginning": "Must contain at least", - "highlighted": "one number" - }, - "uppercase": { - "beginning": "Must contain at least", - "highlighted": "one upper character" - }, - "lowercase": { - "beginning": "Must contain at least", - "highlighted": "one lower character" - }, - "match": { - "beginning": "Passwords", - "highlighted": "must match" - } - }, - "errors": { - "empty": "Please enter your password", - "length": "Password must be at least 8 characters long", - "uppercase": "Password must contain at least 1 uppercase letter", - "lowercase": "Password must contain at least 1 lowercase letter", - "number": "Password must contain at least 1 number", - "special": "Password must contain at least 1 special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", - "incorrect": "The password you provided does not match our records" - } - }, - "passwordConfirm": { - "label": "Confirm password", - "placeholder": "Re-enter password to confirm", - "errors": { - "empty": "Please enter your password again for confirmation (helps with typos)", - "different": "Entered passwords don't match, so one of them is probably mistyped" - } - }, - "firstName": { - "label": "Name", - "placeholder": "Jordan", - "errors": { - "empty": "Please enter your name", - "length": "Name must be less than 50 characters", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "lastName": { - "label": "Surname", - "placeholder": "Ellis", - "errors": { - "empty": "Please enter your surname", - "length": "Surname must be less than 50 characters", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - } - } - }, - "errors": { - "validation": "Error validating data." - }, - "fields": { - "password": { - "errors": { - "incorrect": "The password you provided does not match our records" - } - }, - "role": { - "errors": { - "min": "At least one role is required" - } - } - } - }, - "login": { - "heading": "Log in to continue", - "subheadings": { - "stepOne": "Enter your email", - "stepTwo": "Enter your password" - }, - "links": { - "forgotPassword": "Forgot password?", - "register": "Do not have an account?", - "forgotPasswordLink": "Reset password", - "registerLink": "Register here" - }, - "toasts": { - "success": "Welcome back! You're successfully logged in.", - "incorrectPassword": "Incorrect password" - }, - "errors": { - "password": { - "incorrect": "The password you provided does not match our records" - } - }, - "welcome": "Welcome back to Checkmate!" - }, - "registration": { - "heading": { - "superAdmin": "Create super admin", - "user": "Sign Up" - }, - "subheadings": { - "stepOne": "Enter your personal details", - "stepTwo": "Enter your email", - "stepThree": "Create your password" - }, - "description": { - "superAdmin": "Create your super admin account to get started", - "user": "Sign up as a user and ask super admin for access to your monitors" - }, - "gettingStartedButton": { - "superAdmin": "Create super admin account", - "user": "Sign up regular user" - }, - "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", - "links": { - "login": "Already have an account? Log In" - }, - "toasts": { - "success": "Welcome! Your account was created successfully." - }, - "welcome": "Welcome to Checkmate!" - }, - "forgotPassword": { - "heading": "Forgot password?", - "subheadings": { - "stepOne": "No worries, we'll send you reset instructions.", - "stepTwo": "We sent a password reset link to ", - "stepThree": "Your new password must be different from previously used passwords.", - "stepFour": "Your password has been successfully reset. Click below to log in magically." - }, - "buttons": { - "openEmail": "Open email app", - "resetPassword": "Reset password" - }, - "imageAlts": { - "passwordKey": "Password key icon", - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon" - }, - "links": { - "login": "Go back to Log In", - "resend": "Didn't receive the email? Click to resend" - }, - "toasts": { - "sent": "Instructions sent to .", - "emailNotFound": "Email not found.", - "redirect": "Redirecting in ...", - "success": "Your password was reset successfully.", - "error": "Unable to reset password. Please try again later or contact support." - } - } - }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "Successfully reconnected to the server.", - "stillUnreachable": "Server is still unreachable. Please try again later." - }, - "alertBox": "Server Connection Error", - "description": "We're unable to connect to the server. Please check your internet connection or verify your deployment configuration if the problem persists.", - "retryButton": { - "default": "Retry connection", - "processing": "Connecting..." - } - } - }, - "createNotifications": { - "title": "Create notification channel", - "nameSettings": { - "title": "Name", - "description": "A descriptive name for your integration.", - "nameLabel": "Name", - "namePlaceholder": "e.g. Slack notifications" - }, - "typeSettings": { - "title": "Type", - "description": "Select the type of notification channel you want to create.", - "typeLabel": "Type" - }, - "emailSettings": { - "title": "Email", - "description": "Destination email addresses.", - "emailLabel": "Email address", - "emailPlaceholder": "e.g. john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "Configure your Slack webhook here", - "webhookLabel": "Slack webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "Configure your PagerDuty integration here", - "integrationKeyLabel": "Integration key", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Configure your Discord webhook here", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "Configure your webhook here", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Test notification", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "dialogDeleteConfirm": "Delete" - }, - "notificationConfig": { - "title": "Notifications", - "description": "Select the notifications channels you want to use", - "placeholder": "Search notifications..." - }, - "monitorStatus": { - "checkingEvery": "Checking every {{interval}}", - "withCaptureAgent": "with Capture agent {{version}}", - "up": "up", - "down": "down", - "paused": "paused" - }, - "advancedMatching": "Advanced matching", - "sendTestNotifications": "Send test notifications", - "selectAll": "Select all", - "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", - "logsPage": { - "title": "Logs", - "description": "System logs - last 1000 lines", - "tabs": { - "queue": "Job queue", - "logs": "Server logs", - "diagnostics": "Diagnostics" - }, - "toast": { - "fetchLogsSuccess": "Logs fetched successfully" - }, - "logLevelSelect": { - "title": "Log level", - "values": { - "all": "All", - "info": "Info", - "warn": "Warn", - "error": "Error", - "debug": "Debug" - } - }, - "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" - }, - "timestamp": "Timestamp", - "level": "Level", - "service": "Service", - "method": "Method", - "message": "Message", - "logs": "logs" - }, - "noLogs": "No logs found" - }, + "pause": "Pause", + "PhotoDescriptionText": "This photo will be displayed in your profile page.", + "pingMonitoring": "Ping monitoring", + "pingMonitoringDescription": "Check whether your server is available or not.", + "portMonitoring": "Port monitoring", + "portMonitoringDescription": "Check whether your port is open or not.", + "portToMonitor": "Port to monitor", + "publicLink": "Public link", "queuePage": { - "title": "Queue", - "refreshButton": "Refresh", - "flushButton": "Flush queue", - "jobTable": { - "title": "Jobs currently in queue", - "idHeader": "Monitor ID", - "urlHeader": "URL", - "typeHeader": "Type", - "activeHeader": "Active", - "lockedAtHeader": "Locked at", - "runCountHeader": "Run count", - "failCountHeader": "Fail count", - "lastRunHeader": "Last run at", - "lastFinishedAtHeader": "Last finished at", - "lastRunTookHeader": "Last run took", - "intervalHeader": "Interval" - }, - "metricsTable": { - "title": "Queue metrics", - "metricHeader": "Metric", - "valueHeader": "Value" - }, "failedJobTable": { - "title": "Failed jobs", - "monitorIdHeader": "Monitor ID", - "monitorUrlHeader": "Monitor URL", "failCountHeader": "Fail count", "failedAtHeader": "Last failed at", - "failReasonHeader": "Fail reason" - } + "failReasonHeader": "Fail reason", + "monitorIdHeader": "Monitor ID", + "monitorUrlHeader": "Monitor URL", + "title": "Failed jobs" + }, + "flushButton": "Flush queue", + "jobTable": { + "activeHeader": "Active", + "failCountHeader": "Fail count", + "idHeader": "Monitor ID", + "intervalHeader": "Interval", + "lastFinishedAtHeader": "Last finished at", + "lastRunHeader": "Last run at", + "lastRunTookHeader": "Last run took", + "lockedAtHeader": "Locked at", + "runCountHeader": "Run count", + "title": "Jobs currently in queue", + "typeHeader": "Type", + "urlHeader": "URL" + }, + "metricsTable": { + "metricHeader": "Metric", + "title": "Queue metrics", + "valueHeader": "Value" + }, + "refreshButton": "Refresh", + "title": "Queue" }, - "export": { - "title": "Export Monitors", - "success": "Monitors exported successfully!", - "failed": "Failed to export monitors" - }, - "monitorActions": { - "title": "Export/Import", - "import": "Import Monitors", - "export": "Export Monitors", - "deleteSuccess": "Monitor deleted successfully", - "deleteFailed": "Failed to delete monitor", - "details": "Details" + "rate": "Rate", + "remove": "Remove", + "removeLogo": "Remove Logo", + "repeat": "Repeat", + "reset": "Reset", + "response": "RESPONSE", + "responseTime": "Response time", + "responseTimeBreakdown": "Response time breakdown", + "resume": "Resume", + "roles": { + "admin": "Admin", + "demoUser": "Demo user", + "superAdmin": "Super admin", + "teamMember": "Team member" }, + "save": "Save", + "selectAll": "Select all", + "selectInterface": "Select Interface", + "sendTestNotifications": "Send test notifications", + "seperateEmails": "You can separate multiple emails with a comma", + "settingsAppearance": "Appearance", + "settingsDisabled": "Disabled", + "settingsDisplayTimezone": "Display timezone", + "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", + "settingsFailedToClearStats": "Failed to clear stats", + "settingsFailedToDeleteMonitors": "Failed to delete all monitors", + "settingsFailedToSave": "Failed to save settings", + "settingsGeneralSettings": "General settings", + "settingsMonitorsDeleted": "Successfully deleted all monitors", "settingsPage": { "aboutSettings": { "labelDevelopedBy": "Developed by Bluewave Labs", @@ -1357,10 +1434,14 @@ "title": "Email", "toastEmailRequiredFieldsError": "Email address, host, port and password are required" }, + "globalThresholds": { + "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring.", + "title": "Global Thresholds" + }, "pageSpeedSettings": { "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", - "labelApiKeySet": "API key is set. Click Reset to change it.", "labelApiKey": "PageSpeed API key", + "labelApiKeySet": "API key is set. Click Reset to change it.", "title": "Google PageSpeed API key" }, "saveButtonLabel": "Save", @@ -1390,12 +1471,12 @@ }, "title": "Settings", "uiSettings": { + "chartTypeHeatmap": "Heatmap", + "chartTypeHistogram": "Histogram", "description": "Switch between light and dark mode, or change user interface language.", + "labelChartType": "Chart type", "labelLanguage": "Language", "labelTheme": "Theme mode", - "labelChartType": "Chart type", - "chartTypeHistogram": "Histogram", - "chartTypeHeatmap": "Heatmap", "title": "Appearance" }, "urlSettings": { @@ -1404,296 +1485,215 @@ "selectDisabled": "Disabled", "selectEnabled": "Enabled", "title": "Monitor IP/URL on Status Page" - }, - "globalThresholds": { - "title": "Global Thresholds", - "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." } }, - "statusPageCreate": { - "buttonSave": "Save" - }, - "incidentsOptionsHeaderFilterResolved": "Resolved", "settingsSave": "Save", - "statusPageCreateAppearanceTitle": "Appearance", - "confirmPassword": "Confirm password", - "monitorHooks": { - "failureAddDemoMonitors": "Failed to add demo monitors", - "successAddDemoMonitors": "Successfully added demo monitors" + "settingsStatsCleared": "Stats cleared successfully", + "settingsSuccessSaved": "Settings saved successfully", + "settingsTestEmailFailed": "Failed to send test email", + "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", + "settingsTestEmailSuccess": "Test email sent successfully", + "settingsTestEmailUnknownError": "Unknown error", + "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", + "showCharts": "Show charts", + "shown": "Shown", + "showUptimePercentage": "Show uptime percentage", + "starPromptDescription": "See the latest releases and help grow the community on GitHub", + "starPromptTitle": "Star Checkmate", + "startTime": "Start time", + "state": "State", + "status": "Status", + "statusBreadCrumbsDetails": "Details", + "statusBreadCrumbsStatusPages": "Status Pages", + "statusCode": "Status code", + "statusMsg": { + "down": "Your site is down.", + "paused": "Monitoring is paused.", + "pending": "Pending...", + "up": "Your site is up." }, - "settingsAppearance": "Appearance", - "settingsDisplayTimezone": "Display timezone", - "settingsGeneralSettings": "General settings", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "statusPage": { - "deleteSuccess": "Status page deleted successfully", - "deleteFailed": "Failed to delete status page", - "createSuccess": "Status page created successfully", - "updateSuccess": "Status page updated successfully", - "generalSettings": "General settings", "contents": "Contents", + "createSuccess": "Status page created successfully", + "deleteFailed": "Failed to delete status page", + "deleteSuccess": "Status page deleted successfully", "fallback": { + "actionButton": "Let's create your first status page!", "checks": [ "Monitor and display the health of your services in real time", "Track multiple services and share their status", "Keep users informed about outages and performance" ], - "title": "A status page is used to:", - "actionButton": "Let's create your first status page!" - } + "title": "A status page is used to:" + }, + "generalSettings": "General settings", + "updateSuccess": "Status page updated successfully" + }, + "statusPageCreate": { + "buttonSave": "Save" + }, + "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", + "statusPageCreateAppearanceTitle": "Appearance", + "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", + "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", + "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", + "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", + "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", + "statusPageCreateTabsContent": "Status page servers", + "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", + "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", + "statusPageStatusServiceStatus": "Service status", + "submit": "Submit", + "SupportedFormats": "Supported formats", + "tcpConnection": "TCP connection", + "teamPanel": { + "addTeamMember": { + "addButton": "Add Member", + "addMemberMenu": "Add Team Member", + "description": "Create a new user and share the credentials with them. This method gives the member immediate access to all monitors.", + "title": "Register new team member" + }, + "cancel": "Cancel", + "changeTeamPassword": { + "changePasswordMenu": "Reset Password", + "description": "Create a new password for this team member. You will need to share the password with them securely.", + "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way.", + "title": "Reset team member password" + }, + "email": "Email", + "emailToken": "E-mail token", + "filter": { + "all": "All", + "member": "Member" + }, + "getToken": "Get token", + "inviteDescription": "When you add a new team member, they will get access to all monitors.", + "inviteLink": "Invite link", + "inviteNewTeamMember": "Invite new team member", + "inviteTeamMember": "Invite a team member", + "noMembers": "There are no team members with this role", + "register": "Register a team member", + "registerTeamMember": { + "auth": { + "common": { + "inputs": { + "email": { + "errors": { + "empty": "To continue, please enter an email address", + "invalid": "Please recheck validity of entered email address" + } + }, + "firstName": { + "errors": { + "empty": "Please enter a name", + "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" + } + }, + "lastName": { + "errors": { + "empty": "Please enter a surname", + "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" + } + }, + "role": { + "errors": { + "empty": "Role is required" + } + } + } + } + }, + "title": "Register team member" + }, + "registerToast": { + "dbUserExists": "User already exists.", + "success": "User created, share credentials with the member securely.", + "unknownError": "Unknown error occurred." + }, + "role": "Role", + "selectRole": "Select role", + "table": { + "created": "Created", + "email": "Email", + "headers": { + "name": "Status page name", + "url": "Public URL" + }, + "name": "Name", + "role": "Role" + }, + "teamMembers": "Team members" }, "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", - "incidentsTableResolvedAt": "Resolved at", - "incidentsTableActionResolve": "Resolve", - "checkHooks": { - "failureResolveOne": "Failed to resolve incident.", - "failureResolveAll": "Failed to resolve all incidents.", - "failureResolveMonitor": "Failed to resolve monitor incidents." - }, - "checkFormError": "Please check the form for errors.", - "diagnosticsPage": { - "diagnosticDescription": "System diagnostics", - "statsDescription": "System statistics", - "gauges": { - "heapAllocationTitle": "Heap allocation", - "heapAllocationSubtitle": "% of available memory", - "heapUsageTitle": "Heap usage", - "heapUsageSubtitle": "% of available memory", - "heapUtilizationTitle": "Heap utilization", - "heapUtilizationSubtitle": "% of allocated", - "instantCpuUsageTitle": "Instant CPU usage", - "instantCpuUsageSubtitle": "% of 1s used by CPU" - }, - "stats": { - "eventLoopDelayTitle": "Event loop delay", - "uptimeTitle": "Uptime", - "usedHeapSizeTitle": "Used heap size", - "totalHeapSizeTitle": "Total heap size", - "osMemoryLimitTitle": "OS Memory Limit" - } - }, - "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", "time": { - "fifteenSeconds": "15 seconds", - "thirtySeconds": "30 seconds", "fifteenMinutes": "15 minutes", - "thirtyMinutes": "30 minutes", - "threeMinutes": "3 minutes", + "fifteenSeconds": "15 seconds", "fiveMinutes": "5 minutes", - "tenMinutes": "10 minutes", - "twentyMinutes": "20 minutes", - "oneHour": "1 hour", - "oneDay": "1 day", - "oneWeek": "1 week", "fourMinutes": "4 minutes", + "oneDay": "1 day", + "oneHour": "1 hour", "oneMinute": "1 minute", + "oneWeek": "1 week", + "tenMinutes": "10 minutes", + "thirtyMinutes": "30 minutes", + "thirtySeconds": "30 seconds", + "threeMinutes": "3 minutes", + "twentyMinutes": "20 minutes", "twoMinutes": "2 minutes" }, - "general": { - "noOptionsFound": "No {{unit}} found" + "timeToFirstByte": "Time to first byte", + "timezone": "Timezone", + "timeZoneInfo": "All dates and times are in GMT+0 time zone.", + "title": "Title", + "tlsErrorIgnored": "TLS/SSL errors ignored", + "tlsHandshake": "TLS handshake", + "total": "Total", + "type": "Type", + "update": "Update", + "uptime": "Uptime", + "uptimeAdvancedMatching": { + "jsonPath": "JSON Path" }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:", - "actionButton": "Let's create your first infrastructure monitor!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "Mark your maintenance periods", - "Eliminate any misunderstandings", - "Stop sending alerts in maintenance windows" - ], - "title": "A maintenance window is used to:", - "actionButton": "Let's create your first maintenance window!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:", - "actionButton": "Let's create your first PageSpeed monitor!" - } + "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", + "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", + "uptimeCreateJsonPathQuery": "for query language documentation.", + "uptimeGeneralInstructions": { + "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." }, "uptimeMonitor": { "fallback": { + "actionButton": "Let's create your first uptime monitor!", "checks": [ "Check if websites or servers are online & responsive", "Alert teams about downtime or performance issues", "Monitor HTTP endpoints, pings, containers & ports", "Track historical uptime and reliability trends" ], - "title": "An uptime monitor is used to:", - "actionButton": "Let's create your first uptime monitor!" + "title": "An uptime monitor is used to:" } }, - "editUserPage": { - "form": { - "email": "Email", - "firstName": "First name", - "lastName": "Last name", - "role": "Roles", - "save": "Save" - }, - "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" - }, - "actionHeader": "Action", - "roleHeader": "Role" - }, - "title": "Edit user", - "toast": { - "successUserUpdate": "User updated successfully", - "validationErrors": "Validation errors" - } - }, - "incidentsPageActionResolveMonitor": "Resolve monitor incidents", - "checksPageActionResolveMonitor": "Resolve monitor checks", - "incidentsPageActionResolveAll": "Resolve all incidents", - "checksPageActionResolveAllMonitor": "Resolve all checks", - "matchMethodOptions": { - "equal": "Equal", - "equalPlaceholder": "success", - "include": "Include", - "includePlaceholder": "ok", - "regex": "Regex", - "regexPlaceholder": "^(success|ok)$", - "text": "Match Method" - }, - "monitorType": { - "docker": { - "label": "Container Name/ID", - "namePlaceholder": "My Container", - "placeholder": "my-app or abcd1234" - }, - "http": { - "label": "URL to monitor", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "IP address to monitor", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "URL to monitor", - "namePlaceholder": "Localhost:5173", - "placeholder": "localhost" - }, - "game": { - "label": "URL to monitor", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "JSON Path" - }, - "bytesPerSecond": "Bytes per second", - "bytesReceived": "Bytes Received", - "bytesSent": "Bytes Sent", - "chooseGame": "Choose game", - "createMonitorPage": { - "incidentConfigDescription": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", - "incidentConfigStatusWindowLabel": "How many checks should be in the sliding window?", - "incidentConfigStatusWindowThresholdLabel": "What percentage of checks in the sliding window fail/succeed before monitor status changes?", - "incidentConfigTitle": "Incidents", - "incidentConfigDescriptionV2": "Number of consecutive checks needed to change monitor status. Maximum 25", - "incidentConfigStatusCheckNumber": "How many checks before status change?", - "intervalTitle": "Check interval", - "intervalDescription": "How often the monitor should be checked." - }, - "dataRate": "Data Rate", - "dataReceived": "Data Received", - "dataSent": "Data Sent", - "details": "Details", - "drops": "Drops", - "errors": "Errors", - "errorsIn": "Errors In", - "errorsOut": "Errors Out", - "gameServerMonitoring": "Game Server Monitoring", - "gameServerMonitoringDescription": "Check whether your game server is running or not", - "network": "Network", - "networkDrops": "Network Drops", - "networkErrors": "Network Errors", - "networkInterface": "Network Interface", - "noNetworkStatsAvailable": "No network stats available.", - "packetsPerSecond": "Packets per second", - "packetsReceived": "Packets Received", - "packetsReceivedRate": "Packets Received Rate", - "packetsSent": "Packets Sent", - "rate": "Rate", - "selectInterface": "Select Interface", + "url": "URL", + "urlMonitor": "URL to monitor", + "used": "Used", "v1": { "infrastructure": { - "disk_selection_title": "Disk Selection", + "disk_selection_description": "Select the specific disks you want to monitor.", "disk_selection_info": "No disk detected for the moment.", - "disk_selection_description": "Select the specific disks you want to monitor." + "disk_selection_title": "Disk Selection" } }, - "incidentsPage": { - "title": "Incidents", - "description": "Manage incidents for your monitors. You can resolve incidents here.", - "resolved": "Resolved", - "active": "Active", - "startTime": "Start Time", - "endTime": "End Time", - "resolutionType": "Resolution Type", - "unresolved": "Unresolved", - "all": "All", - "filterByMonitor": "Filter by monitor", - "filterByStatus": "Filter by status", - "filterByType": "Filter by type", - "filterByDate": "Filter by date", - "filterByPriority": "Filter by priority", - "incidentsActivePanelError": "Error loading active incidents", - "allSystemsAreOperational": "All Systems Are Operational", - "incidentsActivePanelTitle": "Total Active Incidents", - "incidentsLatestPanelError": "Error loading latest incidents", - "incidentsLatestPanelTitle": "Latest Incidents", - "incidentsLatestPanelEmpty": "No recent incidents", - "incidentItemMonitor": "Monitor", - "incidentItemStatus": "Status", - "unknownMonitor": "Unknown Monitor", - "incidentsStatisticsPanelError": "Error loading statistics", - "incidentsStatisticsPanelTitle": "General Statistics", - "totalIncidents": "Total Incidents", - "mostAffectedMonitor": "Most Affected Monitor", - "avgResolutionTime": "Avg. Resolution Time", - "resolutions": "Resolutions", - "automatic": "Automatic", - "manual": "Manual", - "incidentResolvedSuccessfully": "Incident resolved successfully", - "incidentResolvedFailed": "Error resolving incident", - "errorFetchingIncidents": "Error fetching incidents", - "errorFetchingIncident": "Error fetching incident", - "noIncidentIdProvided": "No incident ID provided", - "errorFetchingIncidentSummary": "Error fetching incident summary", - "incidentsOptionsHeaderFilterManual": "Manual", - "incidentsOptionsHeaderFilterAutomatic": "Automatic", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterActive": "Active", - "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsTableResolved": "Closed", - "incidentsTableActionResolveManually": "Resolve Manually", - "incidentsTableActionDetails": "Details", - "incidentsTableActionGoToMonitor": "Go to monitor", - "resolveIncidentDialogTitle": "Resolve Incident", - "resolveIncidentDialogCommentLabel": "Comment (optional)", - "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", - "resolveIncidentDialogConfirm": "Resolve" - } + "waitTime": "Wait time", + "webhookSendError": "Error sending webhook notification to {platform}", + "webhookSendSuccess": "Webhook notification sent successfully", + "webhookUnsupportedPlatform": "Unsupported platform: {platform}", + "websiteMonitoring": "Website monitoring", + "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", + "whenNewIncident": "When there is a new incident,", + "window": "window", + "YourPhoto": "Profile photo" } From 3566da75150b0c8a04f7ebd9fafac76a5da87089 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 18:02:24 +0000 Subject: [PATCH 134/392] uptime strings --- client/src/locales/en.json | 320 ++++++++----------------------------- 1 file changed, 64 insertions(+), 256 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c6ad859f0..f6aac1cbc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -231,26 +231,6 @@ "details": "Details", "home": "Home" }, - "imageUpload": { - "clickToUpload": "Click to upload", - "orDragAndDrop": "or drag and drop", - "maxSize": "Max size", - "supportedFormats": "Supported formats" - }, - "errors": { - "invalidFileFormat": "Invalid file format", - "invalidFileSize": "File size exceeds maximum allowed" - }, - "imageUpload": { - "clickToUpload": "Click to upload", - "orDragAndDrop": "or drag and drop", - "maxSize": "Max size", - "supportedFormats": "Supported formats" - }, - "errors": { - "invalidFileFormat": "Invalid file format", - "invalidFileSize": "File size exceeds maximum allowed" - }, "buttons": { "cancel": "Cancel", "configure": "Configure", @@ -280,7 +260,17 @@ "title": "Are you sure you want to delete this?" } }, + "errors": { + "invalidFileFormat": "Invalid file format", + "invalidFileSize": "File size exceeds maximum allowed" + }, "forDocumentation": "for query language documentation.", + "imageUpload": { + "clickToUpload": "Click to upload", + "maxSize": "Max size", + "orDragAndDrop": "or drag and drop", + "supportedFormats": "Supported formats" + }, "labels": { "active": "Active", "na": "N/A", @@ -1278,38 +1268,22 @@ ], "title": "A status page is used to:" }, - "monitorsList": { - "chartTypeHeatmap": "Heatmap", - "chartTypeHistogram": "Histogram" - }, - "serviceStauts": "Service stauts", - "statusBar": { - "allDown": "All systems down", - "allUp": "All systems operational", - "degraded": "Degraded performance", - "unknown": "Unknown status" - }, - "monitorsList": { - "chartTypeHeatmap": "Heatmap", - "chartTypeHistogram": "Histogram" - }, "form": { "access": { - "title": "Access", "description": "Choose if you want your status page to be public", "option": { "published": { "name": "Published and publicly visible" } - } + }, + "title": "Access" }, "appearance": { - "title": "Appearance", "description": "Define the default look and feel of your public status page", - "option": {} + "option": {}, + "title": "Appearance" }, "basicInfo": { - "title": "Basic information", "description": "Name and address of your status page", "option": { "name": { @@ -1320,10 +1294,10 @@ "label": "URL", "placeholder": "my-status-page" } - } + }, + "title": "Basic information" }, "monitors": { - "title": "Monitors", "description": "Select which monitors to display on your status page", "noMonitors": "No monitors available. Create uptime monitors first.", "option": { @@ -1331,209 +1305,62 @@ "label": "Monitors", "placeholder": "Select monitors..." } - } + }, + "title": "Monitors" }, "timezone": { - "title": "Timezone", "description": "Select the timezone for your status page", "option": { "timezone": { "label": "Timezone", "placeholder": "Search timezones..." } - } + }, + "title": "Timezone" } }, + "monitorsList": { + "chartTypeHeatmap": "Heatmap", + "chartTypeHistogram": "Histogram" + }, + "serviceStauts": "Service stauts", "settings": { - "title": "Status page settings", "description": "Configure basic settings for your status page.", "option": { "name": { "label": "Status page name", "placeholder": "e.g. My Company Status Page" }, + "published": { + "label": "Published and visible to the public" + }, "subdomain": { "label": "Subdomain", "placeholder": "e.g. my-company" }, "timezone": { "label": "Timezone" - }, - "published": { - "label": "Published and visible to the public" } - } - } - } - }, - "used": "Used", - "total": "Total", - "status": "Status", - "uptime": "Uptime", - "actions": "Actions", - "integrations": "Integrations", - "integrationsPrism": "Connect Prism to your favorite service.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", - "commonSave": "Save", - "pause": "Pause", - "resume": "Resume", - "access": "Access", - "timezone": "Timezone", - "features": "Features", - "administrator": "Administrator?", - "loginHere": "Login here", - "portToMonitor": "Port to monitor", - "createMaintenanceWindow": "Create maintenance window", - "createMaintenance": "Create maintenance", - "editMaintenance": "Edit maintenance", - "maintenanceWindowName": "Maintenance Window Name", - "friendlyNameInput": "Friendly name", - "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", - "maintenanceRepeat": "Maintenance Repeat", - "maintenance": "maintenance", - "duration": "Duration", - "addMonitors": "Add monitors", - "window": "window", - "cancel": "Cancel", - "close": "Close", - "type": "Type", - "repeat": "Repeat", - "edit": "Edit", - "createA": "Create a", - "remove": "Remove", - "maintenanceWindowDescription": "During maintenance windows, all monitoring is suspended for selected monitors. No network checks will be performed, preventing any status updates or notifications from being triggered. Your monitors will appear frozen at their last known status, and status pages will display a maintenance indicator. Once the maintenance window ends, monitoring automatically resumes, and alerts will trigger if issues are detected. Maintenance periods do not count against uptime calculations.", - "startTime": "Start time", - "timeZoneInfo": "All dates and times are in GMT+0 time zone.", - "monitorsToApply": "Monitors to apply maintenance window to", - "nextWindow": "Next window", - "notFoundButton": "Go to the main dashboard", - "companyName": "Company name", - "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", - "basicInformation": "Basic Information", - "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", - "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", - "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", - "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", - "statusPageCreateTabsContent": "Status page servers", - "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", - "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", - "showCharts": "Show charts", - "showUptimePercentage": "Show uptime percentage", - "removeLogo": "Remove Logo", - "statusPageStatusServiceStatus": "Service status", - "deleteStatusPage": "Do you want to delete this status page?", - "deleteStatusPageConfirm": "Yes, delete status page", - "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", - "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "inviteNoTokenFound": "No invite token found", - "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", - "pageSpeedLearnMoreLink": "Click here", - "pageSpeedAddApiKey": "to add your API key.", - "update": "Update", - "invalidFileFormat": "Unsupported file format!", - "invalidFileSize": "File size is too large!", - "ClickUpload": "Click to upload", - "DragandDrop": "drag and drop", - "MaxSize": "Maximum Size", - "SupportedFormats": "Supported formats", - "FirstName": "First name", - "LastName": "Last name", - "EmailDescriptionText": "This is your current email address — it cannot be changed.", - "YourPhoto": "Profile photo", - "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "save": "Save", - "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", - "DeleteWarningTitle": "Really remove this account?", - "bulkImport": { - "title": "Bulk Import", - "selectFileTips": "Select CSV file to upload", - "selectFileDescription": "You can download our or sample", - "selectFile": "Select File", - "uploadSuccess": "Monitors created successfully!", - "noFileSelected": "No file selected", - "fallbackPage": "Import a file to upload a list of servers in bulk", - "invalidFileType": "Invalid file type", - "uploadFailed": "Upload failed" - }, - "DeleteAccountTitle": "Remove account", - "DeleteAccountButton": "Remove account", - "publicLink": "Public link", - "reset": "Reset", - "createNew": "Create new", - "greeting": { - "prepend": "Hey there", - "append": "The afternoon is your playground—let's make it epic!", - "overview": "Here's an overview of your {{type}} monitors." - }, - "roles": { - "superAdmin": "Super admin", - "admin": "Admin", - "teamMember": "Team member", - "demoUser": "Demo user" - }, - "teamPanel": { - "teamMembers": "Team members", - "filter": { - "all": "All", - "member": "Member" - }, - "inviteTeamMember": "Invite a team member", - "inviteNewTeamMember": "Invite new team member", - "inviteDescription": "When you add a new team member, they will get access to all monitors.", - "email": "Email", - "selectRole": "Select role", - "inviteLink": "Invite link", - "cancel": "Cancel", - "noMembers": "There are no team members with this role", - "getToken": "Get token", - "emailToken": "E-mail token", - "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" + }, + "title": "Status page settings" }, - "name": "Name", - "email": "Email", - "role": "Role", - "created": "Created" - }, - "addTeamMember": { - "addMemberMenu": "Add Team Member", - "title": "Register new team member", - "description": "Create a new user and share the credentials with them. This method gives the member immediate access to all monitors.", - "addButton": "Add Member" - }, - "register": "Register a team member", - "registerToast": { - "success": "User created, share credentials with the member securely." + "statusBar": { + "allDown": "All systems down", + "allUp": "All systems operational", + "degraded": "Degraded performance", + "unknown": "Unknown status" + } }, "uptime": { - "fallback": { - "actionButton": "Create a monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:" + "table": { + "headers": { + "responseTime": "Response time" + } }, "filters": { "search": { "placeholder": "Search monitors..." } - }, - "table": { - "headers": { - "responseTime": "Response time" - } } } }, @@ -1789,12 +1616,6 @@ "title": "Register new team member" }, "cancel": "Cancel", - "changeTeamPassword": { - "changePasswordMenu": "Reset Password", - "description": "Create a new password for this team member. You will need to share the password with them securely.", - "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way.", - "title": "Reset team member password" - }, "email": "Email", "emailToken": "E-mail token", "filter": { @@ -1808,44 +1629,9 @@ "inviteTeamMember": "Invite a team member", "noMembers": "There are no team members with this role", "register": "Register a team member", - "registerTeamMember": { - "auth": { - "common": { - "inputs": { - "email": { - "errors": { - "empty": "To continue, please enter an email address", - "invalid": "Please recheck validity of entered email address" - } - }, - "firstName": { - "errors": { - "empty": "Please enter a name", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "lastName": { - "errors": { - "empty": "Please enter a surname", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "role": { - "errors": { - "empty": "Role is required" - } - } - } - } - }, - "title": "Register team member" - }, "registerToast": { - "dbUserExists": "User already exists.", - "success": "User created, share credentials with the member securely.", - "unknownError": "Unknown error occurred." + "success": "User created, share credentials with the member securely." }, - "role": "Role", "selectRole": "Select role", "table": { "created": "Created", @@ -1857,7 +1643,29 @@ "name": "Name", "role": "Role" }, - "teamMembers": "Team members" + "teamMembers": "Team members", + "uptime": { + "fallback": { + "actionButton": "Create a monitor!", + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ], + "title": "An uptime monitor is used to:" + }, + "filters": { + "search": { + "placeholder": "Search monitors..." + } + }, + "table": { + "headers": { + "responseTime": "Response time" + } + } + } }, "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { From 7426418629324e917600119d6f061c299ea1642d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 18:03:26 +0000 Subject: [PATCH 135/392] fix uptime --- client/src/locales/en.json | 177 +++++++++++++++++++++++-------------- 1 file changed, 110 insertions(+), 67 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ad387a215..f6aac1cbc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -260,7 +260,17 @@ "title": "Are you sure you want to delete this?" } }, + "errors": { + "invalidFileFormat": "Invalid file format", + "invalidFileSize": "File size exceeds maximum allowed" + }, "forDocumentation": "for query language documentation.", + "imageUpload": { + "clickToUpload": "Click to upload", + "maxSize": "Max size", + "orDragAndDrop": "or drag and drop", + "supportedFormats": "Supported formats" + }, "labels": { "active": "Active", "na": "N/A", @@ -1258,47 +1268,99 @@ ], "title": "A status page is used to:" }, + "form": { + "access": { + "description": "Choose if you want your status page to be public", + "option": { + "published": { + "name": "Published and publicly visible" + } + }, + "title": "Access" + }, + "appearance": { + "description": "Define the default look and feel of your public status page", + "option": {}, + "title": "Appearance" + }, + "basicInfo": { + "description": "Name and address of your status page", + "option": { + "name": { + "label": "Name", + "placeholder": "My status page" + }, + "url": { + "label": "URL", + "placeholder": "my-status-page" + } + }, + "title": "Basic information" + }, + "monitors": { + "description": "Select which monitors to display on your status page", + "noMonitors": "No monitors available. Create uptime monitors first.", + "option": { + "monitors": { + "label": "Monitors", + "placeholder": "Select monitors..." + } + }, + "title": "Monitors" + }, + "timezone": { + "description": "Select the timezone for your status page", + "option": { + "timezone": { + "label": "Timezone", + "placeholder": "Search timezones..." + } + }, + "title": "Timezone" + } + }, "monitorsList": { "chartTypeHeatmap": "Heatmap", "chartTypeHistogram": "Histogram" }, "serviceStauts": "Service stauts", + "settings": { + "description": "Configure basic settings for your status page.", + "option": { + "name": { + "label": "Status page name", + "placeholder": "e.g. My Company Status Page" + }, + "published": { + "label": "Published and visible to the public" + }, + "subdomain": { + "label": "Subdomain", + "placeholder": "e.g. my-company" + }, + "timezone": { + "label": "Timezone" + } + }, + "title": "Status page settings" + }, "statusBar": { "allDown": "All systems down", "allUp": "All systems operational", "degraded": "Degraded performance", "unknown": "Unknown status" - }, - "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" - }, - "published": "Published", - "unpublished": "Unpublished" - }, - "title": "Status Pages" + } }, "uptime": { - "fallback": { - "actionButton": "Create a monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:" + "table": { + "headers": { + "responseTime": "Response time" + } }, "filters": { "search": { "placeholder": "Search monitors..." } - }, - "table": { - "headers": { - "responseTime": "Response time" - } } } }, @@ -1554,12 +1616,6 @@ "title": "Register new team member" }, "cancel": "Cancel", - "changeTeamPassword": { - "changePasswordMenu": "Reset Password", - "description": "Create a new password for this team member. You will need to share the password with them securely.", - "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way.", - "title": "Reset team member password" - }, "email": "Email", "emailToken": "E-mail token", "filter": { @@ -1573,44 +1629,9 @@ "inviteTeamMember": "Invite a team member", "noMembers": "There are no team members with this role", "register": "Register a team member", - "registerTeamMember": { - "auth": { - "common": { - "inputs": { - "email": { - "errors": { - "empty": "To continue, please enter an email address", - "invalid": "Please recheck validity of entered email address" - } - }, - "firstName": { - "errors": { - "empty": "Please enter a name", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "lastName": { - "errors": { - "empty": "Please enter a surname", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "role": { - "errors": { - "empty": "Role is required" - } - } - } - } - }, - "title": "Register team member" - }, "registerToast": { - "dbUserExists": "User already exists.", - "success": "User created, share credentials with the member securely.", - "unknownError": "Unknown error occurred." + "success": "User created, share credentials with the member securely." }, - "role": "Role", "selectRole": "Select role", "table": { "created": "Created", @@ -1622,7 +1643,29 @@ "name": "Name", "role": "Role" }, - "teamMembers": "Team members" + "teamMembers": "Team members", + "uptime": { + "fallback": { + "actionButton": "Create a monitor!", + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ], + "title": "An uptime monitor is used to:" + }, + "filters": { + "search": { + "placeholder": "Search monitors..." + } + }, + "table": { + "headers": { + "responseTime": "Response time" + } + } + } }, "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { From 12b61d17f2fd6405b4fe456f57bc8cf5abd71b24 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 18:09:51 +0000 Subject: [PATCH 136/392] initial commit --- client/src/locales/en.json | 935 ++++--------------------------------- 1 file changed, 91 insertions(+), 844 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index f6aac1cbc..ed0f7fcf5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,67 +1,42 @@ { + "ClickUpload": "Click to upload", + "DeleteAccountButton": "Remove account", + "DeleteAccountTitle": "Remove account", + "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", + "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", + "DeleteWarningTitle": "Really remove this account?", + "DragandDrop": "drag and drop", + "EmailDescriptionText": "This is your current email address — it cannot be changed.", + "FirstName": "First name", + "LastName": "Last name", + "MaxSize": "Maximum Size", + "PhotoDescriptionText": "This photo will be displayed in your profile page.", + "SupportedFormats": "Supported formats", + "YourPhoto": "Profile photo", "aboutus": "About Us", "access": "Access", "actions": "Actions", "add": "Add", "addMonitors": "Add monitors", - "administrator": "Administrator?", - "advancedMatching": "Advanced matching", - "ago": "ago", - "area": "Area", "auth": { "common": { "errors": { "validation": "Error validating data." }, - "fields": { - "password": { - "errors": { - "incorrect": "The password you provided does not match our records" - } - }, - "role": { - "errors": { - "min": "At least one role is required" - } - } - }, "inputs": { "email": { - "errors": { - "empty": "To continue, please enter your email address", - "invalid": "Please recheck validity of entered email address" - }, "label": "Email", "placeholder": "jordan.ellis@domain.com" }, "firstName": { - "errors": { - "empty": "Please enter your name", - "length": "Name must be less than 50 characters", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - }, "label": "Name", "placeholder": "Jordan" }, "lastName": { - "errors": { - "empty": "Please enter your surname", - "length": "Surname must be less than 50 characters", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - }, "label": "Surname", "placeholder": "Ellis" }, "password": { - "errors": { - "empty": "Please enter your password", - "incorrect": "The password you provided does not match our records", - "length": "Password must be at least 8 characters long", - "lowercase": "Password must contain at least 1 lowercase letter", - "number": "Password must contain at least 1 number", - "special": "Password must contain at least 1 special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", - "uppercase": "Password must contain at least 1 uppercase letter" - }, "label": "Password", "rules": { "length": { @@ -91,16 +66,11 @@ } }, "passwordConfirm": { - "errors": { - "different": "Entered passwords don't match, so one of them is probably mistyped", - "empty": "Please enter your password again for confirmation (helps with typos)" - }, "label": "Confirm password", "placeholder": "Re-enter password to confirm" } }, "navigation": { - "back": "Back", "continue": "Continue" } }, @@ -110,21 +80,10 @@ "resetPassword": "Reset password" }, "heading": "Forgot password?", - "imageAlts": { - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon", - "passwordKey": "Password key icon" - }, - "links": { - "login": "Go back to Log In", - "resend": "Didn't receive the email? Click to resend" - }, "subheadings": { "stepFour": "Your password has been successfully reset. Click below to log in magically.", "stepOne": "No worries, we'll send you reset instructions.", - "stepThree": "Your new password must be different from previously used passwords.", - "stepTwo": "We sent a password reset link to " + "stepThree": "Your new password must be different from previously used passwords." }, "toasts": { "emailNotFound": "Email not found.", @@ -147,10 +106,6 @@ "register": "Do not have an account?", "registerLink": "Register here" }, - "subheadings": { - "stepOne": "Enter your email", - "stepTwo": "Enter your password" - }, "toasts": { "incorrectPassword": "Incorrect password", "success": "Welcome back! You're successfully logged in." @@ -162,70 +117,32 @@ "superAdmin": "Create your super admin account to get started", "user": "Sign up as a user and ask super admin for access to your monitors" }, - "gettingStartedButton": { - "superAdmin": "Create super admin account", - "user": "Sign up regular user" - }, "heading": { - "superAdmin": "Create super admin", "user": "Sign Up" }, - "links": { - "login": "Already have an account? Log In" - }, - "subheadings": { - "stepOne": "Enter your personal details", - "stepThree": "Create your password", - "stepTwo": "Enter your email" - }, - "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", "toasts": { "success": "Welcome! Your account was created successfully." }, "welcome": "Welcome to Checkmate!" } }, - "avgCpuTemperature": "Average CPU Temperature", - "bar": "Bar", "basicInformation": "Basic Information", "bulkImport": { "fallbackPage": "Import a file to upload a list of servers in bulk", "invalidFileType": "Invalid file type", "noFileSelected": "No file selected", - "parsingFailed": "Parsing failed", "selectFile": "Select File", - "selectFileDescription": "You can download our or sample", "selectFileTips": "Select CSV file to upload", "title": "Bulk Import", "uploadFailed": "Upload failed", - "uploadSuccess": "Monitors created successfully!", - "validationFailed": "Validation failed" + "uploadSuccess": "Monitors created successfully!" }, - "bytesPerSecond": "Bytes per second", - "bytesReceived": "Bytes Received", - "bytesSent": "Bytes Sent", "cancel": "Cancel", - "checkFormError": "Please check the form for errors.", - "checkFrequency": "Check frequency", "checkHooks": { - "failureResolveAll": "Failed to resolve all incidents.", - "failureResolveMonitor": "Failed to resolve monitor incidents.", "failureResolveOne": "Failed to resolve incident." }, - "checkingEvery": "Checking every", - "checksPageActionResolveAllMonitor": "Resolve all checks", - "checksPageActionResolveMonitor": "Resolve monitor checks", - "checksPageTitle": "Checks", "chooseGame": "Choose game", - "city": "CITY", - "ClickUpload": "Click to upload", - "close": "Close", "common": { - "alerts": { - "pageSpeedApiKey": { - "content": "Warning: You haven't added a Google PageSpeed API key yet. Visit Settings to add one. Without it, the PageSpeed monitor won't function." - } - }, "appName": "Checkmate", "breadcrumbs": { "details": "Details", @@ -260,30 +177,16 @@ "title": "Are you sure you want to delete this?" } }, - "errors": { - "invalidFileFormat": "Invalid file format", - "invalidFileSize": "File size exceeds maximum allowed" - }, - "forDocumentation": "for query language documentation.", - "imageUpload": { - "clickToUpload": "Click to upload", - "maxSize": "Max size", - "orDragAndDrop": "or drag and drop", - "supportedFormats": "Supported formats" - }, "labels": { "active": "Active", "na": "N/A", "resolved": "Resolved", "responseTime": "Response time" }, - "monitoringAgentName": "Capture", - "see": "See", "table": { "empty": "Nothing here", "headers": { "actions": "Actions", - "active": "Active", "dateTime": "Date & time", "message": "Message", "monitor": "Monitor", @@ -296,6 +199,16 @@ "checkConnection": "Please check your connection", "networkError": "Network error", "unknownError": "Unknown error" + }, + "imageUpload": { + "clickToUpload": "Click to upload", + "dragAndDrop": "or drag and drop", + "supportedFormats": "Supported formats", + "maxSize": "Max size" + }, + "errors": { + "invalidFileType": "Invalid file type", + "fileTooLarge": "File too large" } }, "commonSave": "Save", @@ -303,12 +216,6 @@ "companyName": "Company name", "components": { "headerTimeRange": { - "description": { - "day": "Showing statistics for past 24 hours.", - "month": "Showing statistics for past 30 days.", - "recent": "Showing statistics for past 2 hours.", - "week": "Showing statistics for past 7 days." - }, "labels": { "day": "Day", "month": "Month", @@ -319,94 +226,14 @@ }, "configure": "Configure", "confirmPassword": "Confirm password", - "cores": "Cores", - "country": "COUNTRY", - "cpu": "CPU", - "cpuFrequency": "CPU Frequency", - "cpuLogical": "CPU (Logical)", - "cpuPhysical": "CPU (Physical)", - "cpuTemperature": "CPU Temperature", - "cpuUsage": "CPU usage", "createA": "Create a", "createMaintenance": "Create maintenance", "createMaintenanceWindow": "Create maintenance window", - "createMonitor": "Create monitor", - "createMonitorPage": { - "incidentConfigDescription": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", - "incidentConfigDescriptionV2": "Number of consecutive checks needed to change monitor status. Maximum 25", - "incidentConfigStatusCheckNumber": "How many checks before status change?", - "incidentConfigStatusWindowLabel": "How many checks should be in the sliding window?", - "incidentConfigStatusWindowThresholdLabel": "What percentage of checks in the sliding window fail/succeed before monitor status changes?", - "incidentConfigTitle": "Incidents", - "intervalDescription": "How often the monitor should be checked.", - "intervalTitle": "Check interval" - }, "createNew": "Create new", - "createNotifications": { - "dialogDeleteConfirm": "Delete", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "discordSettings": { - "description": "Configure your Discord webhook here", - "title": "Discord", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "emailSettings": { - "description": "Destination email addresses.", - "emailLabel": "Email address", - "emailPlaceholder": "e.g. john@example.com", - "title": "Email" - }, - "nameSettings": { - "description": "A descriptive name for your integration.", - "nameLabel": "Name", - "namePlaceholder": "e.g. Slack notifications", - "title": "Name" - }, - "pagerdutySettings": { - "description": "Configure your PagerDuty integration here", - "integrationKeyLabel": "Integration key", - "integrationKeyPlaceholder": "1234567890", - "title": "PagerDuty" - }, - "slackSettings": { - "description": "Configure your Slack webhook here", - "title": "Slack", - "webhookLabel": "Slack webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "testNotification": "Test notification", - "title": "Create notification channel", - "typeSettings": { - "description": "Select the type of notification channel you want to create.", - "title": "Type", - "typeLabel": "Type" - }, - "webhookSettings": { - "description": "Configure your webhook here", - "title": "Webhook", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - } - }, - "createYour": "Create your", - "dataRate": "Data Rate", - "dataReceived": "Data Received", - "dataSent": "Data Sent", - "date&Time": "Date & Time", "delete": "Delete", - "DeleteAccountButton": "Remove account", - "DeleteAccountTitle": "Remove account", - "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", - "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", - "deleteDialogTitle": "Do you really want to delete this monitor?", "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", - "DeleteWarningTitle": "Really remove this account?", - "details": "Details", - "device": "Device", "diagnosticsPage": { "diagnosticDescription": "System diagnostics", "gauges": { @@ -425,26 +252,10 @@ "totalHeapSizeTitle": "Total heap size", "uptimeTitle": "Uptime", "usedHeapSizeTitle": "Used heap size" - }, - "statsDescription": "System statistics" + } }, - "disk": "Disk", - "diskUsage": "Disk Usage", - "displayName": "Display name", - "distributedUptimeCreateAdvancedSettings": "Advanced settings", - "distributedUptimeCreateChecks": "Checks to perform", - "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", - "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", - "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", - "dnsLookup": "DNS lookup", - "dockerContainerMonitoring": "Docker container monitoring", - "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", - "download": "Download", - "DragandDrop": "drag and drop", - "drops": "Drops", "duration": "Duration", "edit": "Edit", - "editing": "Editing...", "editMaintenance": "Edit maintenance", "editUserPage": { "form": { @@ -456,10 +267,6 @@ }, "table": { "actionHeader": "Action", - "headers": { - "name": "Status page name", - "url": "Public URL" - }, "roleHeader": "Role" }, "title": "Edit user", @@ -468,10 +275,6 @@ "validationErrors": "Validation errors" } }, - "EmailDescriptionText": "This is your current email address — it cannot be changed.", - "emailSent": "Email sent successfully", - "errorInvalidFieldId": "Invalid field ID provided", - "errorInvalidTypeId": "Invalid notification type provided", "errorPages": { "serverUnreachable": { "alertBox": "Server Connection Error", @@ -486,24 +289,10 @@ } } }, - "errors": "Errors", - "errorsIn": "Errors In", - "errorsOut": "Errors Out", - "expectedValue": "Expected value", - "export": { - "failed": "Failed to export monitors", - "success": "Monitors exported successfully!", - "title": "Export Monitors" - }, "failedToSendEmail": "Failed to send email", "features": "Features", - "FirstName": "First name", - "frequency": "Frequency", "friendlyNameInput": "Friendly name", "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", - "gameServerMonitoring": "Game Server Monitoring", - "gameServerMonitoringDescription": "Check whether your game server is running or not", - "gb": "GB", "general": { "noOptionsFound": "No {{unit}} found" }, @@ -512,113 +301,12 @@ "overview": "Here's an overview of your {{type}} monitors.", "prepend": "Hey there" }, - "high": "high", - "host": "Host", - "http": "HTTP", - "https": "HTTPS", - "ignoreTLSError": "Ignore TLS/SSL error", - "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", - "incidentsOptionsHeader": "Incidents for:", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", - "incidentsOptionsHeaderFilterDown": "Down", - "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsOptionsHeaderLastDay": "Last day", - "incidentsOptionsHeaderLastHour": "Last hour", - "incidentsOptionsHeaderLastWeek": "Last week", - "incidentsOptionsHeaderShow": "Show:", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", - "incidentsOptionsPlaceholderAllServers": "All servers", "incidentsPage": { - "active": "Active", - "all": "All", - "allSystemsAreOperational": "All Systems Are Operational", - "automatic": "Automatic", - "avgResolutionTime": "Avg. Resolution Time", - "description": "Manage incidents for your monitors. You can resolve incidents here.", - "endTime": "End Time", - "errorFetchingIncident": "Error fetching incident", - "errorFetchingIncidents": "Error fetching incidents", - "errorFetchingIncidentSummary": "Error fetching incident summary", - "filterByDate": "Filter by date", - "filterByMonitor": "Filter by monitor", - "filterByPriority": "Filter by priority", - "filterByStatus": "Filter by status", - "filterByType": "Filter by type", - "incidentItemMonitor": "Monitor", - "incidentItemStatus": "Status", - "incidentResolvedFailed": "Error resolving incident", - "incidentResolvedSuccessfully": "Incident resolved successfully", - "incidentsActivePanelError": "Error loading active incidents", - "incidentsActivePanelTitle": "Total Active Incidents", - "incidentsLatestPanelEmpty": "No recent incidents", - "incidentsLatestPanelError": "Error loading latest incidents", - "incidentsLatestPanelTitle": "Latest Incidents", - "incidentsOptionsHeaderFilterActive": "Active", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterAutomatic": "Automatic", - "incidentsOptionsHeaderFilterManual": "Manual", - "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsStatisticsPanelError": "Error loading statistics", - "incidentsStatisticsPanelTitle": "General Statistics", - "incidentsTableActionDetails": "Details", - "incidentsTableActionGoToMonitor": "Go to monitor", - "incidentsTableActionResolveManually": "Resolve Manually", - "incidentsTableResolved": "Closed", - "manual": "Manual", - "mostAffectedMonitor": "Most Affected Monitor", - "noIncidentIdProvided": "No incident ID provided", - "resolutions": "Resolutions", - "resolutionType": "Resolution Type", - "resolved": "Resolved", "resolveIncidentDialogCommentLabel": "Comment (optional)", "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", "resolveIncidentDialogConfirm": "Resolve", - "resolveIncidentDialogTitle": "Resolve Incident", - "startTime": "Start Time", - "title": "Incidents", - "totalIncidents": "Total Incidents", - "unknownMonitor": "Unknown Monitor", - "unresolved": "Unresolved" + "resolveIncidentDialogTitle": "Resolve Incident" }, - "incidentsPageActionResolveAll": "Resolve all incidents", - "incidentsPageActionResolveMonitor": "Resolve monitor incidents", - "incidentsPageTitle": "Incidents", - "incidentsTableActionResolve": "Resolve", - "incidentsTableDateTime": "Date & Time", - "incidentsTableMessage": "Message", - "incidentsTableMonitorName": "Monitor Name", - "incidentsTableNoIncidents": "No incidents recorded", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableResolvedAt": "Resolved at", - "incidentsTableStatus": "Status", - "incidentsTableStatusCode": "Status Code", - "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", - "infrastructureAuthorizationSecretLabel": "Authorization secret", - "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", - "infrastructureCreateMonitor": "Create Infrastructure Monitor", - "infrastructureCreateYour": "Create your", - "infrastructureCustomizeAlerts": "Customize alerts", - "infrastructureDisplayNameLabel": "Display name", - "infrastructureEditMonitor": "Save Infrastructure Monitor", - "infrastructureEditYour": "Edit your", - "infrastructureMonitor": { - "fallback": { - "actionButton": "Let's create your first infrastructure monitor!", - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:" - } - }, - "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", - "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", - "infrastructureProtocol": "Protocol", - "infrastructureServerRequirement": "The server you are monitoring must be running the", - "infrastructureServerUrlLabel": "Server URL", "integrations": "Integrations", "integrationsDiscord": "Discord", "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", @@ -630,10 +318,7 @@ "invalidFileFormat": "Unsupported file format!", "invalidFileSize": "File size is too large!", "inviteNoTokenFound": "No invite token found", - "LastName": "Last name", - "loginHere": "Login here", "logsPage": { - "description": "System logs - last 1000 lines", "logLevelSelect": { "title": "Log level", "values": { @@ -646,10 +331,6 @@ }, "noLogs": "No logs found", "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" - }, "level": "Level", "logs": "logs", "message": "Message", @@ -662,44 +343,18 @@ "logs": "Server logs", "queue": "Job queue" }, - "title": "Logs", - "toast": { - "fetchLogsSuccess": "Logs fetched successfully" - } + "title": "Logs" }, - "low": "low", "maintenance": "maintenance", "maintenanceRepeat": "Maintenance Repeat", "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "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:" - } - }, "maintenanceWindowDescription": "During maintenance windows, all monitoring is suspended for selected monitors. No network checks will be performed, preventing any status updates or notifications from being triggered. Your monitors will appear frozen at their last known status, and status pages will display a maintenance indicator. Once the maintenance window ends, monitoring automatically resumes, and alerts will trigger if issues are detected. Maintenance periods do not count against uptime calculations.", "maintenanceWindowName": "Maintenance Window Name", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "matchMethod": "Match Method", "matchMethodOptions": { "equal": "Equal", - "equalPlaceholder": "success", "include": "Include", - "includePlaceholder": "ok", - "regex": "Regex", - "regexPlaceholder": "^(success|ok)$", - "text": "Match Method" + "regex": "Regex" }, - "MaxSize": "Maximum Size", - "mb": "MB", - "mem": "Mem", - "memory": "Memory", - "memoryUsage": "Memory usage", "menu": { "changelog": "Changelog", "checks": "Checks", @@ -707,7 +362,6 @@ "docs": "Docs", "incidents": "Incidents", "infrastructure": "Infrastructure", - "integrations": "Integrations", "logOut": "Log out", "logs": "Logs", "maintenance": "Maintenance", @@ -721,159 +375,25 @@ "team": "Team", "uptime": "Uptime" }, - "message": "Message", - "monitor": "monitor", - "monitorActions": { - "deleteFailed": "Failed to delete monitor", - "deleteSuccess": "Monitor deleted successfully", - "details": "Details", - "export": "Export Monitors", - "import": "Import Monitors", - "title": "Export/Import" - }, - "monitorDisplayName": "Monitor display name", "monitorHooks": { "failureAddDemoMonitors": "Failed to add demo monitors", "successAddDemoMonitors": "Successfully added demo monitors" }, - "monitors": "monitors", - "monitorState": { - "active": "Active", - "paused": "Paused", - "resumed": "Resumed" - }, - "monitorStatus": { - "checkingEvery": "Checking every {{interval}}", - "down": "down", - "paused": "paused", - "up": "up", - "withCaptureAgent": "with Capture agent {{version}}" - }, - "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", - "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", "monitorsToApply": "Monitors to apply maintenance window to", - "monitorType": { - "docker": { - "label": "Container Name/ID", - "namePlaceholder": "My Container", - "placeholder": "my-app or abcd1234" - }, - "game": { - "label": "URL to monitor", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - }, - "http": { - "label": "URL to monitor", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "IP address to monitor", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "URL to monitor", - "namePlaceholder": "Localhost:5173", - "placeholder": "localhost" - } - }, - "mountedOn": "Mounted on", - "ms": "ms", "navControls": "Controls", - "network": "Network", - "networkDrops": "Network Drops", - "networkErrors": "Network Errors", - "networkInterface": "Network Interface", "nextWindow": "Next window", - "noNetworkStatsAvailable": "No network stats available.", "notFoundButton": "Go to the main dashboard", - "notificationConfig": { - "description": "Select the notifications channels you want to use", - "placeholder": "Search notifications...", - "title": "Notifications" - }, "notifications": { - "addOrEditNotifications": "Add or edit notifications", - "create": { - "failed": "Failed to create notification", - "success": "Notification created successfully" - }, - "createButton": "Create notification channel", - "createTitle": "Notification channel", - "delete": { - "failed": "Failed to delete notification", - "success": "Notification deleted successfully" - }, - "discord": { - "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", - "label": "Discord", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord webhook URL is required" - }, - "edit": { - "failed": "Failed to update notification", - "success": "Notification updated successfully" - }, - "enableNotifications": "Enable {{platform}} notifications", - "fallback": { - "actionButton": "Let's create your first notification channel!", - "checks": [ - "Alert teams about downtime or performance issues", - "Let engineers know when incidents happen", - "Keep administrators informed of system changes" - ], - "title": "A notification channel is used to:" - }, "fetch": { - "failed": "Failed to fetch notifications", - "success": "Notifications fetched successfully" - }, - "integrationButton": "Notification Integration", - "networkError": "Network error occurred", - "slack": { - "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", - "label": "Slack", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL is required" - }, - "telegram": { - "chatIdLabel": "Your Chat ID", - "chatIdPlaceholder": "-1001234567890", - "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", - "fieldsRequired": "Telegram token and chat ID are required", - "label": "Telegram", - "tokenLabel": "Your bot token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + "failed": "Failed to fetch notifications" }, "test": { - "failed": "Failed to send test notification", "success": "Test notification sent successfully" - }, - "testFailed": "Failed to send test notification", - "testNotification": "Test notification", - "testNotificationDevelop": "Test notification 2", - "testSuccess": "Test notification sent successfully!", - "unsupportedType": "Unsupported notification type", - "webhook": { - "description": "You can set up a custom webhook to receive notifications when incidents occur.", - "label": "Webhooks", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL is required" } }, - "notifyEmails": "Also notify via email to multiple addresses (coming soon)", - "notifySMS": "Notify via SMS (coming soon)", - "now": "Now", - "os": "OS", - "packetsPerSecond": "Packets per second", - "packetsReceived": "Packets Received", - "packetsReceivedRate": "Packets Received Rate", - "packetsSent": "Packets Sent", + "pageSpeedAddApiKey": "to add your API key.", + "pageSpeedLearnMoreLink": "Click here", + "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", "pages": { "checks": { "selects": { @@ -924,43 +444,13 @@ "form": { "advanced": { "description": "Optional settings for advanced use cases", - "option": { - "advancedMatching": { - "label": "Use advanced matching" - }, - "expectedValue": { - "description": "The expected value is used to match against response result, and the match determines the status.", - "label": "Expected value" - }, - "jsonPath": { - "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value. See jmespath.org for query language documentation.", - "label": "JSONPath expression" - }, - "matchMethod": { - "label": "Match method" - } - }, "title": "Advanced settings" }, "frequency": { "description": "How often do you want to check the status of this monitor?", - "option": { - "frequency": { - "label": "Check frequency" - } - }, "title": "Check frequency" }, "general": { - "description": { - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", - "hardware": "Enter the URL of your infrastructure agent, the authorization secret, and a clear display name that appears on the dashboard.", - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "pagespeed": "Enter the URL of the page to analyze with Google PageSpeed and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." - }, "option": { "container": { "label": "Container name/ID", @@ -975,8 +465,7 @@ "placeholder": "e.g. My Website" }, "secret": { - "label": "Authorization secret", - "placeholder": "Enter your secret key" + "label": "Authorization secret" }, "url": { "label": "URL", @@ -999,9 +488,6 @@ "option": { "checks": { "label": "Number of checks in sliding window" - }, - "percentage": { - "label": "What percentage of checks in the sliding window fail/succeed before monitor status changes?" } }, "title": "Incidents" @@ -1013,20 +499,10 @@ "type": { "description": "Select the type of check to perform", "optionDocker": "Docker", - "optionDockerDescription": "Use Docker to monitor if a container is running.", "optionGame": "Game", - "optionGameDescription": "Monitor if a specific game server is online.", - "optionHardware": "Hardware", - "optionHardwareDescription": "Monitor server hardware metrics like CPU, memory, and disk.", "optionHttp": "HTTP(S)", - "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", - "optionPagespeed": "PageSpeed", - "optionPagespeedDescription": "Analyze page performance using Google PageSpeed Insights.", "optionPing": "Ping", - "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", "optionPort": "Port", - "optionPortDescription": "Monitor if a specific port on a server is open.", - "optionType": "Type", "title": "Type" } } @@ -1043,10 +519,6 @@ "overview": "Overview", "resolutionDetails": "Resolution details", "resolutionType": "Type:", - "resolutionTypes": { - "automatic": "Automatic", - "manual": "Manual" - }, "resolve": "Resolve Incident", "resolvedAt": "Resolved at:", "resolvedBy": "Resolved by:", @@ -1060,26 +532,21 @@ }, "filters": { "allMonitors": "All monitors", - "clearFilters": "Clear Filters", + "clearFilters": "Clear filters", "monitor": "Monitor", - "resolutionType": "Resolution type", - "resolutionTypes": { - "automatic": "Automatic", - "manual": "Manual" - } + "resolutionType": "Resolution type" }, "summaryCard": { "activeIncidents": { - "active_one": "{{count}} Active incident", - "active_other": "{{count}} Active incidents", "active_zero": "No active incidents", - "title": "Active Incidents" + "title": "Active Incidents", + "active": "active" }, "incidentStats": { - "avgResolutionTime": "Average resolution time", - "mostAffectedMonitor": "Most affected monitor", + "avgResolutionTime": "Avg Resolution Time", + "mostAffectedMonitor": "Most Affected Monitor", "title": "Incident Statistics", - "totalIncidents": "Total incidents" + "totalIncidents": "Total Incidents" }, "latestIncidents": { "title": "Latest Incidents" @@ -1101,7 +568,6 @@ "resolvedIncidents": "Resolved Incidents", "status": { "active": "Active", - "na": "N/A", "resolved": "Resolved" } } @@ -1117,15 +583,6 @@ "temp": "Temp" } }, - "fallback": { - "actionButton": "Let's create your first infrastructure monitor!", - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:" - }, "gauges": { "cpu": { "lowerLabel": "Max frequency", @@ -1178,7 +635,6 @@ }, "form": { "accessToken": { - "optionAccessToken": "Access token", "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" }, "address": { @@ -1189,10 +645,8 @@ "title": "Address" }, "homeServer": { - "description": "Select the home server for this notification channel.", "optionHomeServer": "Home server", - "placeholder": "example.com", - "title": "Home server" + "placeholder": "example.com" }, "matrix": { "description": "Configure your Matrix homeserver connection for notifications.", @@ -1243,15 +697,6 @@ "title": "Performance report" } }, - "fallback": { - "actionButton": "Let's create your first PageSpeed monitor!", - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:" - }, "table": { "headers": { "pageSpeedScore": "PageSpeed score" @@ -1260,153 +705,57 @@ }, "statusPages": { "fallback": { - "actionButton": "Create a status page!", - "checks": [ - "Monitor and display the health of your services in real time", - "Track multiple services and share their status", - "Keep users informed about outages and performance" - ], - "title": "A status page is used to:" - }, - "form": { - "access": { - "description": "Choose if you want your status page to be public", - "option": { - "published": { - "name": "Published and publicly visible" - } - }, - "title": "Access" - }, - "appearance": { - "description": "Define the default look and feel of your public status page", - "option": {}, - "title": "Appearance" - }, - "basicInfo": { - "description": "Name and address of your status page", - "option": { - "name": { - "label": "Name", - "placeholder": "My status page" - }, - "url": { - "label": "URL", - "placeholder": "my-status-page" - } - }, - "title": "Basic information" - }, - "monitors": { - "description": "Select which monitors to display on your status page", - "noMonitors": "No monitors available. Create uptime monitors first.", - "option": { - "monitors": { - "label": "Monitors", - "placeholder": "Select monitors..." - } - }, - "title": "Monitors" - }, - "timezone": { - "description": "Select the timezone for your status page", - "option": { - "timezone": { - "label": "Timezone", - "placeholder": "Search timezones..." - } - }, - "title": "Timezone" - } + "actionButton": "Create status page", + "checks": "No status pages found", + "title": "Status pages" }, "monitorsList": { "chartTypeHeatmap": "Heatmap", "chartTypeHistogram": "Histogram" }, - "serviceStauts": "Service stauts", - "settings": { - "description": "Configure basic settings for your status page.", - "option": { - "name": { - "label": "Status page name", - "placeholder": "e.g. My Company Status Page" - }, - "published": { - "label": "Published and visible to the public" - }, - "subdomain": { - "label": "Subdomain", - "placeholder": "e.g. my-company" - }, - "timezone": { - "label": "Timezone" - } - }, - "title": "Status page settings" - }, "statusBar": { - "allDown": "All systems down", + "allDown": "All systems are down", "allUp": "All systems operational", - "degraded": "Degraded performance", - "unknown": "Unknown status" - } - }, - "uptime": { + "degraded": "Some systems are experiencing issues", + "unknown": "Unable to determine system status" + }, "table": { "headers": { - "responseTime": "Response time" - } + "name": "Status page name", + "url": "Public URL" + }, + "published": "Published", + "unpublished": "Unpublished" }, + "title": "Status pages" + }, + "uptime": { "filters": { "search": { "placeholder": "Search monitors..." } + }, + "table": { + "headers": { + "responseTime": "Response time" + } } } }, - "pageSpeed": { - "fallback": { - "actionButton": "Let's create your first PageSpeed monitor!", - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:" - } - }, - "pageSpeedAddApiKey": "to add your API key.", - "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", - "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", - "pageSpeedDetailsPerformanceReportCalculator": "See calculator", - "pageSpeedLearnMoreLink": "Click here", - "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", - "pageSpeedMonitor": "PageSpeed monitor", - "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", "passwordPanel": { "confirmNewPassword": "Confirm new password", "currentPassword": "Current password", - "enterCurrentPassword": "Enter your current password", "enterNewPassword": "Enter your new password", - "newPassword": "New password", - "passwordChangedSuccess": "Your password was changed successfully.", - "passwordInputIncorrect": "Your password input was incorrect.", - "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`).", - "saving": "Saving..." + "newPassword": "New password" }, "pause": "Pause", - "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "pingMonitoring": "Ping monitoring", - "pingMonitoringDescription": "Check whether your server is available or not.", - "portMonitoring": "Port monitoring", - "portMonitoringDescription": "Check whether your port is open or not.", "portToMonitor": "Port to monitor", "publicLink": "Public link", "queuePage": { "failedJobTable": { "failCountHeader": "Fail count", - "failedAtHeader": "Last failed at", "failReasonHeader": "Fail reason", + "failedAtHeader": "Last failed at", "monitorIdHeader": "Monitor ID", "monitorUrlHeader": "Monitor URL", "title": "Failed jobs" @@ -1431,17 +780,12 @@ "title": "Queue metrics", "valueHeader": "Value" }, - "refreshButton": "Refresh", - "title": "Queue" + "refreshButton": "Refresh" }, - "rate": "Rate", "remove": "Remove", "removeLogo": "Remove Logo", "repeat": "Repeat", "reset": "Reset", - "response": "RESPONSE", - "responseTime": "Response time", - "responseTimeBreakdown": "Response time breakdown", "resume": "Resume", "roles": { "admin": "Admin", @@ -1451,13 +795,7 @@ }, "save": "Save", "selectAll": "Select all", - "selectInterface": "Select Interface", - "sendTestNotifications": "Send test notifications", - "seperateEmails": "You can separate multiple emails with a comma", - "settingsAppearance": "Appearance", - "settingsDisabled": "Disabled", "settingsDisplayTimezone": "Display timezone", - "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", "settingsFailedToClearStats": "Failed to clear stats", "settingsFailedToDeleteMonitors": "Failed to delete all monitors", "settingsFailedToSave": "Failed to save settings", @@ -1466,7 +804,6 @@ "settingsPage": { "aboutSettings": { "labelDevelopedBy": "Developed by Bluewave Labs", - "labelVersion": "Version", "title": "About" }, "demoMonitorsSettings": { @@ -1481,14 +818,9 @@ "labelAddress": "Email address - Used for authentication", "labelConnectionHost": "Email connection host - Hostname to use in the HELO/EHLO greeting", "labelHost": "Email host - Hostname or IP address to connect to", - "labelIgnoreTLS": "Disable STARTTLS: Don't use TLS even if the server supports it", "labelPassword": "Email password - Password for authentication", "labelPasswordSet": "Password is set. Click Reset to change it.", - "labelPool": "Enable connection pooling: Reuse existing connections to improve performance", "labelPort": "Email port - Port to connect to", - "labelRejectUnauthorized": "Reject invalid certificates: Reject connections with self-signed or untrusted certificates", - "labelRequireTLS": "Force STARTTLS: Require TLS upgrade, fail if not supported", - "labelSecure": "Use SSL (recommended): Encrypt the connection using SSL/TLS", "labelTLSServername": "TLS Servername - Optional Hostname for TLS Validation when host is an IP", "labelUser": "Email user - Username for authentication, overrides email address if specified", "linkTransport": "See specifications here", @@ -1497,7 +829,6 @@ "toastEmailRequiredFieldsError": "Email address, host, port and password are required" }, "globalThresholds": { - "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring.", "title": "Global Thresholds" }, "pageSpeedSettings": { @@ -1510,25 +841,20 @@ "statsSettings": { "clearAllStatsButton": "Clear all stats", "clearAllStatsDescription": "Clear all stats. This is irreversible.", - "clearAllStatsDialogConfirm": "Yes, clear all stats", "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", "clearAllStatsDialogTitle": "Do you want to clear all stats?", "description": "Define how long you want to retain historical data. You can also clear all existing data.", - "labelTTL": "The days you want to keep monitoring history.", - "labelTTLOptional": "0 for infinite", "title": "Monitor history" }, "systemResetSettings": { "buttonRemoveAllMonitors": "Remove all monitors", "description": "Remove all monitors from your system.", "dialogConfirm": "Yes, remove all monitors", - "dialogDescription": "Once removed, the monitors cannot be retrieved.", "dialogTitle": "Do you want to remove all monitors?", "title": "System reset" }, "timezoneSettings": { "description": "Select the timezone used to display dates and times throughout the application.", - "label": "Display timezone", "title": "Display timezone" }, "title": "Settings", @@ -1549,47 +875,34 @@ "title": "Monitor IP/URL on Status Page" } }, - "settingsSave": "Save", "settingsStatsCleared": "Stats cleared successfully", "settingsSuccessSaved": "Settings saved successfully", "settingsTestEmailFailed": "Failed to send test email", - "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", "settingsTestEmailSuccess": "Test email sent successfully", "settingsTestEmailUnknownError": "Unknown error", "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", "showCharts": "Show charts", - "shown": "Shown", "showUptimePercentage": "Show uptime percentage", "starPromptDescription": "See the latest releases and help grow the community on GitHub", "starPromptTitle": "Star Checkmate", "startTime": "Start time", - "state": "State", "status": "Status", "statusBreadCrumbsDetails": "Details", "statusBreadCrumbsStatusPages": "Status Pages", - "statusCode": "Status code", - "statusMsg": { - "down": "Your site is down.", - "paused": "Monitoring is paused.", - "pending": "Pending...", - "up": "Your site is up." - }, "statusPage": { "contents": "Contents", "createSuccess": "Status page created successfully", "deleteFailed": "Failed to delete status page", "deleteSuccess": "Status page deleted successfully", - "fallback": { - "actionButton": "Let's create your first status page!", - "checks": [ - "Monitor and display the health of your services in real time", - "Track multiple services and share their status", - "Keep users informed about outages and performance" - ], - "title": "A status page is used to:" - }, "generalSettings": "General settings", - "updateSuccess": "Status page updated successfully" + "updateSuccess": "Status page updated successfully", + "details": { + "statusHeader": { + "allUp": "All systems operational", + "allDown": "All systems are experiencing issues", + "anyDown": "Some systems are experiencing issues" + } + } }, "statusPageCreate": { "buttonSave": "Save" @@ -1606,8 +919,6 @@ "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", "statusPageStatusServiceStatus": "Service status", "submit": "Submit", - "SupportedFormats": "Supported formats", - "tcpConnection": "TCP connection", "teamPanel": { "addTeamMember": { "addButton": "Add Member", @@ -1616,6 +927,12 @@ "title": "Register new team member" }, "cancel": "Cancel", + "changeTeamPassword": { + "changePasswordMenu": "Reset Password", + "description": "Create a new password for this team member. You will need to share the password with them securely.", + "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way.", + "title": "Reset team member password" + }, "email": "Email", "emailToken": "E-mail token", "filter": { @@ -1632,111 +949,41 @@ "registerToast": { "success": "User created, share credentials with the member securely." }, + "role": "Role", "selectRole": "Select role", "table": { "created": "Created", "email": "Email", - "headers": { - "name": "Status page name", - "url": "Public URL" - }, "name": "Name", "role": "Role" }, - "teamMembers": "Team members", - "uptime": { - "fallback": { - "actionButton": "Create a monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:" - }, - "filters": { - "search": { - "placeholder": "Search monitors..." - } - }, - "table": { - "headers": { - "responseTime": "Response time" - } - } - } + "teamMembers": "Team members" }, - "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { "fifteenMinutes": "15 minutes", "fifteenSeconds": "15 seconds", "fiveMinutes": "5 minutes", "fourMinutes": "4 minutes", - "oneDay": "1 day", - "oneHour": "1 hour", "oneMinute": "1 minute", - "oneWeek": "1 week", "tenMinutes": "10 minutes", "thirtyMinutes": "30 minutes", "thirtySeconds": "30 seconds", "threeMinutes": "3 minutes", - "twentyMinutes": "20 minutes", "twoMinutes": "2 minutes" }, - "timeToFirstByte": "Time to first byte", - "timezone": "Timezone", "timeZoneInfo": "All dates and times are in GMT+0 time zone.", - "title": "Title", - "tlsErrorIgnored": "TLS/SSL errors ignored", - "tlsHandshake": "TLS handshake", + "timezone": "Timezone", "total": "Total", - "type": "Type", "update": "Update", "uptime": "Uptime", - "uptimeAdvancedMatching": { - "jsonPath": "JSON Path" - }, - "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", - "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", - "uptimeCreateJsonPathQuery": "for query language documentation.", - "uptimeGeneralInstructions": { - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." - }, - "uptimeMonitor": { - "fallback": { - "actionButton": "Let's create your first uptime monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:" - } - }, - "url": "URL", - "urlMonitor": "URL to monitor", "used": "Used", - "v1": { - "infrastructure": { - "disk_selection_description": "Select the specific disks you want to monitor.", - "disk_selection_info": "No disk detected for the moment.", - "disk_selection_title": "Disk Selection" - } - }, - "waitTime": "Wait time", - "webhookSendError": "Error sending webhook notification to {platform}", - "webhookSendSuccess": "Webhook notification sent successfully", - "webhookUnsupportedPlatform": "Unsupported platform: {platform}", - "websiteMonitoring": "Website monitoring", - "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", - "whenNewIncident": "When there is a new incident,", "window": "window", - "YourPhoto": "Profile photo" + "failedDeleteMonitor": "Failed to delete monitor", + "failedPauseMonitor": "Failed to pause monitor", + "hour": "hour", + "minute": "minute", + "monitorDeleted": "Monitor deleted successfully", + "monitorPaused": "Monitor paused successfully", + "monitorResumed": "Monitor resumed successfully", + "name": "Name" } From 6f0ff1583e9af9875c3a43a990a1228b9fd36bff Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 18:23:19 +0000 Subject: [PATCH 137/392] rebuild strings --- client/src/locales/en.json | 267 +++++++++++++++++++++++++++++++++---- 1 file changed, 242 insertions(+), 25 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ed0f7fcf5..52f0dff1f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,42 +1,59 @@ { - "ClickUpload": "Click to upload", - "DeleteAccountButton": "Remove account", - "DeleteAccountTitle": "Remove account", - "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", - "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "DeleteWarningTitle": "Really remove this account?", - "DragandDrop": "drag and drop", - "EmailDescriptionText": "This is your current email address — it cannot be changed.", - "FirstName": "First name", - "LastName": "Last name", - "MaxSize": "Maximum Size", - "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "SupportedFormats": "Supported formats", - "YourPhoto": "Profile photo", "aboutus": "About Us", "access": "Access", "actions": "Actions", "add": "Add", "addMonitors": "Add monitors", + "advancedMatching": "Advanced matching", + "area": "Area", "auth": { "common": { "errors": { "validation": "Error validating data." }, + "fields": { + "role": { + "errors": { + "min": "At least one role is required" + } + } + }, "inputs": { "email": { + "errors": { + "empty": "To continue, please enter your email address", + "invalid": "Please recheck validity of entered email address" + }, "label": "Email", "placeholder": "jordan.ellis@domain.com" }, "firstName": { + "errors": { + "empty": "Please enter your name", + "length": "Name must be less than 50 characters", + "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" + }, "label": "Name", "placeholder": "Jordan" }, "lastName": { + "errors": { + "empty": "Please enter your surname", + "length": "Surname must be less than 50 characters", + "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" + }, "label": "Surname", "placeholder": "Ellis" }, "password": { + "errors": { + "empty": "Please enter your password", + "length": "Password must be at least 8 characters long", + "lowercase": "Password must contain at least 1 lowercase letter", + "number": "Password must contain at least 1 number", + "special": "Password must contain at least 1 special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", + "uppercase": "Password must contain at least 1 uppercase letter" + }, "label": "Password", "rules": { "length": { @@ -66,6 +83,10 @@ } }, "passwordConfirm": { + "errors": { + "different": "Entered passwords don't match, so one of them is probably mistyped", + "empty": "Please enter your password again for confirmation (helps with typos)" + }, "label": "Confirm password", "placeholder": "Re-enter password to confirm" } @@ -80,10 +101,15 @@ "resetPassword": "Reset password" }, "heading": "Forgot password?", + "links": { + "login": "Go back to Log In", + "resend": "Didn't receive the email? Click to resend" + }, "subheadings": { "stepFour": "Your password has been successfully reset. Click below to log in magically.", "stepOne": "No worries, we'll send you reset instructions.", - "stepThree": "Your new password must be different from previously used passwords." + "stepThree": "Your new password must be different from previously used passwords.", + "stepTwo": "We sent a password reset link to " }, "toasts": { "emailNotFound": "Email not found.", @@ -126,23 +152,35 @@ "welcome": "Welcome to Checkmate!" } }, + "avgCpuTemperature": "Average CPU Temperature", + "bar": "Bar", "basicInformation": "Basic Information", "bulkImport": { "fallbackPage": "Import a file to upload a list of servers in bulk", "invalidFileType": "Invalid file type", "noFileSelected": "No file selected", "selectFile": "Select File", + "selectFileDescription": "You can download our or sample", "selectFileTips": "Select CSV file to upload", "title": "Bulk Import", "uploadFailed": "Upload failed", "uploadSuccess": "Monitors created successfully!" }, + "bytesSent": "Bytes Sent", "cancel": "Cancel", "checkHooks": { "failureResolveOne": "Failed to resolve incident." }, "chooseGame": "Choose game", + "city": "CITY", + "ClickUpload": "Click to upload", + "close": "Close", "common": { + "alerts": { + "pageSpeedApiKey": { + "content": "Warning: You haven't added a Google PageSpeed API key yet. Visit Settings to add one. Without it, the PageSpeed monitor won't function." + } + }, "appName": "Checkmate", "breadcrumbs": { "details": "Details", @@ -221,19 +259,40 @@ "month": "Month", "recent": "Recent", "week": "Week" + }, + "description": { + "recent": "Showing statistics for past 2 hours.", + "day": "Showing statistics for past 24 hours.", + "week": "Showing statistics for past 7 days.", + "month": "Showing statistics for past 30 days." } } }, "configure": "Configure", "confirmPassword": "Confirm password", + "cores": "Cores", + "cpu": "CPU", + "cpuFrequency": "CPU Frequency", + "cpuLogical": "CPU (Logical)", + "cpuPhysical": "CPU (Physical)", + "cpuTemperature": "CPU Temperature", + "cpuUsage": "CPU usage", "createA": "Create a", "createMaintenance": "Create maintenance", "createMaintenanceWindow": "Create maintenance window", + "createMonitor": "Create monitor", "createNew": "Create new", "delete": "Delete", + "DeleteAccountButton": "Remove account", + "DeleteAccountTitle": "Remove account", + "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", + "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", + "DeleteWarningTitle": "Really remove this account?", + "details": "Details", + "device": "Device", "diagnosticsPage": { "diagnosticDescription": "System diagnostics", "gauges": { @@ -254,8 +313,14 @@ "usedHeapSizeTitle": "Used heap size" } }, + "disk": "Disk", + "diskUsage": "Disk Usage", + "displayName": "Display name", + "download": "Download", + "DragandDrop": "drag and drop", "duration": "Duration", "edit": "Edit", + "editing": "Editing...", "editMaintenance": "Edit maintenance", "editUserPage": { "form": { @@ -275,6 +340,7 @@ "validationErrors": "Validation errors" } }, + "EmailDescriptionText": "This is your current email address — it cannot be changed.", "errorPages": { "serverUnreachable": { "alertBox": "Server Connection Error", @@ -289,10 +355,15 @@ } } }, + "errors": "Errors", + "expectedValue": "Expected value", "failedToSendEmail": "Failed to send email", "features": "Features", + "FirstName": "First name", + "frequency": "Frequency", "friendlyNameInput": "Friendly name", "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", + "gb": "GB", "general": { "noOptionsFound": "No {{unit}} found" }, @@ -301,6 +372,10 @@ "overview": "Here's an overview of your {{type}} monitors.", "prepend": "Hey there" }, + "high": "high", + "host": "Host", + "http": "HTTP", + "https": "HTTPS", "incidentsPage": { "resolveIncidentDialogCommentLabel": "Comment (optional)", "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", @@ -318,6 +393,7 @@ "invalidFileFormat": "Unsupported file format!", "invalidFileSize": "File size is too large!", "inviteNoTokenFound": "No invite token found", + "LastName": "Last name", "logsPage": { "logLevelSelect": { "title": "Log level", @@ -345,16 +421,23 @@ }, "title": "Logs" }, + "low": "low", "maintenance": "maintenance", "maintenanceRepeat": "Maintenance Repeat", "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", "maintenanceWindowDescription": "During maintenance windows, all monitoring is suspended for selected monitors. No network checks will be performed, preventing any status updates or notifications from being triggered. Your monitors will appear frozen at their last known status, and status pages will display a maintenance indicator. Once the maintenance window ends, monitoring automatically resumes, and alerts will trigger if issues are detected. Maintenance periods do not count against uptime calculations.", "maintenanceWindowName": "Maintenance Window Name", + "matchMethod": "Match Method", "matchMethodOptions": { "equal": "Equal", "include": "Include", "regex": "Regex" }, + "MaxSize": "Maximum Size", + "mb": "MB", + "mem": "Mem", + "memory": "Memory", + "memoryUsage": "Memory usage", "menu": { "changelog": "Changelog", "checks": "Checks", @@ -375,15 +458,29 @@ "team": "Team", "uptime": "Uptime" }, + "message": "Message", + "monitor": "monitor", "monitorHooks": { "failureAddDemoMonitors": "Failed to add demo monitors", "successAddDemoMonitors": "Successfully added demo monitors" }, + "monitors": "monitors", "monitorsToApply": "Monitors to apply maintenance window to", + "ms": "ms", "navControls": "Controls", + "network": "Network", "nextWindow": "Next window", "notFoundButton": "Go to the main dashboard", "notifications": { + "fallback": { + "actionButton": "Let's create your first notification channel!", + "checks": [ + "Alert teams about downtime or performance issues", + "Let engineers know when incidents happen", + "Keep administrators informed of system changes" + ], + "title": "A notification channel is used to:" + }, "fetch": { "failed": "Failed to fetch notifications" }, @@ -391,9 +488,8 @@ "success": "Test notification sent successfully" } }, - "pageSpeedAddApiKey": "to add your API key.", - "pageSpeedLearnMoreLink": "Click here", - "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", + "now": "Now", + "os": "OS", "pages": { "checks": { "selects": { @@ -444,10 +540,30 @@ "form": { "advanced": { "description": "Optional settings for advanced use cases", + "option": { + "advancedMatching": { + "label": "Use advanced matching" + }, + "expectedValue": { + "label": "Expected value" + }, + "jsonPath": { + "description": "This expression will be evaluated against the response JSON data and the result will be used to match against the expected value. See jmespath.org for query language documentation.", + "label": "JSONPath expression" + }, + "matchMethod": { + "label": "Match method" + } + }, "title": "Advanced settings" }, "frequency": { "description": "How often do you want to check the status of this monitor?", + "option": { + "frequency": { + "label": "Check frequency" + } + }, "title": "Check frequency" }, "general": { @@ -465,14 +581,22 @@ "placeholder": "e.g. My Website" }, "secret": { - "label": "Authorization secret" + "label": "Authorization secret", + "placeholder": "Enter your secret key" }, "url": { "label": "URL", "placeholder": "https://www.google.com" } }, - "title": "General settings" + "title": "General settings", + "description": { + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", + "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type." + } }, "ignoreTls": { "description": "Configure TLS/SSL certificate validation for HTTPS connections.", @@ -488,6 +612,9 @@ "option": { "checks": { "label": "Number of checks in sliding window" + }, + "percentage": { + "label": "What percentage of checks in the sliding window fail/succeed before monitor status changes?" } }, "title": "Incidents" @@ -499,10 +626,15 @@ "type": { "description": "Select the type of check to perform", "optionDocker": "Docker", + "optionDockerDescription": "Use Docker to monitor if a container is running.", "optionGame": "Game", + "optionGameDescription": "Monitor if a specific game server is online.", "optionHttp": "HTTP(S)", + "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", "optionPing": "Ping", + "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", "optionPort": "Port", + "optionPortDescription": "Monitor if a specific port on a server is open.", "title": "Type" } } @@ -534,7 +666,12 @@ "allMonitors": "All monitors", "clearFilters": "Clear filters", "monitor": "Monitor", - "resolutionType": "Resolution type" + "resolutionType": "Resolution type", + "resolutionTypes": { + "manual": "Manual", + "automatic": "Automatic", + "all": "All" + } }, "summaryCard": { "activeIncidents": { @@ -621,6 +758,15 @@ "network": "Network", "overview": "Overview" } + }, + "fallback": { + "actionButton": "Let's create your first infrastructure monitor!", + "checks": [ + "Track the performance of your servers", + "Identify bottlenecks and optimize usage", + "Ensure reliability with real-time monitoring" + ], + "title": "An infrastructure monitor is used to:" } }, "notifications": { @@ -635,6 +781,7 @@ }, "form": { "accessToken": { + "optionAccessToken": "Access token", "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" }, "address": { @@ -701,6 +848,15 @@ "headers": { "pageSpeedScore": "PageSpeed score" } + }, + "fallback": { + "actionButton": "Let's create your first PageSpeed monitor!", + "checks": [ + "Report on the user experience of a page", + "Help analyze webpage speed", + "Give suggestions on how the page can be improved" + ], + "title": "A PageSpeed monitor is used to:" } }, "statusPages": { @@ -739,23 +895,41 @@ "headers": { "responseTime": "Response time" } + }, + "fallback": { + "actionButton": "Create a monitor!", + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ], + "title": "An uptime monitor is used to:" } } }, + "pageSpeedAddApiKey": "to add your API key.", + "pageSpeedLearnMoreLink": "Click here", + "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", "passwordPanel": { "confirmNewPassword": "Confirm new password", "currentPassword": "Current password", + "enterCurrentPassword": "Enter your current password", "enterNewPassword": "Enter your new password", - "newPassword": "New password" + "newPassword": "New password", + "passwordChangedSuccess": "Your password was changed successfully.", + "passwordInputIncorrect": "Your password input was incorrect.", + "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)." }, "pause": "Pause", + "PhotoDescriptionText": "This photo will be displayed in your profile page.", "portToMonitor": "Port to monitor", "publicLink": "Public link", "queuePage": { "failedJobTable": { "failCountHeader": "Fail count", - "failReasonHeader": "Fail reason", "failedAtHeader": "Last failed at", + "failReasonHeader": "Fail reason", "monitorIdHeader": "Monitor ID", "monitorUrlHeader": "Monitor URL", "title": "Failed jobs" @@ -782,10 +956,13 @@ }, "refreshButton": "Refresh" }, + "rate": "Rate", "remove": "Remove", "removeLogo": "Remove Logo", "repeat": "Repeat", "reset": "Reset", + "response": "RESPONSE", + "responseTime": "Response time", "resume": "Resume", "roles": { "admin": "Admin", @@ -818,9 +995,14 @@ "labelAddress": "Email address - Used for authentication", "labelConnectionHost": "Email connection host - Hostname to use in the HELO/EHLO greeting", "labelHost": "Email host - Hostname or IP address to connect to", + "labelIgnoreTLS": "Disable STARTTLS: Don't use TLS even if the server supports it", "labelPassword": "Email password - Password for authentication", "labelPasswordSet": "Password is set. Click Reset to change it.", + "labelPool": "Enable connection pooling: Reuse existing connections to improve performance", "labelPort": "Email port - Port to connect to", + "labelRejectUnauthorized": "Reject invalid certificates: Reject connections with self-signed or untrusted certificates", + "labelRequireTLS": "Force STARTTLS: Require TLS upgrade, fail if not supported", + "labelSecure": "Use SSL (recommended): Encrypt the connection using SSL/TLS", "labelTLSServername": "TLS Servername - Optional Hostname for TLS Validation when host is an IP", "labelUser": "Email user - Username for authentication, overrides email address if specified", "linkTransport": "See specifications here", @@ -829,6 +1011,7 @@ "toastEmailRequiredFieldsError": "Email address, host, port and password are required" }, "globalThresholds": { + "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring.", "title": "Global Thresholds" }, "pageSpeedSettings": { @@ -841,6 +1024,7 @@ "statsSettings": { "clearAllStatsButton": "Clear all stats", "clearAllStatsDescription": "Clear all stats. This is irreversible.", + "clearAllStatsDialogConfirm": "Yes, clear all stats", "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", "clearAllStatsDialogTitle": "Do you want to clear all stats?", "description": "Define how long you want to retain historical data. You can also clear all existing data.", @@ -878,17 +1062,21 @@ "settingsStatsCleared": "Stats cleared successfully", "settingsSuccessSaved": "Settings saved successfully", "settingsTestEmailFailed": "Failed to send test email", + "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", "settingsTestEmailSuccess": "Test email sent successfully", "settingsTestEmailUnknownError": "Unknown error", "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", "showCharts": "Show charts", + "shown": "Shown", "showUptimePercentage": "Show uptime percentage", "starPromptDescription": "See the latest releases and help grow the community on GitHub", "starPromptTitle": "Star Checkmate", "startTime": "Start time", + "state": "State", "status": "Status", "statusBreadCrumbsDetails": "Details", "statusBreadCrumbsStatusPages": "Status Pages", + "statusCode": "Status code", "statusPage": { "contents": "Contents", "createSuccess": "Status page created successfully", @@ -919,6 +1107,7 @@ "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", "statusPageStatusServiceStatus": "Service status", "submit": "Submit", + "SupportedFormats": "Supported formats", "teamPanel": { "addTeamMember": { "addButton": "Add Member", @@ -946,6 +1135,19 @@ "inviteTeamMember": "Invite a team member", "noMembers": "There are no team members with this role", "register": "Register a team member", + "registerTeamMember": { + "auth": { + "common": { + "inputs": { + "role": { + "errors": { + "empty": "Role is required" + } + } + } + } + } + }, "registerToast": { "success": "User created, share credentials with the member securely." }, @@ -971,13 +1173,17 @@ "threeMinutes": "3 minutes", "twoMinutes": "2 minutes" }, - "timeZoneInfo": "All dates and times are in GMT+0 time zone.", "timezone": "Timezone", + "timeZoneInfo": "All dates and times are in GMT+0 time zone.", + "title": "Title", "total": "Total", + "type": "Type", "update": "Update", "uptime": "Uptime", + "url": "URL", "used": "Used", "window": "window", + "YourPhoto": "Profile photo", "failedDeleteMonitor": "Failed to delete monitor", "failedPauseMonitor": "Failed to pause monitor", "hour": "hour", @@ -985,5 +1191,16 @@ "monitorDeleted": "Monitor deleted successfully", "monitorPaused": "Monitor paused successfully", "monitorResumed": "Monitor resumed successfully", - "name": "Name" + "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:" + } + } } From 7aa05c7f8550ec9ede6991f83495b2c1ef87fea3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 18:34:15 +0000 Subject: [PATCH 138/392] strings --- client/src/locales/en.json | 804 +++++++------------------------------ 1 file changed, 134 insertions(+), 670 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index f6aac1cbc..52f0dff1f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -4,9 +4,7 @@ "actions": "Actions", "add": "Add", "addMonitors": "Add monitors", - "administrator": "Administrator?", "advancedMatching": "Advanced matching", - "ago": "ago", "area": "Area", "auth": { "common": { @@ -14,11 +12,6 @@ "validation": "Error validating data." }, "fields": { - "password": { - "errors": { - "incorrect": "The password you provided does not match our records" - } - }, "role": { "errors": { "min": "At least one role is required" @@ -55,7 +48,6 @@ "password": { "errors": { "empty": "Please enter your password", - "incorrect": "The password you provided does not match our records", "length": "Password must be at least 8 characters long", "lowercase": "Password must contain at least 1 lowercase letter", "number": "Password must contain at least 1 number", @@ -100,7 +92,6 @@ } }, "navigation": { - "back": "Back", "continue": "Continue" } }, @@ -110,12 +101,6 @@ "resetPassword": "Reset password" }, "heading": "Forgot password?", - "imageAlts": { - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon", - "passwordKey": "Password key icon" - }, "links": { "login": "Go back to Log In", "resend": "Didn't receive the email? Click to resend" @@ -147,10 +132,6 @@ "register": "Do not have an account?", "registerLink": "Register here" }, - "subheadings": { - "stepOne": "Enter your email", - "stepTwo": "Enter your password" - }, "toasts": { "incorrectPassword": "Incorrect password", "success": "Welcome back! You're successfully logged in." @@ -162,23 +143,9 @@ "superAdmin": "Create your super admin account to get started", "user": "Sign up as a user and ask super admin for access to your monitors" }, - "gettingStartedButton": { - "superAdmin": "Create super admin account", - "user": "Sign up regular user" - }, "heading": { - "superAdmin": "Create super admin", "user": "Sign Up" }, - "links": { - "login": "Already have an account? Log In" - }, - "subheadings": { - "stepOne": "Enter your personal details", - "stepThree": "Create your password", - "stepTwo": "Enter your email" - }, - "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", "toasts": { "success": "Welcome! Your account was created successfully." }, @@ -192,30 +159,18 @@ "fallbackPage": "Import a file to upload a list of servers in bulk", "invalidFileType": "Invalid file type", "noFileSelected": "No file selected", - "parsingFailed": "Parsing failed", "selectFile": "Select File", "selectFileDescription": "You can download our or sample", "selectFileTips": "Select CSV file to upload", "title": "Bulk Import", "uploadFailed": "Upload failed", - "uploadSuccess": "Monitors created successfully!", - "validationFailed": "Validation failed" + "uploadSuccess": "Monitors created successfully!" }, - "bytesPerSecond": "Bytes per second", - "bytesReceived": "Bytes Received", "bytesSent": "Bytes Sent", "cancel": "Cancel", - "checkFormError": "Please check the form for errors.", - "checkFrequency": "Check frequency", "checkHooks": { - "failureResolveAll": "Failed to resolve all incidents.", - "failureResolveMonitor": "Failed to resolve monitor incidents.", "failureResolveOne": "Failed to resolve incident." }, - "checkingEvery": "Checking every", - "checksPageActionResolveAllMonitor": "Resolve all checks", - "checksPageActionResolveMonitor": "Resolve monitor checks", - "checksPageTitle": "Checks", "chooseGame": "Choose game", "city": "CITY", "ClickUpload": "Click to upload", @@ -260,30 +215,16 @@ "title": "Are you sure you want to delete this?" } }, - "errors": { - "invalidFileFormat": "Invalid file format", - "invalidFileSize": "File size exceeds maximum allowed" - }, - "forDocumentation": "for query language documentation.", - "imageUpload": { - "clickToUpload": "Click to upload", - "maxSize": "Max size", - "orDragAndDrop": "or drag and drop", - "supportedFormats": "Supported formats" - }, "labels": { "active": "Active", "na": "N/A", "resolved": "Resolved", "responseTime": "Response time" }, - "monitoringAgentName": "Capture", - "see": "See", "table": { "empty": "Nothing here", "headers": { "actions": "Actions", - "active": "Active", "dateTime": "Date & time", "message": "Message", "monitor": "Monitor", @@ -296,6 +237,16 @@ "checkConnection": "Please check your connection", "networkError": "Network error", "unknownError": "Unknown error" + }, + "imageUpload": { + "clickToUpload": "Click to upload", + "dragAndDrop": "or drag and drop", + "supportedFormats": "Supported formats", + "maxSize": "Max size" + }, + "errors": { + "invalidFileType": "Invalid file type", + "fileTooLarge": "File too large" } }, "commonSave": "Save", @@ -303,24 +254,23 @@ "companyName": "Company name", "components": { "headerTimeRange": { - "description": { - "day": "Showing statistics for past 24 hours.", - "month": "Showing statistics for past 30 days.", - "recent": "Showing statistics for past 2 hours.", - "week": "Showing statistics for past 7 days." - }, "labels": { "day": "Day", "month": "Month", "recent": "Recent", "week": "Week" + }, + "description": { + "recent": "Showing statistics for past 2 hours.", + "day": "Showing statistics for past 24 hours.", + "week": "Showing statistics for past 7 days.", + "month": "Showing statistics for past 30 days." } } }, "configure": "Configure", "confirmPassword": "Confirm password", "cores": "Cores", - "country": "COUNTRY", "cpu": "CPU", "cpuFrequency": "CPU Frequency", "cpuLogical": "CPU (Logical)", @@ -331,76 +281,12 @@ "createMaintenance": "Create maintenance", "createMaintenanceWindow": "Create maintenance window", "createMonitor": "Create monitor", - "createMonitorPage": { - "incidentConfigDescription": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.", - "incidentConfigDescriptionV2": "Number of consecutive checks needed to change monitor status. Maximum 25", - "incidentConfigStatusCheckNumber": "How many checks before status change?", - "incidentConfigStatusWindowLabel": "How many checks should be in the sliding window?", - "incidentConfigStatusWindowThresholdLabel": "What percentage of checks in the sliding window fail/succeed before monitor status changes?", - "incidentConfigTitle": "Incidents", - "intervalDescription": "How often the monitor should be checked.", - "intervalTitle": "Check interval" - }, "createNew": "Create new", - "createNotifications": { - "dialogDeleteConfirm": "Delete", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "discordSettings": { - "description": "Configure your Discord webhook here", - "title": "Discord", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "emailSettings": { - "description": "Destination email addresses.", - "emailLabel": "Email address", - "emailPlaceholder": "e.g. john@example.com", - "title": "Email" - }, - "nameSettings": { - "description": "A descriptive name for your integration.", - "nameLabel": "Name", - "namePlaceholder": "e.g. Slack notifications", - "title": "Name" - }, - "pagerdutySettings": { - "description": "Configure your PagerDuty integration here", - "integrationKeyLabel": "Integration key", - "integrationKeyPlaceholder": "1234567890", - "title": "PagerDuty" - }, - "slackSettings": { - "description": "Configure your Slack webhook here", - "title": "Slack", - "webhookLabel": "Slack webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "testNotification": "Test notification", - "title": "Create notification channel", - "typeSettings": { - "description": "Select the type of notification channel you want to create.", - "title": "Type", - "typeLabel": "Type" - }, - "webhookSettings": { - "description": "Configure your webhook here", - "title": "Webhook", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - } - }, - "createYour": "Create your", - "dataRate": "Data Rate", - "dataReceived": "Data Received", - "dataSent": "Data Sent", - "date&Time": "Date & Time", "delete": "Delete", "DeleteAccountButton": "Remove account", "DeleteAccountTitle": "Remove account", "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", - "deleteDialogTitle": "Do you really want to delete this monitor?", "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", @@ -425,23 +311,13 @@ "totalHeapSizeTitle": "Total heap size", "uptimeTitle": "Uptime", "usedHeapSizeTitle": "Used heap size" - }, - "statsDescription": "System statistics" + } }, "disk": "Disk", "diskUsage": "Disk Usage", "displayName": "Display name", - "distributedUptimeCreateAdvancedSettings": "Advanced settings", - "distributedUptimeCreateChecks": "Checks to perform", - "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", - "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", - "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", - "dnsLookup": "DNS lookup", - "dockerContainerMonitoring": "Docker container monitoring", - "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", "download": "Download", "DragandDrop": "drag and drop", - "drops": "Drops", "duration": "Duration", "edit": "Edit", "editing": "Editing...", @@ -456,10 +332,6 @@ }, "table": { "actionHeader": "Action", - "headers": { - "name": "Status page name", - "url": "Public URL" - }, "roleHeader": "Role" }, "title": "Edit user", @@ -469,9 +341,6 @@ } }, "EmailDescriptionText": "This is your current email address — it cannot be changed.", - "emailSent": "Email sent successfully", - "errorInvalidFieldId": "Invalid field ID provided", - "errorInvalidTypeId": "Invalid notification type provided", "errorPages": { "serverUnreachable": { "alertBox": "Server Connection Error", @@ -487,22 +356,13 @@ } }, "errors": "Errors", - "errorsIn": "Errors In", - "errorsOut": "Errors Out", "expectedValue": "Expected value", - "export": { - "failed": "Failed to export monitors", - "success": "Monitors exported successfully!", - "title": "Export Monitors" - }, "failedToSendEmail": "Failed to send email", "features": "Features", "FirstName": "First name", "frequency": "Frequency", "friendlyNameInput": "Friendly name", "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", - "gameServerMonitoring": "Game Server Monitoring", - "gameServerMonitoringDescription": "Check whether your game server is running or not", "gb": "GB", "general": { "noOptionsFound": "No {{unit}} found" @@ -516,109 +376,12 @@ "host": "Host", "http": "HTTP", "https": "HTTPS", - "ignoreTLSError": "Ignore TLS/SSL error", - "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", - "incidentsOptionsHeader": "Incidents for:", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", - "incidentsOptionsHeaderFilterDown": "Down", - "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsOptionsHeaderLastDay": "Last day", - "incidentsOptionsHeaderLastHour": "Last hour", - "incidentsOptionsHeaderLastWeek": "Last week", - "incidentsOptionsHeaderShow": "Show:", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", - "incidentsOptionsPlaceholderAllServers": "All servers", "incidentsPage": { - "active": "Active", - "all": "All", - "allSystemsAreOperational": "All Systems Are Operational", - "automatic": "Automatic", - "avgResolutionTime": "Avg. Resolution Time", - "description": "Manage incidents for your monitors. You can resolve incidents here.", - "endTime": "End Time", - "errorFetchingIncident": "Error fetching incident", - "errorFetchingIncidents": "Error fetching incidents", - "errorFetchingIncidentSummary": "Error fetching incident summary", - "filterByDate": "Filter by date", - "filterByMonitor": "Filter by monitor", - "filterByPriority": "Filter by priority", - "filterByStatus": "Filter by status", - "filterByType": "Filter by type", - "incidentItemMonitor": "Monitor", - "incidentItemStatus": "Status", - "incidentResolvedFailed": "Error resolving incident", - "incidentResolvedSuccessfully": "Incident resolved successfully", - "incidentsActivePanelError": "Error loading active incidents", - "incidentsActivePanelTitle": "Total Active Incidents", - "incidentsLatestPanelEmpty": "No recent incidents", - "incidentsLatestPanelError": "Error loading latest incidents", - "incidentsLatestPanelTitle": "Latest Incidents", - "incidentsOptionsHeaderFilterActive": "Active", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterAutomatic": "Automatic", - "incidentsOptionsHeaderFilterManual": "Manual", - "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsStatisticsPanelError": "Error loading statistics", - "incidentsStatisticsPanelTitle": "General Statistics", - "incidentsTableActionDetails": "Details", - "incidentsTableActionGoToMonitor": "Go to monitor", - "incidentsTableActionResolveManually": "Resolve Manually", - "incidentsTableResolved": "Closed", - "manual": "Manual", - "mostAffectedMonitor": "Most Affected Monitor", - "noIncidentIdProvided": "No incident ID provided", - "resolutions": "Resolutions", - "resolutionType": "Resolution Type", - "resolved": "Resolved", "resolveIncidentDialogCommentLabel": "Comment (optional)", "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", "resolveIncidentDialogConfirm": "Resolve", - "resolveIncidentDialogTitle": "Resolve Incident", - "startTime": "Start Time", - "title": "Incidents", - "totalIncidents": "Total Incidents", - "unknownMonitor": "Unknown Monitor", - "unresolved": "Unresolved" + "resolveIncidentDialogTitle": "Resolve Incident" }, - "incidentsPageActionResolveAll": "Resolve all incidents", - "incidentsPageActionResolveMonitor": "Resolve monitor incidents", - "incidentsPageTitle": "Incidents", - "incidentsTableActionResolve": "Resolve", - "incidentsTableDateTime": "Date & Time", - "incidentsTableMessage": "Message", - "incidentsTableMonitorName": "Monitor Name", - "incidentsTableNoIncidents": "No incidents recorded", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableResolvedAt": "Resolved at", - "incidentsTableStatus": "Status", - "incidentsTableStatusCode": "Status Code", - "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", - "infrastructureAuthorizationSecretLabel": "Authorization secret", - "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", - "infrastructureCreateMonitor": "Create Infrastructure Monitor", - "infrastructureCreateYour": "Create your", - "infrastructureCustomizeAlerts": "Customize alerts", - "infrastructureDisplayNameLabel": "Display name", - "infrastructureEditMonitor": "Save Infrastructure Monitor", - "infrastructureEditYour": "Edit your", - "infrastructureMonitor": { - "fallback": { - "actionButton": "Let's create your first infrastructure monitor!", - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:" - } - }, - "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", - "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", - "infrastructureProtocol": "Protocol", - "infrastructureServerRequirement": "The server you are monitoring must be running the", - "infrastructureServerUrlLabel": "Server URL", "integrations": "Integrations", "integrationsDiscord": "Discord", "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", @@ -631,9 +394,7 @@ "invalidFileSize": "File size is too large!", "inviteNoTokenFound": "No invite token found", "LastName": "Last name", - "loginHere": "Login here", "logsPage": { - "description": "System logs - last 1000 lines", "logLevelSelect": { "title": "Log level", "values": { @@ -646,10 +407,6 @@ }, "noLogs": "No logs found", "table": { - "headers": { - "name": "Status page name", - "url": "Public URL" - }, "level": "Level", "logs": "logs", "message": "Message", @@ -662,38 +419,19 @@ "logs": "Server logs", "queue": "Job queue" }, - "title": "Logs", - "toast": { - "fetchLogsSuccess": "Logs fetched successfully" - } + "title": "Logs" }, "low": "low", "maintenance": "maintenance", "maintenanceRepeat": "Maintenance Repeat", "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "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:" - } - }, "maintenanceWindowDescription": "During maintenance windows, all monitoring is suspended for selected monitors. No network checks will be performed, preventing any status updates or notifications from being triggered. Your monitors will appear frozen at their last known status, and status pages will display a maintenance indicator. Once the maintenance window ends, monitoring automatically resumes, and alerts will trigger if issues are detected. Maintenance periods do not count against uptime calculations.", "maintenanceWindowName": "Maintenance Window Name", - "maskedPageSpeedKeyPlaceholder": "*************************************", "matchMethod": "Match Method", "matchMethodOptions": { "equal": "Equal", - "equalPlaceholder": "success", "include": "Include", - "includePlaceholder": "ok", - "regex": "Regex", - "regexPlaceholder": "^(success|ok)$", - "text": "Match Method" + "regex": "Regex" }, "MaxSize": "Maximum Size", "mb": "MB", @@ -707,7 +445,6 @@ "docs": "Docs", "incidents": "Incidents", "infrastructure": "Infrastructure", - "integrations": "Integrations", "logOut": "Log out", "logs": "Logs", "maintenance": "Maintenance", @@ -723,101 +460,18 @@ }, "message": "Message", "monitor": "monitor", - "monitorActions": { - "deleteFailed": "Failed to delete monitor", - "deleteSuccess": "Monitor deleted successfully", - "details": "Details", - "export": "Export Monitors", - "import": "Import Monitors", - "title": "Export/Import" - }, - "monitorDisplayName": "Monitor display name", "monitorHooks": { "failureAddDemoMonitors": "Failed to add demo monitors", "successAddDemoMonitors": "Successfully added demo monitors" }, "monitors": "monitors", - "monitorState": { - "active": "Active", - "paused": "Paused", - "resumed": "Resumed" - }, - "monitorStatus": { - "checkingEvery": "Checking every {{interval}}", - "down": "down", - "paused": "paused", - "up": "up", - "withCaptureAgent": "with Capture agent {{version}}" - }, - "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", - "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", "monitorsToApply": "Monitors to apply maintenance window to", - "monitorType": { - "docker": { - "label": "Container Name/ID", - "namePlaceholder": "My Container", - "placeholder": "my-app or abcd1234" - }, - "game": { - "label": "URL to monitor", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - }, - "http": { - "label": "URL to monitor", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "IP address to monitor", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "URL to monitor", - "namePlaceholder": "Localhost:5173", - "placeholder": "localhost" - } - }, - "mountedOn": "Mounted on", "ms": "ms", "navControls": "Controls", "network": "Network", - "networkDrops": "Network Drops", - "networkErrors": "Network Errors", - "networkInterface": "Network Interface", "nextWindow": "Next window", - "noNetworkStatsAvailable": "No network stats available.", "notFoundButton": "Go to the main dashboard", - "notificationConfig": { - "description": "Select the notifications channels you want to use", - "placeholder": "Search notifications...", - "title": "Notifications" - }, "notifications": { - "addOrEditNotifications": "Add or edit notifications", - "create": { - "failed": "Failed to create notification", - "success": "Notification created successfully" - }, - "createButton": "Create notification channel", - "createTitle": "Notification channel", - "delete": { - "failed": "Failed to delete notification", - "success": "Notification deleted successfully" - }, - "discord": { - "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", - "label": "Discord", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord webhook URL is required" - }, - "edit": { - "failed": "Failed to update notification", - "success": "Notification updated successfully" - }, - "enableNotifications": "Enable {{platform}} notifications", "fallback": { "actionButton": "Let's create your first notification channel!", "checks": [ @@ -828,52 +482,14 @@ "title": "A notification channel is used to:" }, "fetch": { - "failed": "Failed to fetch notifications", - "success": "Notifications fetched successfully" - }, - "integrationButton": "Notification Integration", - "networkError": "Network error occurred", - "slack": { - "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", - "label": "Slack", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL is required" - }, - "telegram": { - "chatIdLabel": "Your Chat ID", - "chatIdPlaceholder": "-1001234567890", - "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", - "fieldsRequired": "Telegram token and chat ID are required", - "label": "Telegram", - "tokenLabel": "Your bot token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + "failed": "Failed to fetch notifications" }, "test": { - "failed": "Failed to send test notification", "success": "Test notification sent successfully" - }, - "testFailed": "Failed to send test notification", - "testNotification": "Test notification", - "testNotificationDevelop": "Test notification 2", - "testSuccess": "Test notification sent successfully!", - "unsupportedType": "Unsupported notification type", - "webhook": { - "description": "You can set up a custom webhook to receive notifications when incidents occur.", - "label": "Webhooks", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL is required" } }, - "notifyEmails": "Also notify via email to multiple addresses (coming soon)", - "notifySMS": "Notify via SMS (coming soon)", "now": "Now", "os": "OS", - "packetsPerSecond": "Packets per second", - "packetsReceived": "Packets Received", - "packetsReceivedRate": "Packets Received Rate", - "packetsSent": "Packets Sent", "pages": { "checks": { "selects": { @@ -929,7 +545,6 @@ "label": "Use advanced matching" }, "expectedValue": { - "description": "The expected value is used to match against response result, and the match determines the status.", "label": "Expected value" }, "jsonPath": { @@ -952,15 +567,6 @@ "title": "Check frequency" }, "general": { - "description": { - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", - "hardware": "Enter the URL of your infrastructure agent, the authorization secret, and a clear display name that appears on the dashboard.", - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "pagespeed": "Enter the URL of the page to analyze with Google PageSpeed and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." - }, "option": { "container": { "label": "Container name/ID", @@ -983,7 +589,14 @@ "placeholder": "https://www.google.com" } }, - "title": "General settings" + "title": "General settings", + "description": { + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", + "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type." + } }, "ignoreTls": { "description": "Configure TLS/SSL certificate validation for HTTPS connections.", @@ -1016,17 +629,12 @@ "optionDockerDescription": "Use Docker to monitor if a container is running.", "optionGame": "Game", "optionGameDescription": "Monitor if a specific game server is online.", - "optionHardware": "Hardware", - "optionHardwareDescription": "Monitor server hardware metrics like CPU, memory, and disk.", "optionHttp": "HTTP(S)", "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", - "optionPagespeed": "PageSpeed", - "optionPagespeedDescription": "Analyze page performance using Google PageSpeed Insights.", "optionPing": "Ping", "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", "optionPort": "Port", "optionPortDescription": "Monitor if a specific port on a server is open.", - "optionType": "Type", "title": "Type" } } @@ -1043,10 +651,6 @@ "overview": "Overview", "resolutionDetails": "Resolution details", "resolutionType": "Type:", - "resolutionTypes": { - "automatic": "Automatic", - "manual": "Manual" - }, "resolve": "Resolve Incident", "resolvedAt": "Resolved at:", "resolvedBy": "Resolved by:", @@ -1060,26 +664,26 @@ }, "filters": { "allMonitors": "All monitors", - "clearFilters": "Clear Filters", + "clearFilters": "Clear filters", "monitor": "Monitor", "resolutionType": "Resolution type", "resolutionTypes": { + "manual": "Manual", "automatic": "Automatic", - "manual": "Manual" + "all": "All" } }, "summaryCard": { "activeIncidents": { - "active_one": "{{count}} Active incident", - "active_other": "{{count}} Active incidents", "active_zero": "No active incidents", - "title": "Active Incidents" + "title": "Active Incidents", + "active": "active" }, "incidentStats": { - "avgResolutionTime": "Average resolution time", - "mostAffectedMonitor": "Most affected monitor", + "avgResolutionTime": "Avg Resolution Time", + "mostAffectedMonitor": "Most Affected Monitor", "title": "Incident Statistics", - "totalIncidents": "Total incidents" + "totalIncidents": "Total Incidents" }, "latestIncidents": { "title": "Latest Incidents" @@ -1101,7 +705,6 @@ "resolvedIncidents": "Resolved Incidents", "status": { "active": "Active", - "na": "N/A", "resolved": "Resolved" } } @@ -1117,15 +720,6 @@ "temp": "Temp" } }, - "fallback": { - "actionButton": "Let's create your first infrastructure monitor!", - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:" - }, "gauges": { "cpu": { "lowerLabel": "Max frequency", @@ -1164,6 +758,15 @@ "network": "Network", "overview": "Overview" } + }, + "fallback": { + "actionButton": "Let's create your first infrastructure monitor!", + "checks": [ + "Track the performance of your servers", + "Identify bottlenecks and optimize usage", + "Ensure reliability with real-time monitoring" + ], + "title": "An infrastructure monitor is used to:" } }, "notifications": { @@ -1189,10 +792,8 @@ "title": "Address" }, "homeServer": { - "description": "Select the home server for this notification channel.", "optionHomeServer": "Home server", - "placeholder": "example.com", - "title": "Home server" + "placeholder": "example.com" }, "matrix": { "description": "Configure your Matrix homeserver connection for notifications.", @@ -1243,6 +844,11 @@ "title": "Performance report" } }, + "table": { + "headers": { + "pageSpeedScore": "PageSpeed score" + } + }, "fallback": { "actionButton": "Let's create your first PageSpeed monitor!", "checks": [ @@ -1251,137 +857,59 @@ "Give suggestions on how the page can be improved" ], "title": "A PageSpeed monitor is used to:" - }, - "table": { - "headers": { - "pageSpeedScore": "PageSpeed score" - } } }, "statusPages": { "fallback": { - "actionButton": "Create a status page!", - "checks": [ - "Monitor and display the health of your services in real time", - "Track multiple services and share their status", - "Keep users informed about outages and performance" - ], - "title": "A status page is used to:" - }, - "form": { - "access": { - "description": "Choose if you want your status page to be public", - "option": { - "published": { - "name": "Published and publicly visible" - } - }, - "title": "Access" - }, - "appearance": { - "description": "Define the default look and feel of your public status page", - "option": {}, - "title": "Appearance" - }, - "basicInfo": { - "description": "Name and address of your status page", - "option": { - "name": { - "label": "Name", - "placeholder": "My status page" - }, - "url": { - "label": "URL", - "placeholder": "my-status-page" - } - }, - "title": "Basic information" - }, - "monitors": { - "description": "Select which monitors to display on your status page", - "noMonitors": "No monitors available. Create uptime monitors first.", - "option": { - "monitors": { - "label": "Monitors", - "placeholder": "Select monitors..." - } - }, - "title": "Monitors" - }, - "timezone": { - "description": "Select the timezone for your status page", - "option": { - "timezone": { - "label": "Timezone", - "placeholder": "Search timezones..." - } - }, - "title": "Timezone" - } + "actionButton": "Create status page", + "checks": "No status pages found", + "title": "Status pages" }, "monitorsList": { "chartTypeHeatmap": "Heatmap", "chartTypeHistogram": "Histogram" }, - "serviceStauts": "Service stauts", - "settings": { - "description": "Configure basic settings for your status page.", - "option": { - "name": { - "label": "Status page name", - "placeholder": "e.g. My Company Status Page" - }, - "published": { - "label": "Published and visible to the public" - }, - "subdomain": { - "label": "Subdomain", - "placeholder": "e.g. my-company" - }, - "timezone": { - "label": "Timezone" - } - }, - "title": "Status page settings" - }, "statusBar": { - "allDown": "All systems down", + "allDown": "All systems are down", "allUp": "All systems operational", - "degraded": "Degraded performance", - "unknown": "Unknown status" - } + "degraded": "Some systems are experiencing issues", + "unknown": "Unable to determine system status" + }, + "table": { + "headers": { + "name": "Status page name", + "url": "Public URL" + }, + "published": "Published", + "unpublished": "Unpublished" + }, + "title": "Status pages" }, "uptime": { + "filters": { + "search": { + "placeholder": "Search monitors..." + } + }, "table": { "headers": { "responseTime": "Response time" } }, - "filters": { - "search": { - "placeholder": "Search monitors..." - } + "fallback": { + "actionButton": "Create a monitor!", + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ], + "title": "An uptime monitor is used to:" } } }, - "pageSpeed": { - "fallback": { - "actionButton": "Let's create your first PageSpeed monitor!", - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:" - } - }, "pageSpeedAddApiKey": "to add your API key.", - "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", - "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", - "pageSpeedDetailsPerformanceReportCalculator": "See calculator", "pageSpeedLearnMoreLink": "Click here", - "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", - "pageSpeedMonitor": "PageSpeed monitor", "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", "passwordPanel": { "confirmNewPassword": "Confirm new password", @@ -1391,15 +919,10 @@ "newPassword": "New password", "passwordChangedSuccess": "Your password was changed successfully.", "passwordInputIncorrect": "Your password input was incorrect.", - "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`).", - "saving": "Saving..." + "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)." }, "pause": "Pause", "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "pingMonitoring": "Ping monitoring", - "pingMonitoringDescription": "Check whether your server is available or not.", - "portMonitoring": "Port monitoring", - "portMonitoringDescription": "Check whether your port is open or not.", "portToMonitor": "Port to monitor", "publicLink": "Public link", "queuePage": { @@ -1431,8 +954,7 @@ "title": "Queue metrics", "valueHeader": "Value" }, - "refreshButton": "Refresh", - "title": "Queue" + "refreshButton": "Refresh" }, "rate": "Rate", "remove": "Remove", @@ -1441,7 +963,6 @@ "reset": "Reset", "response": "RESPONSE", "responseTime": "Response time", - "responseTimeBreakdown": "Response time breakdown", "resume": "Resume", "roles": { "admin": "Admin", @@ -1451,13 +972,7 @@ }, "save": "Save", "selectAll": "Select all", - "selectInterface": "Select Interface", - "sendTestNotifications": "Send test notifications", - "seperateEmails": "You can separate multiple emails with a comma", - "settingsAppearance": "Appearance", - "settingsDisabled": "Disabled", "settingsDisplayTimezone": "Display timezone", - "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", "settingsFailedToClearStats": "Failed to clear stats", "settingsFailedToDeleteMonitors": "Failed to delete all monitors", "settingsFailedToSave": "Failed to save settings", @@ -1466,7 +981,6 @@ "settingsPage": { "aboutSettings": { "labelDevelopedBy": "Developed by Bluewave Labs", - "labelVersion": "Version", "title": "About" }, "demoMonitorsSettings": { @@ -1514,21 +1028,17 @@ "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", "clearAllStatsDialogTitle": "Do you want to clear all stats?", "description": "Define how long you want to retain historical data. You can also clear all existing data.", - "labelTTL": "The days you want to keep monitoring history.", - "labelTTLOptional": "0 for infinite", "title": "Monitor history" }, "systemResetSettings": { "buttonRemoveAllMonitors": "Remove all monitors", "description": "Remove all monitors from your system.", "dialogConfirm": "Yes, remove all monitors", - "dialogDescription": "Once removed, the monitors cannot be retrieved.", "dialogTitle": "Do you want to remove all monitors?", "title": "System reset" }, "timezoneSettings": { "description": "Select the timezone used to display dates and times throughout the application.", - "label": "Display timezone", "title": "Display timezone" }, "title": "Settings", @@ -1549,7 +1059,6 @@ "title": "Monitor IP/URL on Status Page" } }, - "settingsSave": "Save", "settingsStatsCleared": "Stats cleared successfully", "settingsSuccessSaved": "Settings saved successfully", "settingsTestEmailFailed": "Failed to send test email", @@ -1568,28 +1077,20 @@ "statusBreadCrumbsDetails": "Details", "statusBreadCrumbsStatusPages": "Status Pages", "statusCode": "Status code", - "statusMsg": { - "down": "Your site is down.", - "paused": "Monitoring is paused.", - "pending": "Pending...", - "up": "Your site is up." - }, "statusPage": { "contents": "Contents", "createSuccess": "Status page created successfully", "deleteFailed": "Failed to delete status page", "deleteSuccess": "Status page deleted successfully", - "fallback": { - "actionButton": "Let's create your first status page!", - "checks": [ - "Monitor and display the health of your services in real time", - "Track multiple services and share their status", - "Keep users informed about outages and performance" - ], - "title": "A status page is used to:" - }, "generalSettings": "General settings", - "updateSuccess": "Status page updated successfully" + "updateSuccess": "Status page updated successfully", + "details": { + "statusHeader": { + "allUp": "All systems operational", + "allDown": "All systems are experiencing issues", + "anyDown": "Some systems are experiencing issues" + } + } }, "statusPageCreate": { "buttonSave": "Save" @@ -1607,7 +1108,6 @@ "statusPageStatusServiceStatus": "Service status", "submit": "Submit", "SupportedFormats": "Supported formats", - "tcpConnection": "TCP connection", "teamPanel": { "addTeamMember": { "addButton": "Add Member", @@ -1616,6 +1116,12 @@ "title": "Register new team member" }, "cancel": "Cancel", + "changeTeamPassword": { + "changePasswordMenu": "Reset Password", + "description": "Create a new password for this team member. You will need to share the password with them securely.", + "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way.", + "title": "Reset team member password" + }, "email": "Email", "emailToken": "E-mail token", "filter": { @@ -1629,114 +1135,72 @@ "inviteTeamMember": "Invite a team member", "noMembers": "There are no team members with this role", "register": "Register a team member", + "registerTeamMember": { + "auth": { + "common": { + "inputs": { + "role": { + "errors": { + "empty": "Role is required" + } + } + } + } + } + }, "registerToast": { "success": "User created, share credentials with the member securely." }, + "role": "Role", "selectRole": "Select role", "table": { "created": "Created", "email": "Email", - "headers": { - "name": "Status page name", - "url": "Public URL" - }, "name": "Name", "role": "Role" }, - "teamMembers": "Team members", - "uptime": { - "fallback": { - "actionButton": "Create a monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:" - }, - "filters": { - "search": { - "placeholder": "Search monitors..." - } - }, - "table": { - "headers": { - "responseTime": "Response time" - } - } - } + "teamMembers": "Team members" }, - "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { "fifteenMinutes": "15 minutes", "fifteenSeconds": "15 seconds", "fiveMinutes": "5 minutes", "fourMinutes": "4 minutes", - "oneDay": "1 day", - "oneHour": "1 hour", "oneMinute": "1 minute", - "oneWeek": "1 week", "tenMinutes": "10 minutes", "thirtyMinutes": "30 minutes", "thirtySeconds": "30 seconds", "threeMinutes": "3 minutes", - "twentyMinutes": "20 minutes", "twoMinutes": "2 minutes" }, - "timeToFirstByte": "Time to first byte", "timezone": "Timezone", "timeZoneInfo": "All dates and times are in GMT+0 time zone.", "title": "Title", - "tlsErrorIgnored": "TLS/SSL errors ignored", - "tlsHandshake": "TLS handshake", "total": "Total", "type": "Type", "update": "Update", "uptime": "Uptime", - "uptimeAdvancedMatching": { - "jsonPath": "JSON Path" - }, - "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", - "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", - "uptimeCreateJsonPathQuery": "for query language documentation.", - "uptimeGeneralInstructions": { - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." - }, - "uptimeMonitor": { - "fallback": { - "actionButton": "Let's create your first uptime monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:" - } - }, "url": "URL", - "urlMonitor": "URL to monitor", "used": "Used", - "v1": { - "infrastructure": { - "disk_selection_description": "Select the specific disks you want to monitor.", - "disk_selection_info": "No disk detected for the moment.", - "disk_selection_title": "Disk Selection" - } - }, - "waitTime": "Wait time", - "webhookSendError": "Error sending webhook notification to {platform}", - "webhookSendSuccess": "Webhook notification sent successfully", - "webhookUnsupportedPlatform": "Unsupported platform: {platform}", - "websiteMonitoring": "Website monitoring", - "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", - "whenNewIncident": "When there is a new incident,", "window": "window", - "YourPhoto": "Profile photo" + "YourPhoto": "Profile photo", + "failedDeleteMonitor": "Failed to delete monitor", + "failedPauseMonitor": "Failed to pause monitor", + "hour": "hour", + "minute": "minute", + "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:" + } + } } From bbb61a6f884c89bb830f647bd159a3eb4147791e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 10:40:00 -0800 Subject: [PATCH 139/392] format --- .../v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx b/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx index 7219ba821..1663ab3b5 100644 --- a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx +++ b/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx @@ -8,8 +8,9 @@ import type { TooltipProps } from "recharts"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; -interface HistogramPageSpeedScoresTooltipProps - extends Partial> { +interface HistogramPageSpeedScoresTooltipProps extends Partial< + TooltipProps +> { config: Record; } export const HistogramPageSpeedScoresTooltip = ({ From a1e6eaa9d042fe513af9a1490512a2ef72bd7d84 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 19:03:17 +0000 Subject: [PATCH 140/392] submit --- client/src/Pages/StatusPage/Create/index.tsx | 69 ++++++++++++++----- client/src/Pages/StatusPage/Status/index.tsx | 31 ++++++++- client/src/Routes/index.jsx | 8 ++- client/src/locales/en.json | 71 ++++++++++++++++++-- 4 files changed, 154 insertions(+), 25 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index ec5245e41..104ce6476 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -20,9 +20,11 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; -import { useGet } from "@/Hooks/UseApi"; +import { useGet, usePost } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import timezones from "@/Utils/timezones.json"; +import { useNavigate } from "react-router-dom"; +import axios from "axios"; interface TimezoneOption { _id: string; @@ -32,8 +34,10 @@ interface TimezoneOption { const CreateStatusPage = () => { const theme = useTheme(); const { t } = useTranslation(); + const navigate = useNavigate(); const { schema, defaults } = useStatusPageForm(); + const { post, loading: isSubmitting } = usePost(); const { data: monitorsResponse } = useGet( "/monitors/team?type=http&type=ping&type=port&type=docker" @@ -55,8 +59,39 @@ const CreateStatusPage = () => { console.log(errors); }; - const onSubmit = (data: StatusPageFormData) => { - console.log("Form Data Submitted: ", data); + const onSubmit = async (data: StatusPageFormData) => { + const fd = new FormData(); + fd.append("type", "uptime"); + fd.append("isPublished", String(data.isPublished)); + if (data.companyName) fd.append("companyName", data.companyName); + if (data.url) fd.append("url", data.url); + if (data.timezone) fd.append("timezone", data.timezone); + + data.monitors.forEach((monitorId) => { + fd.append("monitors[]", monitorId); + }); + + if (data.logo?.data && data.logo.data !== "") { + try { + const imageResult = await axios.get(data.logo.data, { + responseType: "blob", + }); + fd.append("logo", imageResult.data); + if (data.logo.data.startsWith("blob:")) { + URL.revokeObjectURL(data.logo.data); + } + } catch (e) { + console.error("Error fetching logo blob:", e); + } + } + + const result = await post("/status-page", fd, { + headers: { "Content-Type": "multipart/form-data" }, + }); + + if (result) { + navigate(`/status/uptime/${data.url}`); + } }; return ( @@ -137,10 +172,18 @@ const CreateStatusPage = () => { name="monitors" control={control} render={({ field, fieldState }) => { - // Preserve order from field.value (IDs) when building selectedMonitors const selectedMonitors = field.value .map((id: string) => monitors.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); + .filter(Boolean) as Monitor[]; + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + const reordered = Array.from(field.value); + const [removed] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, removed); + field.onChange(reordered); + }; + return ( { onChange={(_, newValue: Monitor[]) => { field.onChange(newValue.map((m) => m.id)); }} - isOptionEqualToValue={(option, value) => option.id === value.id} fieldLabel={t( "pages.statusPages.form.monitors.option.monitors.label" )} @@ -165,24 +207,15 @@ const CreateStatusPage = () => { helperText={fieldState.error?.message ?? ""} /> )} + renderTags={() => null} /> {selectedMonitors.length > 0 && ( - { - if (!result.destination) return; - const reordered = Array.from(field.value); - const [removed] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, removed); - field.onChange(reordered); - }} - > + {(provided) => ( {selectedMonitors.map((monitor, index) => ( { justifyContent="flex-end" > + + ); +}; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 0175c925a..f8695ae80 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -29,6 +29,7 @@ import timezones from "@/Utils/timezones.json"; import { useNavigate, useParams } from "react-router-dom"; import axios from "axios"; import { MuiColorInput } from "mui-color-input"; +import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; interface TimezoneOption { _id: string; @@ -135,6 +136,13 @@ const CreateStatusPage = () => { component="form" onSubmit={handleSubmit(onSubmit, onError)} > + {!isCreate && ( + { + console.log("poo"); + }} + /> + )} Date: Tue, 3 Feb 2026 21:14:16 +0000 Subject: [PATCH 149/392] handle delete --- .../Components/HeaderConfigStatusControls.tsx | 4 +- client/src/Pages/StatusPage/Create/index.tsx | 41 +++++++++++++++---- .../Components/HeaderStatusPageControls.tsx | 4 +- client/src/Pages/StatusPage/Status/index.tsx | 4 +- .../Components/StatusPagesTable.tsx | 4 +- .../Pages/StatusPage/StatusPages/index.tsx | 4 +- client/src/Routes/index.jsx | 8 ++-- client/src/locales/en.json | 1 + server/src/routes/statusPageRoute.ts | 2 +- 9 files changed, 47 insertions(+), 25 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx index f628fd00d..99ae46cdd 100644 --- a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx +++ b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx @@ -1,12 +1,10 @@ import Stack from "@mui/material/Stack"; -import { Icon, MonitorStatus } from "@/Components/v2/design-elements"; +import { Icon } from "@/Components/v2/design-elements"; import { Button } from "@/Components/v2/inputs"; import { Trash } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { useTheme } from "@mui/material/styles"; -import { usePost } from "@/Hooks/UseApi"; interface HeaderConfigStatusControlsProps { onDelete: () => void; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f8695ae80..83286277b 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -13,16 +13,17 @@ import { TextField, Autocomplete, Checkbox, + Dialog, } from "@/Components/v2/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; -import { useGet, usePost, usePut } from "@/Hooks/UseApi"; +import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import type { StatusPageResponse } from "@/Types/StatusPage"; import timezones from "@/Utils/timezones.json"; @@ -55,6 +56,10 @@ const CreateStatusPage = () => { const { post, loading: isSubmittingPost } = usePost(); const { put, loading: isSubmittingPut } = usePut(); + const { deleteFn, loading: isDeleting } = useDelete(); + + // Delete dialog state + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const isSubmitting = isSubmittingPost || isSubmittingPut; const { schema, defaults } = useStatusPageForm({ @@ -78,6 +83,22 @@ const CreateStatusPage = () => { console.log(errors); }; + const handleDeleteClick = () => { + setIsDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + const result = await deleteFn(`/status-page/${statusPageData?.statusPage?.id}`); + if (result) { + navigate("/status"); + } + setIsDeleteDialogOpen(false); + }; + + const handleDeleteCancel = () => { + setIsDeleteDialogOpen(false); + }; + const onSubmit = async (data: StatusPageFormData) => { const fd = new FormData(); fd.append("type", "uptime"); @@ -136,13 +157,7 @@ const CreateStatusPage = () => { component="form" onSubmit={handleSubmit(onSubmit, onError)} > - {!isCreate && ( - { - console.log("poo"); - }} - /> - )} + {!isCreate && } { {t("common.buttons.save")} + ); }; diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx index 4fc2e41e1..d738671ac 100644 --- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx +++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx @@ -50,7 +50,7 @@ export const HeaderStatusPageControls = ({ { window.open( - `/status/uptime/public/${statusPage.url}`, + `/status/public/${statusPage.url}`, "_blank", "noopener,noreferrer" ); @@ -77,7 +77,7 @@ export const HeaderStatusPageControls = ({ variant="contained" color="secondary" startIcon={} - onClick={() => navigate(`/status/uptime/configure/${statusPage.url}`)} + onClick={() => navigate(`/status/configure/${statusPage.url}`)} > {t("common.buttons.configure")} diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index fcb92f2f0..6cb9830b9 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -19,7 +19,7 @@ const StatusPageView = () => { const { url } = useParams(); const isAdmin = useIsAdmin(); const location = useLocation(); - const isPublic = location.pathname.startsWith("/status/uptime/public"); + const isPublic = location.pathname.startsWith("/status/public"); const apiUrl = url ? `/status-page/${url}?type=uptime` : null; @@ -47,7 +47,7 @@ const StatusPageView = () => { {t("pages.statusPages.details.empty.title")} {isAdmin && ( - + {t("pages.statusPages.details.empty.addMonitor")} )} diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx index 1741aeadf..d832751de 100644 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -22,7 +22,7 @@ export const StatusPagesTable = ({ data }: StatusPagesTableProps) => { const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => { if (row.isPublished) { e.stopPropagation(); - const url = `/status/uptime/public/${row.url}`; + const url = `/status/public/${row.url}`; window.open(url, "_blank", "noopener,noreferrer"); } }; @@ -91,7 +91,7 @@ export const StatusPagesTable = ({ data }: StatusPagesTableProps) => { }; const handleRowClick = (statusPage: StatusPage) => { - navigate(`/status/uptime/${statusPage.url}`); + navigate(`/status/${statusPage.url}`); }; return ( diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx index 6a34e587d..0c891591b 100644 --- a/client/src/Pages/StatusPage/StatusPages/index.tsx +++ b/client/src/Pages/StatusPage/StatusPages/index.tsx @@ -27,10 +27,10 @@ const StatusPages = () => { error={!!error} items={statusPages ?? []} actionButtonText={t("pages.statusPages.fallback.actionButton")} - actionLink="/status/uptime/create" + actionLink="/status/create" > diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 078d05d75..908d7efe1 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -237,7 +237,7 @@ const Routes = () => { /> @@ -248,7 +248,7 @@ const Routes = () => { /> @@ -259,7 +259,7 @@ const Routes = () => { /> @@ -377,7 +377,7 @@ const Routes = () => { element={} /> diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d98d4fbdd..04eca42b0 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -861,6 +861,7 @@ } }, "statusPages": { + "deleteSuccess": "Status page deleted successfully", "fallback": { "title": "A status page is used to:", "checks": [ diff --git a/server/src/routes/statusPageRoute.ts b/server/src/routes/statusPageRoute.ts index f70802f73..1468f9d16 100755 --- a/server/src/routes/statusPageRoute.ts +++ b/server/src/routes/statusPageRoute.ts @@ -19,7 +19,7 @@ class StatusPageRoutes { this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); this.router.get("/:url", this.statusPageController.getStatusPageByUrl); - this.router.delete("/:url(*)", verifyJWT, this.statusPageController.deleteStatusPage); + this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); } getRouter() { From f799d460257764e68c84f430b54afd785eabc33f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 21:29:02 +0000 Subject: [PATCH 150/392] cleanup, style picler --- .../src/Components/v2/inputs/ColorPicker.tsx | 48 +++ client/src/Components/v2/inputs/index.tsx | 1 + .../Create/Components/MonitorList/index.jsx | 110 ------- .../Create/Components/Progress/index.jsx | 43 --- .../Create/Components/Skeleton/index.jsx | 14 - .../Create/Components/Tabs/ConfigStack.jsx | 31 -- .../Create/Components/Tabs/Content.jsx | 107 ------ .../Create/Components/Tabs/Settings.jsx | 187 ----------- .../Create/Components/Tabs/index.jsx | 99 ------ .../Create/Hooks/useCreateStatusPage.jsx | 25 -- .../Create/Hooks/useMonitorsFetch.jsx | 33 -- client/src/Pages/StatusPage/Create/index.tsx | 6 +- client/src/Pages/StatusPage/Create/old.jsx | 311 ------------------ .../Status/Hooks/useStatusPageDelete.jsx | 43 --- .../Status/Hooks/useStatusPageFetch.jsx | 53 --- client/src/locales/en.json | 20 -- 16 files changed, 52 insertions(+), 1079 deletions(-) create mode 100644 client/src/Components/v2/inputs/ColorPicker.tsx delete mode 100644 client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Components/Progress/index.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx delete mode 100644 client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx delete mode 100644 client/src/Pages/StatusPage/Create/old.jsx delete mode 100644 client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx delete mode 100644 client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx diff --git a/client/src/Components/v2/inputs/ColorPicker.tsx b/client/src/Components/v2/inputs/ColorPicker.tsx new file mode 100644 index 000000000..f52e66317 --- /dev/null +++ b/client/src/Components/v2/inputs/ColorPicker.tsx @@ -0,0 +1,48 @@ +import { MuiColorInput } from "mui-color-input"; +import type { MuiColorInputProps } from "mui-color-input"; +import { typographyLevels } from "@/Utils/Theme/v2Palette"; +import { useTheme } from "@mui/material"; +import Stack from "@mui/material/Stack"; +import { FieldLabel } from "@/Components/v2/inputs/FieldLabel"; + +interface ColorPickerProps extends MuiColorInputProps { + fieldLabel?: string; + required?: boolean; +} + +export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps) => { + const theme = useTheme(); + const input = ( + + ); + if (fieldLabel) { + return ( + + {fieldLabel} + {input} + + ); + } + + return input; +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index ad7c984bd..fa04903c9 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -13,3 +13,4 @@ export * from "./Radio"; export * from "./Switch"; export * from "./Slider"; export * from "./ImageUpload"; +export * from "./ColorPicker"; diff --git a/client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx b/client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx deleted file mode 100644 index 349571fe8..000000000 --- a/client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx +++ /dev/null @@ -1,110 +0,0 @@ -// Components -import { Stack, Typography } from "@mui/material"; -import Icon from "@/Components/v1/Icon"; - -// Utils -import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; -import { useTheme } from "@emotion/react"; -const MonitorListItem = ({ - monitor, - innerRef, - draggableProps, - dragHandleProps, - onDelete, -}) => { - const theme = useTheme(); - return ( - - - {monitor.name} - { - onDelete(monitor); - }} - /> - - ); -}; - -const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => { - const onDelete = (monitorToDelete) => { - const newMonitors = selectedMonitors.filter( - (monitor) => monitor.id !== monitorToDelete.id - ); - setSelectedMonitors(newMonitors); - }; - const reorder = (list, startIndex, endIndex) => { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - - return result; - }; - - const onDragEnd = (result) => { - // dropped outside the list - if (!result.destination) { - return; - } - - const reorderedMonitors = reorder( - selectedMonitors, - result.source.index, - result.destination.index - ); - - setSelectedMonitors(reorderedMonitors); - }; - - return ( - - - {(provided, snapshot) => ( - - {selectedMonitors?.map((monitor, index) => ( - - {(provided, snapshot) => ( - - )} - - ))} - {provided.placeholder} - - )} - - - ); -}; - -export default MonitorList; diff --git a/client/src/Pages/StatusPage/Create/Components/Progress/index.jsx b/client/src/Pages/StatusPage/Create/Components/Progress/index.jsx deleted file mode 100644 index 594fd9709..000000000 --- a/client/src/Pages/StatusPage/Create/Components/Progress/index.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Button, Box } from "@mui/material"; -import ProgressUpload from "@/Components/v1/ProgressBars/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import { useTranslation } from "react-i18next"; -import { formatBytes } from "../../../../../Utils/fileUtils.js"; -const Progress = ({ isLoading, progressValue, logo, logoType, removeLogo, errors }) => { - const { t } = useTranslation(); - if (isLoading) { - return ( - - } - label={logo?.name} - size={formatBytes(logo?.size)} - progress={progressValue} - onClick={removeLogo} - /> - ); - } - - if (logo && logoType) { - return ( - - - - ); - } -}; - -export default Progress; diff --git a/client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx b/client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx deleted file mode 100644 index 482c490fb..000000000 --- a/client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx deleted file mode 100644 index f31c456ef..000000000 --- a/client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Stack, Typography } from "@mui/material"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; - -// This can be used to add any extra/additional section/stacks on top of existing sections on the tab -const ConfigStack = ({ title, description, children }) => { - const theme = useTheme(); - return ( - - - - {title} - - {description} - - {children} - - ); -}; - -ConfigStack.propTypes = { - title: PropTypes.string.isRequired, // Title must be a string and is required - description: PropTypes.string.isRequired, // Description must be a string and is required - children: PropTypes.node.isRequired, -}; - -export default ConfigStack; diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx deleted file mode 100644 index e84de16dc..000000000 --- a/client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx +++ /dev/null @@ -1,107 +0,0 @@ -// Components -import { Stack, Typography } from "@mui/material"; -import { TabPanel } from "@mui/lab"; -import MonitorList from "../MonitorList/index.jsx"; -import Search from "@/Components/v1/Inputs/Search/index.jsx"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import ConfigStack from "./ConfigStack.jsx"; -const Content = ({ - tabValue, - form, - monitors, - handleFormChange, - errors, - selectedMonitors, - setSelectedMonitors, -}) => { - // Local state - const [search, setSearch] = useState(""); - - // Handlers - const handleMonitorsChange = (selectedMonitors) => { - handleFormChange({ - target: { name: "monitors", value: selectedMonitors.map((monitor) => monitor.id) }, - }); - setSelectedMonitors(selectedMonitors); - }; - - // Utils - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - - - - - - - {errors["monitors"]} - - - - - - - - - - - - - - ); -}; - -export default Content; diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx deleted file mode 100644 index d4c583d3a..000000000 --- a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx +++ /dev/null @@ -1,187 +0,0 @@ -// Components -import { Stack, Typography } from "@mui/material"; -import { TabPanel } from "@mui/lab"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Search from "@/Components/v1/Inputs/Search/index.jsx"; -import ImageUpload from "@/Components/v1/Inputs/ImageUpload/index.jsx"; -import ColorPicker from "@/Components/v1/Inputs/ColorPicker/index.jsx"; -import Progress from "../Progress/index.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import timezones from "../../../../../Utils/timezones.json"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; -import { useMemo, useState, useCallback } from "react"; - -const TabSettings = ({ - isCreate, - tabValue, - form, - handleFormChange, - handleImageChange, - progress, - removeLogo, - errors, -}) => { - // Utils - const theme = useTheme(); - const { t } = useTranslation(); - const [rawInput, setRawInput] = useState(""); - - const selectedTimezone = useMemo( - () => timezones.find((tz) => tz._id === form.timezone) ?? null, - [form.timezone] - ); - - const handleTimezoneChange = useCallback( - (newValue) => { - setRawInput(""); - handleFormChange({ - target: { - name: "timezone", - value: newValue?._id ?? "", - }, - }); - }, - [handleFormChange] - ); - - return ( - - - - - - {t("access")} - - {t("statusPageCreateSettings")} - - - - - - - - - {t("basicInformation")} - - - {t("statusPageCreateBasicInfoDescription")} - - - - - - - - - - - {t("timezone")} - - - {t("statusPageCreateSelectTimeZoneDescription")} - - - - setRawInput(newVal)} - handleChange={handleTimezoneChange} - isAdorned={true} - unit="timezone" - /> - - - - - - {t("statusPageCreateAppearanceTitle")} - - - {t("statusPageCreateAppearanceDescription")} - - - - - - - - - - - ); -}; - -TabSettings.propTypes = { - isCreate: PropTypes.bool, - tabValue: PropTypes.string, - form: PropTypes.object, - handleFormChange: PropTypes.func, - handleImageChange: PropTypes.func, - progress: PropTypes.object, - removeLogo: PropTypes.func, - errors: PropTypes.object, -}; - -export default TabSettings; diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx deleted file mode 100644 index e8669c474..000000000 --- a/client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx +++ /dev/null @@ -1,99 +0,0 @@ -// Components -import { TabContext } from "@mui/lab"; -import { Tab } from "@mui/material"; -import Settings from "./Settings.jsx"; -import Content from "./Content.jsx"; - -// Utils -import PropTypes from "prop-types"; -import CustomTabList from "@/Components/v1/Tab/index.jsx"; - -const Tabs = ({ - isCreate, - form, - errors, - monitors, - selectedMonitors, - setSelectedMonitors, - handleFormChange, - handleImageChange, - progress, - removeLogo, - tab, - setTab, - TAB_LIST, - handleDelete, - isDeleteOpen, - setIsDeleteOpen, - isDeleting, - isLoading, -}) => { - return ( - - { - setTab(TAB_LIST.indexOf(selected)); - }} - aria-label="status page tabs" - > - {TAB_LIST.map((tabLabel) => ( - - ))} - - {tab === 0 ? ( - - ) : ( - - )} - - ); -}; - -Tabs.propTypes = { - isCreate: PropTypes.bool, - form: PropTypes.object, - errors: PropTypes.object, - monitors: PropTypes.array, - selectedMonitors: PropTypes.array, - setSelectedMonitors: PropTypes.func, - handleFormChange: PropTypes.func, - handleImageChange: PropTypes.func, - progress: PropTypes.object, - removeLogo: PropTypes.func, - tab: PropTypes.number, - setTab: PropTypes.func, - TAB_LIST: PropTypes.array, - handleDelete: PropTypes.func, - isDeleteOpen: PropTypes.bool, - setIsDeleteOpen: PropTypes.func, - isDeleting: PropTypes.bool, - isLoading: PropTypes.bool, -}; - -export default Tabs; diff --git a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx b/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx deleted file mode 100644 index c0bcf5ab4..000000000 --- a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useState } from "react"; -import { networkService } from "../../../../main.jsx"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; - -const useCreateStatusPage = (isCreate) => { - const [isLoading, setIsLoading] = useState(false); - const [networkError, setNetworkError] = useState(false); - const createStatusPage = async ({ form, id }) => { - setIsLoading(true); - try { - await networkService.createStatusPage({ form, isCreate, id }); - return true; - } catch (error) { - setNetworkError(true); - createToast({ body: error?.response?.data?.msg ?? error.message }); - return false; - } finally { - setIsLoading(false); - } - }; - - return [createStatusPage, isLoading, networkError]; -}; - -export { useCreateStatusPage }; diff --git a/client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx b/client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx deleted file mode 100644 index 03ebb07bf..000000000 --- a/client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from "react"; -import { networkService } from "../../../../main.jsx"; -import { useSelector } from "react-redux"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; - -const useMonitorsFetch = () => { - const { user } = useSelector((state) => state.auth); - - const [monitors, setMonitors] = useState(undefined); - const [isLoading, setIsLoading] = useState(true); - const [networkError, setNetworkError] = useState(false); - useEffect(() => { - const fetchMonitors = async () => { - try { - const response = await networkService.getMonitorsByTeamId({ - limit: null, // donot return any checks for the monitors - types: ["http", "ping", "port", "game"], // include game servers in status page monitor selection - }); - setMonitors(response.data.data); - } catch (error) { - setNetworkError(true); - createToast({ body: error.message }); - } finally { - setIsLoading(false); - } - }; - fetchMonitors(); - }, [user]); - - return [monitors, isLoading, networkError]; -}; - -export { useMonitorsFetch }; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 83286277b..f5eff80ca 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -14,6 +14,7 @@ import { Autocomplete, Checkbox, Dialog, + ColorInput, } from "@/Components/v2/inputs"; import { useTheme } from "@mui/material/styles"; @@ -29,7 +30,6 @@ import type { StatusPageResponse } from "@/Types/StatusPage"; import timezones from "@/Utils/timezones.json"; import { useNavigate, useParams } from "react-router-dom"; import axios from "axios"; -import { MuiColorInput } from "mui-color-input"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; interface TimezoneOption { @@ -393,11 +393,11 @@ const CreateStatusPage = () => { name="color" control={control} render={({ field }) => ( - )} /> diff --git a/client/src/Pages/StatusPage/Create/old.jsx b/client/src/Pages/StatusPage/Create/old.jsx deleted file mode 100644 index c5aee4b0a..000000000 --- a/client/src/Pages/StatusPage/Create/old.jsx +++ /dev/null @@ -1,311 +0,0 @@ -// Components -import { Stack, Button, Typography } from "@mui/material"; -import Tabs from "./Components/Tabs/index.jsx"; -import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; -import SkeletonLayout from "./Components/Skeleton/index.jsx"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -//Utils -import { useTheme } from "@emotion/react"; -import { useState, useEffect, useRef, useCallback } from "react"; -import { statusPageValidation } from "../../../Validation/validation.js"; -import { buildErrors } from "../../../Validation/error.js"; -import { useMonitorsFetch } from "./Hooks/useMonitorsFetch.jsx"; -import { useCreateStatusPage } from "./Hooks/useCreateStatusPage.jsx"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import { useNavigate } from "react-router-dom"; -import { useStatusPageFetch } from "../Status/Hooks/useStatusPageFetch.jsx"; -import { useParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -//Constants -const ERROR_TAB_MAPPING = [ - ["companyName", "url", "timezone", "color", "isPublished", "logo"], - ["monitors", "showUptimePercentage", "showCharts", "showAdminLoginLink"], -]; - -const CreateStatusPage = () => { - const { url } = useParams(); - //Local state - const [tab, setTab] = useState(0); - const [progress, setProgress] = useState({ value: 0, isLoading: false }); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const [form, setForm] = useState({ - isPublished: false, - companyName: "", - url: url ?? Math.floor(Math.random() * 1000000).toFixed(0), - logo: undefined, - timezone: "America/Toronto", - color: "#4169E1", - type: "uptime", - monitors: [], - showCharts: true, - showUptimePercentage: true, - showAdminLoginLink: false, - }); - const [errors, setErrors] = useState({}); - const [selectedMonitors, setSelectedMonitors] = useState([]); - // Refs - const intervalRef = useRef(null); - - // Setup - const isCreate = typeof url === "undefined"; - - //Utils - const theme = useTheme(); - const [monitors, isLoading, networkError] = useMonitorsFetch(); - const navigate = useNavigate(); - const { t } = useTranslation(); - - const [createStatusPage] = useCreateStatusPage(isCreate); - const [statusPage, statusPageMonitors, statusPageIsLoading, , fetchStatusPage] = - useStatusPageFetch(isCreate, url); - // const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url); - - // Handlers - const handleFormChange = (e) => { - let { type, name, value, checked } = e.target; - // Handle errors - const { error } = statusPageValidation.validate( - { [name]: value }, - { abortEarly: false } - ); - setErrors((prev) => { - return buildErrors(prev, name, error); - }); - - //Handle checkbox - if (type === "checkbox") { - setForm((prev) => ({ - ...prev, - [name]: checked, - })); - return; - } - - // Handle other inputs - setForm((prev) => ({ - ...prev, - [name]: value, - })); - }; - - const handleImageChange = useCallback((fileObj) => { - if (!fileObj || !fileObj.file) return; - - setForm((prev) => ({ - ...prev, - logo: { - src: fileObj.src, - name: fileObj.name, - type: fileObj.file.type, - size: fileObj.file.size, - }, - })); - - intervalRef.current = setInterval(() => { - const buffer = 12; - setProgress((prev) => { - if (prev.value + buffer >= 100) { - clearInterval(intervalRef.current); - return { value: 100, isLoading: false }; - } - return { ...prev, value: prev.value + buffer }; - }); - }, 120); - }, []); - - const removeLogo = () => { - setForm((prev) => ({ - ...prev, - logo: undefined, - })); - // interrupt interval if image upload is canceled prior to completing the process - clearInterval(intervalRef.current); - setProgress({ value: 0, isLoading: false }); - }; - - /** - * Handle status page deletion with optimistic UI update - * Immediately navigates away without waiting for the deletion to complete - * to prevent unnecessary network requests for the deleted page - */ - const handleDelete = async () => { - setIsDeleteOpen(false); - // Start deletion process but don't wait for it - // deleteStatusPage(); - // Immediately navigate away to prevent additional fetches for the deleted page - navigate("/status"); - }; - - const handleSubmit = async () => { - let toSubmit = { - ...form, - logo: { type: form.logo?.type ?? null, size: form.logo?.size ?? null }, - }; - - const { error } = statusPageValidation.validate(toSubmit, { - abortEarly: false, - }); - - if (typeof error === "undefined") { - const success = await createStatusPage({ form, id: statusPage?.id }); - if (success) { - createToast({ - body: isCreate ? t("statusPage.createSuccess") : t("statusPage.updateSuccess"), - }); - navigate(`/status/uptime/${form.url}`); - } - return; - } - - const newErrors = {}; - error?.details?.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors((prev) => ({ ...prev, ...newErrors })); - const errorTabs = Object.keys(newErrors).map((err) => { - return ERROR_TAB_MAPPING.findIndex((tab) => tab.includes(err)); - }); - - // If there's an error in the current tab, don't change the tab - if (errorTabs.some((errorTab) => errorTab === tab)) { - return; - } - - // If we get -1, there's an unknown error - if (errorTabs[0] === -1) { - createToast({ body: t("common.toasts.unknownError") }); - return; - } - - // Otherwise go to tab with error - setTab(errorTabs[0]); - }; - - // If we are configuring, populate fields - useEffect(() => { - if (isCreate) return; - if (typeof statusPage === "undefined") { - return; - } - - let newLogo = undefined; - if (statusPage.logo && Object.keys(statusPage.logo).length > 0) { - newLogo = { - src: `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`, - name: "logo", - type: statusPage.logo.contentType, - size: null, - }; - } - setForm((prev) => { - return { - ...prev, - companyName: statusPage?.companyName, - isPublished: statusPage?.isPublished, - timezone: statusPage?.timezone, - monitors: statusPageMonitors.map((monitor) => monitor.id), - color: statusPage?.color, - logo: newLogo, - showCharts: statusPage?.showCharts ?? true, - showUptimePercentage: statusPage?.showUptimePercentage ?? true, - showAdminLoginLink: statusPage?.showAdminLoginLink ?? false, - }; - }); - setSelectedMonitors(statusPageMonitors); - }, [isCreate, statusPage, statusPageMonitors]); - - if (networkError === true) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - - if (isLoading) return ; - - // Load fields - return ( - - - {!isCreate && ( - - - setIsDeleteOpen(false)} - open={isDeleteOpen} - confirmationButtonLabel={t("deleteStatusPageConfirm")} - description={t("deleteStatusPageDescription")} - // isLoading={isDeleting || statusPageIsLoading} - /> - - )} - - - - - - ); -}; - -export default CreateStatusPage; diff --git a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx b/client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx deleted file mode 100644 index 73b9a8a5f..000000000 --- a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useState } from "react"; -import { networkService } from "../../../../Utils/NetworkService.js"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -/** - * Hook for deleting a status page with optimistic UI update - * @param {Function} fetchStatusPage - Function to fetch status page data - * @param {string} url - URL of the status page - * @returns {Array} - [deleteStatusPage function, isLoading state] - */ -const useStatusPageDelete = (fetchStatusPage, url) => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); - - /** - * Delete a status page with optimistic UI update - * @returns {Promise} - Success status - */ - const deleteStatusPage = async () => { - // We don't need to call fetchStatusPage after deletion - // This prevents the 404 error when trying to fetch a deleted status page - try { - setIsLoading(true); - await networkService.deleteStatusPage({ url }); - createToast({ - body: t("statusPage.deleteSuccess", "Status page deleted successfully"), - }); - return true; - } catch (error) { - createToast({ - body: t("statusPage.deleteFailed", "Failed to delete status page"), - }); - return false; - } finally { - setIsLoading(false); - } - }; - - return [deleteStatusPage, isLoading]; -}; - -export { useStatusPageDelete }; diff --git a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx b/client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx deleted file mode 100644 index 7db6b6c5e..000000000 --- a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useState, useCallback } from "react"; -import { networkService } from "../../../../main.jsx"; -import { useSelector } from "react-redux"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -import { useTheme } from "@emotion/react"; -import { useMonitorUtils } from "../../../../Hooks/useMonitorUtils.js"; - -const useStatusPageFetch = (isCreate = false, url) => { - const [isLoading, setIsLoading] = useState(true); - const [networkError, setNetworkError] = useState(false); - const [statusPage, setStatusPage] = useState(undefined); - const [monitors, setMonitors] = useState(undefined); - const theme = useTheme(); - const { getMonitorWithPercentage } = useMonitorUtils(); - const fetchStatusPage = useCallback(async () => { - try { - const response = await networkService.getStatusPageByUrl({ - url, - type: "uptime", - }); - if (!response?.data?.data) return; - const { statusPage, monitors } = response.data.data; - setStatusPage(statusPage); - - const monitorsWithPercentage = monitors.map((monitor) => - getMonitorWithPercentage(monitor, theme) - ); - - setMonitors(monitorsWithPercentage); - } catch (error) { - // If there is a 404, status page is not found - if (error?.response?.status === 404) { - setStatusPage(undefined); - return; - } - createToast({ body: error.message }); - setNetworkError(true); - } finally { - setIsLoading(false); - } - }, [theme, getMonitorWithPercentage, url]); - - useEffect(() => { - if (isCreate === true) { - return; - } - fetchStatusPage(); - }, [isCreate, fetchStatusPage]); - - return [statusPage, monitors, isLoading, networkError, fetchStatusPage]; -}; - -export { useStatusPageFetch }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 04eca42b0..a26f1808e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -359,7 +359,6 @@ "errors": "Errors", "expectedValue": "Expected value", "failedToSendEmail": "Failed to send email", - "features": "Features", "FirstName": "First name", "frequency": "Frequency", "friendlyNameInput": "Friendly name", @@ -1044,7 +1043,6 @@ }, "rate": "Rate", "remove": "Remove", - "removeLogo": "Remove Logo", "repeat": "Repeat", "reset": "Reset", "response": "RESPONSE", @@ -1058,7 +1056,6 @@ }, "save": "Save", "selectAll": "Select all", - "settingsDisplayTimezone": "Display timezone", "settingsFailedToClearStats": "Failed to clear stats", "settingsFailedToDeleteMonitors": "Failed to delete all monitors", "settingsFailedToSave": "Failed to save settings", @@ -1151,25 +1148,18 @@ "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", "settingsTestEmailSuccess": "Test email sent successfully", "settingsTestEmailUnknownError": "Unknown error", - "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", - "showCharts": "Show charts", "shown": "Shown", - "showUptimePercentage": "Show uptime percentage", "starPromptDescription": "See the latest releases and help grow the community on GitHub", "starPromptTitle": "Star Checkmate", "startTime": "Start time", "state": "State", "status": "Status", - "statusBreadCrumbsDetails": "Details", - "statusBreadCrumbsStatusPages": "Status Pages", "statusCode": "Status code", "statusPage": { "contents": "Contents", - "createSuccess": "Status page created successfully", "deleteFailed": "Failed to delete status page", "deleteSuccess": "Status page deleted successfully", "generalSettings": "General settings", - "updateSuccess": "Status page updated successfully", "details": { "statusHeader": { "allUp": "All systems operational", @@ -1181,16 +1171,6 @@ "statusPageCreate": { "buttonSave": "Save" }, - "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", - "statusPageCreateAppearanceTitle": "Appearance", - "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", - "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", - "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", - "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", - "statusPageCreateTabsContent": "Status page servers", - "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", - "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", "statusPageStatusServiceStatus": "Service status", "submit": "Submit", "SupportedFormats": "Supported formats", From 0fcceeb7a61fca812d9fc90d7001009832930ef7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 21:34:16 +0000 Subject: [PATCH 151/392] strings --- client/src/Pages/Incidents/Components/CardDetails.tsx | 1 - client/src/locales/en.json | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index 07ab49073..577c777de 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -27,7 +27,6 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { if (!incident) { return null; } - console.log(incident); return ( Date: Tue, 3 Feb 2026 21:40:17 +0000 Subject: [PATCH 152/392] actin menu --- client/src/Pages/StatusPage/Create/index.tsx | 2 +- .../Components/StatusPagesTable.tsx | 73 ++++++++++++++++++- .../Pages/StatusPage/StatusPages/index.tsx | 6 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f5eff80ca..cbf470abb 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -145,7 +145,7 @@ const CreateStatusPage = () => { } if (result) { - navigate(`/status/uptime/${data.url}`); + navigate(`/status/${data.url}`); } }; diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx index d832751de..ecc1b14bb 100644 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -2,22 +2,78 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Table, type Header, ValueLabel } from "@/Components/v2/design-elements"; +import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; +import { Dialog } from "@/Components/v2/inputs"; import { ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; +import { useDelete } from "@/Hooks/UseApi"; +import { useState } from "react"; +import { createToast } from "@/Utils/toastUtils"; import type { StatusPage } from "@/Types/StatusPage"; interface StatusPagesTableProps { data: StatusPage[]; + refetch: () => void; } -export const StatusPagesTable = ({ data }: StatusPagesTableProps) => { +export const StatusPagesTable = ({ data, refetch }: StatusPagesTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); + const { deleteFn, loading: isDeleting } = useDelete(); + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [selectedStatusPage, setSelectedStatusPage] = useState(null); + + const handleDeleteClick = (statusPage: StatusPage) => { + setSelectedStatusPage(statusPage); + setIsDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedStatusPage) return; + const result = await deleteFn(`/status-page/${selectedStatusPage.id}`); + if (result) { + createToast({ body: t("pages.statusPages.deleteSuccess") }); + refetch(); + } + setIsDeleteDialogOpen(false); + setSelectedStatusPage(null); + }; + + const handleDeleteCancel = () => { + setIsDeleteDialogOpen(false); + setSelectedStatusPage(null); + }; + + const getActions = (row: StatusPage): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("common.buttons.configure"), + action: () => { + navigate(`/status/configure/${row.url}`); + }, + closeMenu: true, + }, + { + id: 2, + label: ( + + {t("common.buttons.delete")} + + ), + action: () => { + handleDeleteClick(row); + }, + closeMenu: true, + }, + ]; + }; const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => { if (row.isPublished) { @@ -87,6 +143,13 @@ export const StatusPagesTable = ({ data }: StatusPagesTableProps) => { ); }, }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, ]; }; @@ -102,6 +165,14 @@ export const StatusPagesTable = ({ data }: StatusPagesTableProps) => { onRowClick={handleRowClick} emptyViewText={t("common.table.empty")} /> + ); }; diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx index 0c891591b..e1031e9e6 100644 --- a/client/src/Pages/StatusPage/StatusPages/index.tsx +++ b/client/src/Pages/StatusPage/StatusPages/index.tsx @@ -13,6 +13,7 @@ const StatusPages = () => { data: statusPages, isLoading, error, + refetch, } = useGet("/status-page/team"); const isAdmin = useIsAdmin(); @@ -33,7 +34,10 @@ const StatusPages = () => { path="/status/create" isAdmin={isAdmin} /> - + ); }; From e3921beac45454e1650742345e3c45d53b9724c3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 21:53:34 +0000 Subject: [PATCH 153/392] hide unused features --- client/src/Pages/StatusPage/Create/index.tsx | 5 +- .../Components/StatusPagesTable.tsx | 48 +++---------------- .../Pages/StatusPage/StatusPages/index.tsx | 29 ++++++++++- 3 files changed, 35 insertions(+), 47 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index cbf470abb..8b4394f14 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -251,7 +251,6 @@ const CreateStatusPage = () => { onChange={(_, newValue) => { field.onChange(newValue.map((m: Monitor) => m.id)); }} - renderTags={() => null} fieldLabel={t( "pages.statusPages.form.monitors.option.monitors.label" )} @@ -424,7 +423,7 @@ const CreateStatusPage = () => { /> )} /> - ( @@ -457,7 +456,7 @@ const CreateStatusPage = () => { )} /> )} - /> + /> */} } /> diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx index ecc1b14bb..192f9c101 100644 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -3,52 +3,26 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Table, type Header, ValueLabel } from "@/Components/v2/design-elements"; import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; -import { Dialog } from "@/Components/v2/inputs"; import { ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; -import { useDelete } from "@/Hooks/UseApi"; -import { useState } from "react"; -import { createToast } from "@/Utils/toastUtils"; import type { StatusPage } from "@/Types/StatusPage"; interface StatusPagesTableProps { data: StatusPage[]; - refetch: () => void; + setSelectedStatusPage: (statusPage: StatusPage | null) => void; } -export const StatusPagesTable = ({ data, refetch }: StatusPagesTableProps) => { +export const StatusPagesTable = ({ + data, + setSelectedStatusPage, +}: StatusPagesTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); - const { deleteFn, loading: isDeleting } = useDelete(); - - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [selectedStatusPage, setSelectedStatusPage] = useState(null); - - const handleDeleteClick = (statusPage: StatusPage) => { - setSelectedStatusPage(statusPage); - setIsDeleteDialogOpen(true); - }; - - const handleDeleteConfirm = async () => { - if (!selectedStatusPage) return; - const result = await deleteFn(`/status-page/${selectedStatusPage.id}`); - if (result) { - createToast({ body: t("pages.statusPages.deleteSuccess") }); - refetch(); - } - setIsDeleteDialogOpen(false); - setSelectedStatusPage(null); - }; - - const handleDeleteCancel = () => { - setIsDeleteDialogOpen(false); - setSelectedStatusPage(null); - }; const getActions = (row: StatusPage): ActionMenuItem[] => { return [ @@ -67,9 +41,7 @@ export const StatusPagesTable = ({ data, refetch }: StatusPagesTableProps) => { {t("common.buttons.delete")} ), - action: () => { - handleDeleteClick(row); - }, + action: () => setSelectedStatusPage(row), closeMenu: true, }, ]; @@ -165,14 +137,6 @@ export const StatusPagesTable = ({ data, refetch }: StatusPagesTableProps) => { onRowClick={handleRowClick} emptyViewText={t("common.table.empty")} /> - ); }; diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx index e1031e9e6..3ad555512 100644 --- a/client/src/Pages/StatusPage/StatusPages/index.tsx +++ b/client/src/Pages/StatusPage/StatusPages/index.tsx @@ -1,6 +1,8 @@ +import { useState } from "react"; import { BasePageWithStates } from "@/Components/v2/design-elements"; +import { Dialog } from "@/Components/v2/inputs"; import { StatusPagesTable } from "./Components/StatusPagesTable"; -import { useGet } from "@/Hooks/UseApi"; +import { useGet, useDelete } from "@/Hooks/UseApi"; import type { StatusPage } from "@/Types/StatusPage"; import { useTranslation } from "react-i18next"; import { HeaderCreate } from "@/Components/v2/common"; @@ -16,8 +18,23 @@ const StatusPages = () => { refetch, } = useGet("/status-page/team"); + const { deleteFn, loading: isDeleting } = useDelete(); + const [selectedStatusPage, setSelectedStatusPage] = useState(null); + const isDialogOpen = Boolean(selectedStatusPage); + const isAdmin = useIsAdmin(); + const handleConfirm = async () => { + if (!selectedStatusPage) return; + await deleteFn(`/status-page/${selectedStatusPage.id}`); + setSelectedStatusPage(null); + refetch(); + }; + + const handleCancel = () => { + setSelectedStatusPage(null); + }; + return ( { /> + ); From 0a408f8cfd255acf2786e5c8b52d202ec11cfa14 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 22:07:59 +0000 Subject: [PATCH 154/392] sorting --- client/src/Hooks/useStatusPageForm.ts | 2 +- server/src/controllers/statusPageController.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 40fff6fda..a079095cc 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -28,7 +28,7 @@ export const useStatusPageForm = ({ url: data?.url || generateDefaultUrl(), timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, color: data?.color || "#4169E1", - monitors: monitors?.map((m) => m.id) || [], + monitors: data?.monitors || [], isPublished: data?.isPublished ?? false, showCharts: data?.showCharts ?? true, showUptimePercentage: data?.showUptimePercentage ?? true, diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index 3271e39a1..f9cca161e 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -82,7 +82,15 @@ class StatusPageController { const showURL = settings.showURL; const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); - const normalizedMonitors = monitors.map((monitor) => { + // Sort monitors according to the order in statusPage.monitors + const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index])); + const sortedMonitors = [...monitors].sort((a, b) => { + const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + const normalizedMonitors = sortedMonitors.map((monitor) => { const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); if (!showURL) { const { url, port, secret, notifications, ...rest } = monitor; From 4080ac8c2c2d91952f1ae2e05a93f0f6945c106c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 22:40:26 +0000 Subject: [PATCH 155/392] format --- .../status-pages/MongoStatusPagesRepository.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts index a19e738fd..e19676285 100644 --- a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts +++ b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts @@ -32,9 +32,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { return undefined; } // Convert Buffer to base64 string for JSON serialization - const base64Data = Buffer.isBuffer(logo.data) - ? logo.data.toString("base64") - : logo.data; + const base64Data = Buffer.isBuffer(logo.data) ? logo.data.toString("base64") : logo.data; return { data: base64Data, contentType: logo.contentType, @@ -105,7 +103,12 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { return this.mapDocuments(statusPages); }; - updateById = async (id: string, teamId: string, image: Express.Multer.File | undefined, patch: Partial & { removeLogo?: string }): Promise => { + updateById = async ( + id: string, + teamId: string, + image: Express.Multer.File | undefined, + patch: Partial & { removeLogo?: string } + ): Promise => { const { logo: _logo, removeLogo, ...restPatch } = patch; const updateData: StatusPageUpdateData = { ...restPatch }; if (image) { From 4e3e5801793b10a9549d86afd5ccb0886ba56a6e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 22:52:20 +0000 Subject: [PATCH 156/392] align logo --- client/src/Pages/StatusPage/Status/index.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 6cb9830b9..78bee43c2 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -4,6 +4,7 @@ import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList" import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -64,6 +65,10 @@ const StatusPageView = () => { sx.paddingRight = "20vw"; } + const logoSrc = statusPage.logo?.data + ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` + : null; + return ( { statusPage={statusPage} isPublic={isPublic} /> + {logoSrc && ( + + )} {t("statusPageStatusServiceStatus")} Date: Tue, 3 Feb 2026 22:58:09 +0000 Subject: [PATCH 157/392] responsive --- .../Pages/StatusPage/Status/Components/MonitorsList.tsx | 4 ++-- client/src/Pages/StatusPage/Status/index.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx index fc12704a9..04060673a 100644 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx @@ -96,8 +96,8 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { {chartType === "histogram" ? ( ) : ( diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 78bee43c2..1b509507e 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -6,7 +6,7 @@ import { Link } from "react-router-dom"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; -import { useTheme } from "@mui/material"; +import { useMediaQuery, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useLocation, useParams } from "react-router-dom"; @@ -20,6 +20,7 @@ const StatusPageView = () => { const { url } = useParams(); const isAdmin = useIsAdmin(); const location = useLocation(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isPublic = location.pathname.startsWith("/status/public"); const apiUrl = url ? `/status-page/${url}?type=uptime` : null; @@ -61,8 +62,8 @@ const StatusPageView = () => { let sx: React.CSSProperties = {}; if (isPublic) { sx.paddingTop = theme.spacing(20); - sx.paddingLeft = "20vw"; - sx.paddingRight = "20vw"; + sx.paddingLeft = isSmall ? "5vw" : "20vw"; + sx.paddingRight = isSmall ? "5vw" : "20vw"; } const logoSrc = statusPage.logo?.data From 667dc55fc89437d3627228a68fe4072e9e00e076 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 23:30:23 +0000 Subject: [PATCH 158/392] types --- .../v2/common/charts/HeatmapResponseTimeTooltip.tsx | 10 ++++------ client/src/Pages/Settings/DummyChart.tsx | 4 +++- client/src/Types/Check.ts | 4 +++- client/src/Types/Monitor.ts | 3 +-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx index ec9ad4d26..c046e05b5 100644 --- a/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx +++ b/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx @@ -7,6 +7,7 @@ import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { useTranslation } from "react-i18next"; + type HeatmapCheck = | CheckSnapshot | { status: "placeholder"; responseTime: 0; createdAt: "" }; @@ -55,12 +56,9 @@ export const HeatmapResponseTimeTooltip = ({ {formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)} - {check?.responseTime && ( - - {t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms - - )} - + + {t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms + Status:{" "} diff --git a/client/src/Pages/Settings/DummyChart.tsx b/client/src/Pages/Settings/DummyChart.tsx index 39323522e..df7cd5603 100644 --- a/client/src/Pages/Settings/DummyChart.tsx +++ b/client/src/Pages/Settings/DummyChart.tsx @@ -12,11 +12,13 @@ const generateDummyChecks = (): CheckSnapshot[] => { const checks: CheckSnapshot[] = []; for (let i = 0; i < 25; i++) { const isUp = Math.random() > 0.1; + const responseTime = Math.floor(Math.random() * 80) + 20; checks.push({ id: `dummy-${i}`, status: isUp, statusCode: isUp ? 200 : 500, - responseTime: Math.floor(Math.random() * 80) + 20, + responseTime: responseTime, + originalResponseTime: responseTime, message: "", createdAt: new Date(Date.now() - i * 60000).toISOString(), }); diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 9b903207c..f204ab2ca 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -237,7 +237,9 @@ export interface ChecksSummary { export type CheckSnapshot = Omit< Check, "metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt" ->; +> & { + originalResponseTime: number; +}; export interface HasResponseTime { responseTime: number; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 02d4ccf2d..2a0272fdc 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,5 +1,4 @@ -import type { GroupedCheck } from "@/Types/Check"; -import type { CheckSnapshot } from "@/Types/Check"; +import type { GroupedCheck, CheckSnapshot } from "@/Types/Check"; export type MonitorStatus = boolean | undefined; export const MonitorTypes = [ From d469590ef303f2bd17bcd8780bc411789107c7d5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 23:37:16 +0000 Subject: [PATCH 159/392] filter fix --- .../src/Pages/Incidents/Components/ControlsIncidentFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx index 1298057ff..186942cf0 100644 --- a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx +++ b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx @@ -6,7 +6,7 @@ import { Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Monitor } from "@/Types/Monitor"; -const resolutionTypes = ["manual", "automatic"]; +const resolutionTypes = ["all", "manual", "automatic"]; interface ControlsIncidentFilterProps { monitors?: Monitor[]; From 4f3bcd4624f858350792fdee59d25be6ec79f058 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 3 Feb 2026 23:45:23 +0000 Subject: [PATCH 160/392] incident page fixed --- client/src/Components/v2/inputs/Dialog.tsx | 18 ++++++++++-------- .../Components/DialogIncidentDetails.tsx | 8 +++++--- client/src/locales/en.json | 5 +++++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/client/src/Components/v2/inputs/Dialog.tsx b/client/src/Components/v2/inputs/Dialog.tsx index 12831d4c4..717126edb 100644 --- a/client/src/Components/v2/inputs/Dialog.tsx +++ b/client/src/Components/v2/inputs/Dialog.tsx @@ -59,14 +59,16 @@ export const DialogInput = ({ > {cancelText ?? t("common.buttons.cancel")} - + {onConfirm && ( + + )} ); diff --git a/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx b/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx index da24538e2..daef58f68 100644 --- a/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx +++ b/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx @@ -22,13 +22,15 @@ export const DialogIncidentDetails = ({ }: DialogIncidentDetailsProps) => { const { t } = useTranslation(); + const isActive = incident?.status === true; + return ( diff --git a/client/src/locales/en.json b/client/src/locales/en.json index af638e48d..81924ad57 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -188,6 +188,7 @@ }, "buttons": { "cancel": "Cancel", + "close": "Close", "configure": "Configure", "confirm": "Confirm", "create": "Create", @@ -653,6 +654,10 @@ "overview": "Overview", "resolutionDetails": "Resolution details", "resolutionType": "Type:", + "resolutionTypes": { + "automatic": "Automatic", + "manual": "Manual" + }, "resolve": "Resolve Incident", "resolvedAt": "Resolved at:", "resolvedBy": "Resolved by:", From bc58f6e08eb75a9203d92d7b0a38d1da9aaf7b82 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 17:48:58 +0000 Subject: [PATCH 161/392] add user role hierearchy lookup and management function --- server/src/types/user.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/src/types/user.ts b/server/src/types/user.ts index 41c009791..2ee07d487 100644 --- a/server/src/types/user.ts +++ b/server/src/types/user.ts @@ -1,6 +1,17 @@ export const UserRoles = ["user", "admin", "superadmin", "demo"] as const; export type UserRole = (typeof UserRoles)[number]; +export const RoleHierarchy: Record = { + demo: 0, + user: 1, + admin: 2, + superadmin: 3, +}; + +export const canManageRole = (actorRole: UserRole, targetRole: UserRole): boolean => { + return RoleHierarchy[actorRole] > RoleHierarchy[targetRole]; +}; + export interface UserProfileImage { data?: Buffer; contentType?: string; From 4ecc737438eef43c9d808ac20548f1765d201c16 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 17:49:19 +0000 Subject: [PATCH 162/392] add helper method to extract user roles --- server/src/controllers/controllerUtils.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/src/controllers/controllerUtils.ts b/server/src/controllers/controllerUtils.ts index 592c58fc3..b1ccc4c84 100755 --- a/server/src/controllers/controllerUtils.ts +++ b/server/src/controllers/controllerUtils.ts @@ -112,6 +112,13 @@ const requireUserEmail = (userEmail?: string): string => { return userEmail; }; +export const requireUserRoles = (userRoles?: string[]): string[] => { + if (!userRoles || userRoles.length === 0) { + throw new AppError({ message: "User roles are required", status: 400 }); + } + return userRoles; +} + export { fetchMonitorCertificate, requireString, From b4c929963f75b4946a26f6602786591d41977d63 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 17:49:45 +0000 Subject: [PATCH 163/392] add permission check to invite service --- server/src/controllers/inviteController.ts | 7 +++++-- server/src/service/business/inviteService.ts | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/server/src/controllers/inviteController.ts b/server/src/controllers/inviteController.ts index 664d84d98..990b81651 100755 --- a/server/src/controllers/inviteController.ts +++ b/server/src/controllers/inviteController.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/joi.js"; +import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js"; const SERVICE_NAME = "inviteController"; class InviteController { @@ -14,12 +15,14 @@ class InviteController { } getInviteToken = async (req: Request, res: Response, next: NextFunction) => { + console.log(req.body); try { + const teamId = requireTeamId(req.user?.teamId); + const userRoles = requireUserRoles(req.user?.role); const invite = req.body; - const teamId = req?.user?.teamId; invite.teamId = teamId; await inviteBodyValidation.validateAsync(invite); - const inviteToken = await this.inviteService.getInviteToken({ invite, teamId }); + const inviteToken = await this.inviteService.getInviteToken({ invite, teamId, userRoles }); return res.status(200).json({ success: true, msg: "Invite token generated successfully", diff --git a/server/src/service/business/inviteService.ts b/server/src/service/business/inviteService.ts index eee0f993c..ba6039b34 100644 --- a/server/src/service/business/inviteService.ts +++ b/server/src/service/business/inviteService.ts @@ -1,4 +1,5 @@ -import type { Invite } from "@/types/index.js"; +import type { Invite, UserRole } from "@/types/index.js"; +import { canManageRole } from "@/types/user.js"; import type { IInvitesRepository } from "@/repositories/index.js"; import { AppError } from "@/utils/AppError.js"; @@ -29,8 +30,23 @@ class InviteService { return InviteService.SERVICE_NAME; } - getInviteToken = async ({ invite, teamId }: { invite: Partial; teamId: string }) => { + getInviteToken = async ({ invite, teamId, userRoles }: { invite: Partial; teamId: string; userRoles: UserRole[] }) => { invite.teamId = teamId; + + const inviteRoles = invite.role ?? []; + + for (const targetRole of inviteRoles) { + const canManage = userRoles.some((actorRole) => canManageRole(actorRole, targetRole)); + if (!canManage) { + throw new AppError({ + message: "You do not have permission to create this invite", + service: SERVICE_NAME, + method: "getInviteToken", + status: 403, + }); + } + } + const inviteToken = await this.invitesRepository.create(invite); return inviteToken; }; From 97dd3ec9cb6cfde697fa3599b7ab78613c2f0ca2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 17:57:00 +0000 Subject: [PATCH 164/392] add permissin check to sendInviteEmail --- server/src/controllers/inviteController.ts | 6 +++++- server/src/service/business/inviteService.ts | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/server/src/controllers/inviteController.ts b/server/src/controllers/inviteController.ts index 990b81651..e6d5d7ccb 100755 --- a/server/src/controllers/inviteController.ts +++ b/server/src/controllers/inviteController.ts @@ -35,13 +35,17 @@ class InviteController { sendInviteEmail = async (req: Request, res: Response, next: NextFunction) => { try { + const teamId = requireTeamId(req?.user?.teamId); + const userRoles = requireUserRoles(req?.user?.role); + const inviteRequest = req.body; - inviteRequest.teamId = req?.user?.teamId; + inviteRequest.teamId = teamId; await inviteBodyValidation.validateAsync(inviteRequest); const inviteToken = await this.inviteService.sendInviteEmail({ invite: inviteRequest, firstName: req?.user?.firstName, + userRoles, }); return res.status(200).json({ success: true, diff --git a/server/src/service/business/inviteService.ts b/server/src/service/business/inviteService.ts index ba6039b34..e4069d485 100644 --- a/server/src/service/business/inviteService.ts +++ b/server/src/service/business/inviteService.ts @@ -51,7 +51,21 @@ class InviteService { return inviteToken; }; - sendInviteEmail = async ({ invite, firstName }: { invite: Partial; firstName: any }) => { + sendInviteEmail = async ({ invite, firstName, userRoles }: { invite: Partial; firstName: any; userRoles: UserRole[] }) => { + const inviteRoles = invite.role ?? []; + + for (const targetRole of inviteRoles) { + const canManage = userRoles.some((actorRole) => canManageRole(actorRole, targetRole)); + if (!canManage) { + throw new AppError({ + message: "You do not have permission to create this invite", + service: SERVICE_NAME, + method: "getInviteToken", + status: 403, + }); + } + } + const inviteToken = await this.invitesRepository.create(invite); const { clientHost } = this.settingsService.getSettings(); From 189f309631bd44d9fd6c1f3d8507d9d8df78f345 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 18:10:52 +0000 Subject: [PATCH 165/392] format --- server/src/controllers/controllerUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/controllers/controllerUtils.ts b/server/src/controllers/controllerUtils.ts index b1ccc4c84..99c60807e 100755 --- a/server/src/controllers/controllerUtils.ts +++ b/server/src/controllers/controllerUtils.ts @@ -117,7 +117,7 @@ export const requireUserRoles = (userRoles?: string[]): string[] => { throw new AppError({ message: "User roles are required", status: 400 }); } return userRoles; -} +}; export { fetchMonitorCertificate, From a89e5ed56508ebb303266bb895d320bb91845683 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 19:22:05 +0000 Subject: [PATCH 166/392] MaintenanceWindow table --- .../Maintenance/MaintenanceWindowTable.tsx | 213 ++++++++++++++++++ client/src/Pages/Maintenance/index.tsx | 60 +++++ .../Pages/Maintenance/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 12 +- client/src/Types/MaintenanceWindow.ts | 12 + client/src/locales/en.json | 18 ++ 6 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 client/src/Pages/Maintenance/MaintenanceWindowTable.tsx create mode 100644 client/src/Pages/Maintenance/index.tsx rename client/src/Pages/Maintenance/{index.jsx => old.jsx} (100%) create mode 100644 client/src/Types/MaintenanceWindow.ts diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx new file mode 100644 index 000000000..19767238b --- /dev/null +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -0,0 +1,213 @@ +import Typography from "@mui/material/Typography"; +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; + updateCallback: () => void; +} + +const getTimeToNextWindow = ( + startTime: string, + endTime: string, + repeat: number +): string => { + const now = dayjs(); + let start = dayjs(startTime); + let end = dayjs(endTime); + if (repeat > 0) { + while (start.isBefore(now) && end.isBefore(now)) { + start = start.add(repeat, "milliseconds"); + end = end.add(repeat, "milliseconds"); + } + } + + 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 { + return diffInDays + " days"; + } + } + + return "N/A"; +}; + +export const MaintenanceWindowTable = ({ + maintenanceWindows, + maintenanceWindowCount, + page, + setPage, + updateCallback, +}: 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) { + updateCallback(); + setDeleteDialogOpen(false); + setSelectedWindow(null); + } + }; + + const handlePause = async (maintenanceWindow: MaintenanceWindow) => { + const result = await patch(`/maintenance-window/${maintenanceWindow.id}`, { + active: !maintenanceWindow.active, + }); + if (result) { + updateCallback(); + } + }; + + 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.tsx b/client/src/Pages/Maintenance/index.tsx new file mode 100644 index 000000000..9ba72daa1 --- /dev/null +++ b/client/src/Pages/Maintenance/index.tsx @@ -0,0 +1,60 @@ +import { BasePageWithStates } from "@/Components/v2/design-elements"; +import { useTranslation } from "react-i18next"; +import { useGet } from "@/Hooks/UseApi"; +import { MaintenanceWindowTable } from "./MaintenanceWindowTable"; +import { useState, useCallback } from "react"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; +import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; + +interface MaintenanceWindowsResponse { + maintenanceWindows: MaintenanceWindow[]; + maintenanceWindowCount: number; +} + +const MaintenanceWindowPage = () => { + const { t } = useTranslation(); + const [page, setPage] = useState(0); + const rowsPerPage = useSelector( + (state: RootState) => state?.ui?.maintenance?.rowsPerPage ?? 5 + ); + + const { data, isLoading, error, refetch } = useGet( + `/maintenance-window/team?page=${page}&rowsPerPage=${rowsPerPage}` + ); + + const handleUpdate = useCallback(() => { + refetch(); + }, [refetch]); + + const handlePageChange = useCallback((newPage: number) => { + setPage(newPage); + }, []); + + const maintenanceWindows = data?.maintenanceWindows ?? []; + const maintenanceWindowCount = data?.maintenanceWindowCount ?? 0; + + return ( + + + + ); +}; + +export default MaintenanceWindowPage; diff --git a/client/src/Pages/Maintenance/index.jsx b/client/src/Pages/Maintenance/old.jsx similarity index 100% rename from client/src/Pages/Maintenance/index.jsx rename to client/src/Pages/Maintenance/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 908d7efe1..ee08dc581 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={ + <> + + + + + } /> Date: Wed, 4 Feb 2026 19:30:41 +0000 Subject: [PATCH 167/392] put -> patch --- .../src/Components/v2/design-elements/BasePage.tsx | 2 +- .../src/Pages/Maintenance/MaintenanceWindowTable.tsx | 8 ++++---- client/src/Pages/Maintenance/index.tsx | 12 ++---------- server/src/routes/maintenanceWindowRoute.ts | 2 +- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index e5091091e..4daf0cd16 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 ( void; - updateCallback: () => void; + refetch: () => void; } const getTimeToNextWindow = ( @@ -68,7 +68,7 @@ export const MaintenanceWindowTable = ({ maintenanceWindowCount, page, setPage, - updateCallback, + refetch, }: MaintenanceWindowTableProps) => { const theme = useTheme(); const { t } = useTranslation(); @@ -88,7 +88,7 @@ export const MaintenanceWindowTable = ({ if (!selectedWindow) return; const result = await deleteFn(`/maintenance-window/${selectedWindow.id}`); if (result) { - updateCallback(); + refetch(); setDeleteDialogOpen(false); setSelectedWindow(null); } @@ -99,7 +99,7 @@ export const MaintenanceWindowTable = ({ active: !maintenanceWindow.active, }); if (result) { - updateCallback(); + refetch(); } }; diff --git a/client/src/Pages/Maintenance/index.tsx b/client/src/Pages/Maintenance/index.tsx index 9ba72daa1..5a1d29fc0 100644 --- a/client/src/Pages/Maintenance/index.tsx +++ b/client/src/Pages/Maintenance/index.tsx @@ -23,14 +23,6 @@ const MaintenanceWindowPage = () => { `/maintenance-window/team?page=${page}&rowsPerPage=${rowsPerPage}` ); - const handleUpdate = useCallback(() => { - refetch(); - }, [refetch]); - - const handlePageChange = useCallback((newPage: number) => { - setPage(newPage); - }, []); - const maintenanceWindows = data?.maintenanceWindows ?? []; const maintenanceWindowCount = data?.maintenanceWindowCount ?? 0; @@ -50,8 +42,8 @@ const MaintenanceWindowPage = () => { maintenanceWindows={maintenanceWindows} maintenanceWindowCount={maintenanceWindowCount} page={page} - setPage={handlePageChange} - updateCallback={handleUpdate} + setPage={setPage} + refetch={refetch} /> ); diff --git a/server/src/routes/maintenanceWindowRoute.ts b/server/src/routes/maintenanceWindowRoute.ts index 0230ba4bd..539f2413f 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); } From b536099d039475725d155fdd45b4c9bcc345f873 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 19:40:32 +0000 Subject: [PATCH 168/392] create header --- client/src/Pages/Maintenance/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Maintenance/index.tsx b/client/src/Pages/Maintenance/index.tsx index 5a1d29fc0..a197b69ab 100644 --- a/client/src/Pages/Maintenance/index.tsx +++ b/client/src/Pages/Maintenance/index.tsx @@ -1,11 +1,13 @@ 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, useCallback } from "react"; +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[]; @@ -14,12 +16,13 @@ interface MaintenanceWindowsResponse { 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, error, refetch } = useGet( + const { data, isLoading, isValidating, error, refetch } = useGet( `/maintenance-window/team?page=${page}&rowsPerPage=${rowsPerPage}` ); @@ -38,6 +41,11 @@ const MaintenanceWindowPage = () => { actionButtonText={t("pages.maintenanceWindow.fallback.actionButton")} actionLink="/maintenance/create" > + Date: Wed, 4 Feb 2026 19:53:19 +0000 Subject: [PATCH 169/392] pretty-ms --- .../Maintenance/MaintenanceWindowTable.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx index b3033e9dd..9922c9cc6 100644 --- a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -1,4 +1,5 @@ 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"; @@ -35,29 +36,23 @@ const getTimeToNextWindow = ( const now = dayjs(); let start = dayjs(startTime); let end = dayjs(endTime); + + // For repeating windows, advance to next occurrence if (repeat > 0) { - while (start.isBefore(now) && end.isBefore(now)) { + 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)) { - 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 { - return diffInDays + " days"; - } + return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true }); } return "N/A"; From c54582ca7078d882c72c9e7cfc50522385317b9c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 19:54:01 +0000 Subject: [PATCH 170/392] format --- client/package-lock.json | 2 +- client/src/Pages/Maintenance/index.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 1a59d9890..d289d8312 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/Pages/Maintenance/index.tsx b/client/src/Pages/Maintenance/index.tsx index a197b69ab..09b2a9ae7 100644 --- a/client/src/Pages/Maintenance/index.tsx +++ b/client/src/Pages/Maintenance/index.tsx @@ -22,9 +22,10 @@ const MaintenanceWindowPage = () => { (state: RootState) => state?.ui?.maintenance?.rowsPerPage ?? 5 ); - const { data, isLoading, isValidating, error, refetch } = useGet( - `/maintenance-window/team?page=${page}&rowsPerPage=${rowsPerPage}` - ); + const { data, isLoading, isValidating, error, refetch } = + useGet( + `/maintenance-window/team?page=${page}&rowsPerPage=${rowsPerPage}` + ); const maintenanceWindows = data?.maintenanceWindows ?? []; const maintenanceWindowCount = data?.maintenanceWindowCount ?? 0; From 7cb866ce4a0e9d1fad67428d884ee48428c820f4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 19:57:36 +0000 Subject: [PATCH 171/392] legacy put -> patch --- client/src/Utils/NetworkService.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 9c6ad74a0..d9b151a10 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -230,7 +230,7 @@ class NetworkService { async updateMonitor(config) { const { monitorId, updatedFields } = config; const payload = updatedFields; - return this.axiosInstance.put(`/monitors/${monitorId}`, payload, { + return this.axiosInstance.patch(`/monitors/${monitorId}`, payload, { headers: { "Content-Type": "application/json", }, @@ -378,7 +378,7 @@ class NetworkService { * */ async updateUser(config) { - return this.axiosInstance.put(`/auth/user`, config.form); + return this.axiosInstance.patch(`/auth/user`, config.form); } /** @@ -611,7 +611,7 @@ class NetworkService { * */ async updateCheckStatus(config) { - return this.axiosInstance.put(`/checks/check/${config.checkId}`, { + return this.axiosInstance.patch(`/checks/check/${config.checkId}`, { ack: config.ack, }); } @@ -629,7 +629,7 @@ class NetworkService { * */ async updateMonitorChecksStatus(config) { - return this.axiosInstance.put(`/checks/monitor/${config.monitorId}`, { + return this.axiosInstance.patch(`/checks/monitor/${config.monitorId}`, { ack: config.ack, }); } @@ -646,7 +646,7 @@ class NetworkService { * */ async updateAllChecksStatus(config) { - return this.axiosInstance.put(`/checks/team/`, { + return this.axiosInstance.patch(`/checks/team/`, { ack: config.ack, }); } @@ -663,7 +663,7 @@ class NetworkService { * */ async updateChecksTTL(config) { - return this.axiosInstance.put( + return this.axiosInstance.patch( `/checks/team/ttl`, { ttl: config.ttl }, { @@ -705,7 +705,7 @@ class NetworkService { * @returns {Promise} 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 = {}) => { From 1a096b66f7559c6c0c4ca9e2ba92302bb051de2c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 20:03:05 +0000 Subject: [PATCH 172/392] remove unused --- .../MaintenanceTable/ActionsMenu/index.jsx | 178 ------------- .../Maintenance/MaintenanceTable/index.jsx | 237 ------------------ client/src/Pages/Maintenance/index.css | 0 client/src/Pages/Maintenance/old.jsx | 95 ------- 4 files changed, 510 deletions(-) delete mode 100644 client/src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx delete mode 100644 client/src/Pages/Maintenance/MaintenanceTable/index.jsx delete mode 100644 client/src/Pages/Maintenance/index.css delete mode 100644 client/src/Pages/Maintenance/old.jsx diff --git a/client/src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx b/client/src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx deleted file mode 100644 index 709bca212..000000000 --- a/client/src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { useSelector } from "react-redux"; -import { IconButton, Menu, MenuItem } from "@mui/material"; -import { logger } from "@/Utils/Logger.js"; -import Icon from "@/Components/v1/Icon"; -import PropTypes from "prop-types"; -import { networkService } from "../../../../main.jsx"; -import { createToast } from "@/Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -import Dialog from "@/Components/v1/Dialog/index.jsx"; - -const ActionsMenu = ({ /* isAdmin, */ maintenanceWindow, updateCallback }) => { - 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 11875bb6a..000000000 --- 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/index.css b/client/src/Pages/Maintenance/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Pages/Maintenance/old.jsx b/client/src/Pages/Maintenance/old.jsx deleted file mode 100644 index b49cd9f6e..000000000 --- a/client/src/Pages/Maintenance/old.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; From f0b2292f0ae749516089a396ab79ac030b9fb3d0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 21:45:39 +0000 Subject: [PATCH 173/392] general --- .../src/Components/v2/inputs/DatePicker.tsx | 91 +++++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Hooks/useMaintenanceWindowForm.ts | 79 ++++++++++++ .../Components/ConfigSelect/index.jsx | 0 .../Components/MonitorList/index.jsx | 0 .../Components/MonitorsConfig/index.jsx | 0 .../hooks/useMaintenanceActions.jsx | 0 .../hooks/useMaintenanceData.jsx | 0 .../{CreateMaintenance => create}/index.css | 0 client/src/Pages/Maintenance/create/index.tsx | 120 ++++++++++++++++++ .../index.jsx => create/old.jsx} | 0 client/src/Routes/index.jsx | 10 +- client/src/Validation/maintenanceWindow.ts | 32 +++++ client/src/locales/en.json | 52 ++++++-- 14 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 client/src/Components/v2/inputs/DatePicker.tsx create mode 100644 client/src/Hooks/useMaintenanceWindowForm.ts rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/ConfigSelect/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/MonitorList/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/MonitorsConfig/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/hooks/useMaintenanceActions.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/hooks/useMaintenanceData.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/index.css (100%) create mode 100644 client/src/Pages/Maintenance/create/index.tsx rename client/src/Pages/Maintenance/{CreateMaintenance/index.jsx => create/old.jsx} (100%) create mode 100644 client/src/Validation/maintenanceWindow.ts diff --git a/client/src/Components/v2/inputs/DatePicker.tsx b/client/src/Components/v2/inputs/DatePicker.tsx new file mode 100644 index 000000000..4a852f2f1 --- /dev/null +++ b/client/src/Components/v2/inputs/DatePicker.tsx @@ -0,0 +1,91 @@ +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 { 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, "label"> { + fieldLabel?: string; + required?: boolean; +} + +export const DatePickerComponent = ({ + fieldLabel, + required, + ...props +}: DatePickerComponentProps) => { + const theme = useTheme(); + + const picker = ( + + ( + + ), + ...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: 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, + }, + }, + }, + inputAdornment: { sx: { ml: 0 } }, + openPickerButton: { + sx: { + py: 0, + mr: 0, + }, + }, + ...props.slotProps, + }} + /> + + ); + + if (fieldLabel) { + return ( + + {fieldLabel} + {picker} + + ); + } + + return picker; +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index fa04903c9..6cb40aade 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -14,3 +14,4 @@ export * from "./Switch"; export * from "./Slider"; export * from "./ImageUpload"; export * from "./ColorPicker"; +export { DatePickerComponent as DatePicker } from "./DatePicker"; diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts new file mode 100644 index 000000000..b1a6f86ad --- /dev/null +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -0,0 +1,79 @@ +import { useMemo } from "react"; +import dayjs from "dayjs"; +import { + maintenanceWindowSchema, + repeatOptions, + durationUnitOptions, + 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"; +}; + +const getDurationAndUnit = ( + startMs: number, + endMs: number +): { duration: number; durationUnit: string } => { + const diffMs = endMs - startMs; + + for (const unit of [...durationUnitOptions].reverse()) { + if (diffMs >= unit.multiplier && diffMs % unit.multiplier === 0) { + return { + duration: diffMs / unit.multiplier, + durationUnit: unit.id, + }; + } + } + + return { + duration: diffMs / 1000, + durationUnit: "seconds", + }; +}; + +export const useMaintenanceWindowForm = ({ + data = null, +}: UseMaintenanceWindowFormOptions = {}) => { + return useMemo(() => { + let defaults: MaintenanceWindowFormData; + + if (data) { + const startDate = dayjs(data.start); + const endDate = dayjs(data.end); + const { duration, durationUnit } = getDurationAndUnit( + startDate.valueOf(), + endDate.valueOf() + ); + + defaults = { + name: data.name, + repeat: getRepeatId(data.repeat), + startDate: startDate.format("YYYY-MM-DD"), + startTime: startDate.format("HH:mm"), + duration, + durationUnit, + 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]); +}; diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/ConfigSelect/index.jsx b/client/src/Pages/Maintenance/create/Components/ConfigSelect/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/ConfigSelect/index.jsx rename to client/src/Pages/Maintenance/create/Components/ConfigSelect/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorList/index.jsx b/client/src/Pages/Maintenance/create/Components/MonitorList/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorList/index.jsx rename to client/src/Pages/Maintenance/create/Components/MonitorList/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx b/client/src/Pages/Maintenance/create/Components/MonitorsConfig/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx rename to client/src/Pages/Maintenance/create/Components/MonitorsConfig/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx b/client/src/Pages/Maintenance/create/hooks/useMaintenanceActions.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx rename to client/src/Pages/Maintenance/create/hooks/useMaintenanceActions.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx b/client/src/Pages/Maintenance/create/hooks/useMaintenanceData.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx rename to client/src/Pages/Maintenance/create/hooks/useMaintenanceData.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/index.css b/client/src/Pages/Maintenance/create/index.css similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/index.css rename to client/src/Pages/Maintenance/create/index.css diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx new file mode 100644 index 000000000..15948ae3b --- /dev/null +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -0,0 +1,120 @@ +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { TextField, Select, DatePicker } 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 } from "@/Validation/maintenanceWindow"; +import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; +import { useGet } from "@/Hooks/UseApi"; +import { useParams } from "react-router-dom"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; + +const CreateMaintenanceWindowPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { maintenanceWindowId } = useParams<{ maintenanceWindowId: string }>(); + const isEditMode = Boolean(maintenanceWindowId); + + const { data: existingMaintenanceWindow } = useGet( + isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null + ); + + const { schema, defaults } = useMaintenanceWindowForm({ + data: existingMaintenanceWindow, + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { control } = form; + + return ( + + + ( + + )} + /> + ( + + )} + /> + + } + /> + + ( + { + field.onChange(date ? date.format("YYYY-MM-DD") : ""); + }} + /> + )} + /> + + } + /> + + ); +}; + +export default CreateMaintenanceWindowPage; diff --git a/client/src/Pages/Maintenance/CreateMaintenance/index.jsx b/client/src/Pages/Maintenance/create/old.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/index.jsx rename to client/src/Pages/Maintenance/create/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index ee08dc581..d4a5619d6 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -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 = () => { /> } + element={ + <> + + + + + } /> ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2f61a5f62..3d2e0ba61 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -792,6 +792,45 @@ "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" + } + } + }, + "startTime": { + "title": "Start time", + "description": "Set the start time and duration for your maintenance window.", + "option": { + "duration": { + "label": "Duration" + }, + "startDate": { + "label": "Start date" + }, + "startTime": { + "label": "Start time" + } + }, + "monitors": { + "title": "Monitors", + "description": "Monitors that the maintenance window should apply to", + "option": { + "addMonitors": { + "label": "Add monitors" + } + } + } + } } }, "notifications": { @@ -1282,16 +1321,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" } From 4f0017c03186115fed71f902bb556e7eb96db244 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 21:47:56 +0000 Subject: [PATCH 174/392] strings --- client/src/Pages/Maintenance/create/index.tsx | 6 +++--- client/src/locales/en.json | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 15948ae3b..521832ddc 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -90,8 +90,8 @@ const CreateMaintenanceWindowPage = () => { } /> { render={({ field }) => ( { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 3d2e0ba61..a4661575b 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -807,6 +807,15 @@ } } }, + "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.", @@ -814,9 +823,7 @@ "duration": { "label": "Duration" }, - "startDate": { - "label": "Start date" - }, + "startTime": { "label": "Start time" } From 0a5067ef46748ca9a00db82f47d9b4306462751c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 22:10:52 +0000 Subject: [PATCH 175/392] grab monitors --- .../src/Components/v2/inputs/TimePicker.tsx | 61 ++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Pages/Maintenance/create/index.tsx | 115 +++++++++++++++++- 3 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 client/src/Components/v2/inputs/TimePicker.tsx diff --git a/client/src/Components/v2/inputs/TimePicker.tsx b/client/src/Components/v2/inputs/TimePicker.tsx new file mode 100644 index 000000000..251332fe1 --- /dev/null +++ b/client/src/Components/v2/inputs/TimePicker.tsx @@ -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, "label"> { + fieldLabel?: string; + required?: boolean; +} + +export const TimePickerComponent = ({ + fieldLabel, + required, + ...props +}: TimePickerComponentProps) => { + const theme = useTheme(); + + const picker = ( + + + + ); + + if (fieldLabel) { + return ( + + {fieldLabel} + {picker} + + ); + } + + return picker; +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 6cb40aade..25d9febe3 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -15,3 +15,4 @@ export * from "./Slider"; export * from "./ImageUpload"; export * from "./ColorPicker"; export { DatePickerComponent as DatePicker } from "./DatePicker"; +export { TimePickerComponent as TimePicker } from "./TimePicker"; diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 521832ddc..a15338436 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -2,17 +2,24 @@ import Stack from "@mui/material/Stack"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; -import { TextField, Select, DatePicker } from "@/Components/v2/inputs"; +import { + TextField, + Select, + DatePicker, + TimePicker, + Button, +} 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 } from "@/Validation/maintenanceWindow"; +import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow"; import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; import { useGet } from "@/Hooks/UseApi"; import { useParams } from "react-router-dom"; +import type { Monitor } from "@/Types/Monitor"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; @@ -26,6 +33,8 @@ const CreateMaintenanceWindowPage = () => { isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null ); + const { data: monitors } = useGet("/monitors/team"); + const { schema, defaults } = useMaintenanceWindowForm({ data: existingMaintenanceWindow, }); @@ -35,10 +44,21 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control } = form; + const { control, handleSubmit } = form; + + const onSubmit = (data: MaintenanceWindowFormData) => { + console.log("Form submitted with data:", data); + }; + + const onError = (errors: any) => { + console.log("Form errors:", errors); + }; return ( - + { } /> + + ( + { + field.onChange(time ? time.format("HH:mm") : ""); + }} + /> + )} + /> + + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + sx={{ width: 120 }} + /> + )} + /> + ( + + )} + /> + + + } + /> + + + ); }; From dc0e198a8ed47861b3789f79c779317c21fd7b59 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 22:17:04 +0000 Subject: [PATCH 176/392] monitors --- client/src/Pages/Maintenance/create/index.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index a15338436..7190adf32 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -1,6 +1,7 @@ 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, @@ -8,6 +9,7 @@ import { DatePicker, TimePicker, Button, + Autocomplete, } from "@/Components/v2/inputs"; import { useTheme } from "@mui/material"; @@ -22,6 +24,7 @@ import { useParams } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; +import { Trash2 } from "lucide-react"; const CreateMaintenanceWindowPage = () => { const theme = useTheme(); @@ -206,6 +209,85 @@ const CreateMaintenanceWindowPage = () => { } /> + { + const monitorsList = monitors ?? []; + const selectedMonitors = field.value + .map((id: string) => monitorsList.find((m) => m.id === id)) + .filter((m): m is Monitor => m !== undefined); + + return ( + + 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) => ( + + )} + /> + {selectedMonitors.length > 0 && ( + + {selectedMonitors.map((monitor) => ( + + {monitor.name} + { + field.onChange( + field.value.filter( + (id: string) => id !== monitor.id + ) + ); + }} + aria-label="Remove monitor" + > + + + + ))} + + )} + + ); + }} + /> + } + /> Date: Wed, 4 Feb 2026 22:55:54 +0000 Subject: [PATCH 177/392] duration --- .../src/Components/v2/inputs/DatePicker.tsx | 33 +++++--- client/src/Hooks/useMaintenanceWindowForm.ts | 31 +------ client/src/Pages/Maintenance/create/index.tsx | 84 +++++++++++++++---- client/src/Types/MaintenanceWindow.ts | 4 + client/src/Validation/maintenanceWindow.ts | 41 +++++---- client/src/locales/en.json | 2 +- server/src/db/models/MaintenanceWindow.ts | 9 +- .../MongoMaintenanceWindowsRepository.ts | 2 + server/src/types/maintenanceWindow.ts | 4 + server/src/validation/joi.ts | 4 + 10 files changed, 142 insertions(+), 72 deletions(-) diff --git a/client/src/Components/v2/inputs/DatePicker.tsx b/client/src/Components/v2/inputs/DatePicker.tsx index 4a852f2f1..c63c5ce08 100644 --- a/client/src/Components/v2/inputs/DatePicker.tsx +++ b/client/src/Components/v2/inputs/DatePicker.tsx @@ -3,6 +3,7 @@ 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"; @@ -11,11 +12,15 @@ import { LocalizationProvider } from "@mui/x-date-pickers"; interface DatePickerComponentProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; + error?: boolean; + helperText?: string; } export const DatePickerComponent = ({ fieldLabel, required, + error, + helperText, ...props }: DatePickerComponentProps) => { const theme = useTheme(); @@ -56,12 +61,12 @@ export const DatePickerComponent = ({ pr: theme.spacing(5), }, "& fieldset": { - borderColor: theme.palette.divider, + 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: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, }, }, }, @@ -78,14 +83,18 @@ export const DatePickerComponent = ({ ); - if (fieldLabel) { - return ( - - {fieldLabel} - {picker} - - ); - } - - return picker; + return ( + + {fieldLabel && {fieldLabel}} + {picker} + {helperText && ( + + {helperText} + + )} + + ); }; diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts index b1a6f86ad..1f10d55ef 100644 --- a/client/src/Hooks/useMaintenanceWindowForm.ts +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -3,7 +3,6 @@ import dayjs from "dayjs"; import { maintenanceWindowSchema, repeatOptions, - durationUnitOptions, type MaintenanceWindowFormData, } from "@/Validation/maintenanceWindow"; import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; @@ -17,27 +16,6 @@ const getRepeatId = (repeatMs: number): string => { return option?.id ?? "none"; }; -const getDurationAndUnit = ( - startMs: number, - endMs: number -): { duration: number; durationUnit: string } => { - const diffMs = endMs - startMs; - - for (const unit of [...durationUnitOptions].reverse()) { - if (diffMs >= unit.multiplier && diffMs % unit.multiplier === 0) { - return { - duration: diffMs / unit.multiplier, - durationUnit: unit.id, - }; - } - } - - return { - duration: diffMs / 1000, - durationUnit: "seconds", - }; -}; - export const useMaintenanceWindowForm = ({ data = null, }: UseMaintenanceWindowFormOptions = {}) => { @@ -46,19 +24,14 @@ export const useMaintenanceWindowForm = ({ if (data) { const startDate = dayjs(data.start); - const endDate = dayjs(data.end); - const { duration, durationUnit } = getDurationAndUnit( - startDate.valueOf(), - endDate.valueOf() - ); defaults = { name: data.name, repeat: getRepeatId(data.repeat), startDate: startDate.format("YYYY-MM-DD"), startTime: startDate.format("HH:mm"), - duration, - durationUnit, + duration: data.duration ?? 0, + durationUnit: data.durationUnit ?? "minutes", monitors: [data.monitorId], }; } else { diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 7190adf32..0c247b017 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -19,25 +19,32 @@ 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 } from "@/Hooks/UseApi"; -import { useParams } from "react-router-dom"; +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( - isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null + isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null, + {}, + { keepPreviousData: false } ); const { data: monitors } = useGet("/monitors/team"); + const { post, loading: isPosting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + const { schema, defaults } = useMaintenanceWindowForm({ data: existingMaintenanceWindow, }); @@ -47,14 +54,61 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control, handleSubmit } = form; + const { control, handleSubmit, trigger } = form; + useEffect(() => { + if (existingMaintenanceWindow) { + form.reset(defaults); + } + }, [existingMaintenanceWindow, defaults, form]); - const onSubmit = (data: MaintenanceWindowFormData) => { - console.log("Form submitted with data:", data); + 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 = { + 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.log("Form errors:", errors); + console.error("Form submission errors:", errors); }; return ( @@ -121,7 +175,7 @@ const CreateMaintenanceWindowPage = () => { name="startDate" control={control} defaultValue={defaults.startDate} - render={({ field }) => ( + render={({ field, fieldState }) => ( { onChange={(date) => { field.onChange(date ? date.format("YYYY-MM-DD") : ""); }} + error={!!fieldState.error} + helperText={fieldState.error?.message} /> )} /> @@ -152,7 +208,7 @@ const CreateMaintenanceWindowPage = () => { )} value={field.value ? dayjs(field.value, "HH:mm") : null} onChange={(time) => { - field.onChange(time ? time.format("HH:mm") : ""); + field.onChange(time ? time.format("HH:mm") : ""); trigger("startDate"); }} /> )} @@ -241,7 +297,9 @@ const CreateMaintenanceWindowPage = () => { {...params} placeholder={ selectedMonitors.length === 0 - ? t("pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label") + ? t( + "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" + ) : "" } error={!!fieldState.error} @@ -269,9 +327,7 @@ const CreateMaintenanceWindowPage = () => { size="small" onClick={() => { field.onChange( - field.value.filter( - (id: string) => id !== monitor.id - ) + field.value.filter((id: string) => id !== monitor.id) ); }} aria-label="Remove monitor" @@ -294,7 +350,7 @@ const CreateMaintenanceWindowPage = () => { spacing={theme.spacing(2)} >
+ + + ); +}; diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx new file mode 100644 index 000000000..2642e177b --- /dev/null +++ b/client/src/Pages/Logs/index.tsx @@ -0,0 +1,29 @@ +import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements"; +import { TabLogs } from "@/Pages/Logs/TabLogs"; +import { TabQueue } from "@/Pages/Logs/TabQueue"; +import { TabDiagnostics } from "@/Pages/Logs/TabDiagnostics"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +const LogsPage = () => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(0); + return ( + + setActiveTab(newValue)} + > + + + + + {activeTab === 0 && } + {activeTab === 1 && } + {activeTab === 2 && } + + ); +}; + +export default LogsPage; diff --git a/client/src/Pages/Logs/index.jsx b/client/src/Pages/Logs/old.jsx similarity index 100% rename from client/src/Pages/Logs/index.jsx rename to client/src/Pages/Logs/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index d4a5619d6..22b5b446a 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -55,7 +55,7 @@ import ProtectedRoute from "../Components/v1/ProtectedRoute"; import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute"; import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; import BulkImport from "../Pages/Uptime/BulkImport/index.jsx"; -import Logs from "../Pages/Logs/index.jsx"; +import Logs from "../Pages/Logs"; import CreateMonitor from "@/Pages/CreateMonitor"; @@ -350,7 +350,9 @@ const Routes = () => { path="logs" element={ - + + + } /> diff --git a/client/src/Types/Log.ts b/client/src/Types/Log.ts new file mode 100644 index 000000000..b2038e31f --- /dev/null +++ b/client/src/Types/Log.ts @@ -0,0 +1,13 @@ +export const LOG_LEVELS = ["debug", "info", "warn", "error"] as const; +export type LogLevel = (typeof LOG_LEVELS)[number]; +export const LOG_LEVEL_OPTIONS = ["all", ...LOG_LEVELS] as const; +export type LogLevelOption = (typeof LOG_LEVEL_OPTIONS)[number]; +export interface Log { + message: string; + service?: string; + method?: string; + details?: Record; + stack?: string; + level: LogLevel; + timestamp: string; +} diff --git a/client/src/Types/state.ts b/client/src/Types/state.ts index 137d67b66..1575fffb8 100644 --- a/client/src/Types/state.ts +++ b/client/src/Types/state.ts @@ -19,6 +19,9 @@ export interface UIState { infrastructure: { rowsPerPage: number; }; + logs: { + rowsPerPage: number; + }; sidebar: { collapsed: boolean; }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 644057425..6ec628238 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -777,6 +777,32 @@ "title": "An infrastructure monitor is used to:" } }, + "logs": { + "tabs": { + "diagnostics": "Diagnostics", + "logs": "Logs", + "queue": "Job queue" + }, + "logLevelSelect": { + "label": "Log level" + }, + "noLogs": "No logs found", + "table": { + "headers": { + "timestamp": "Timestamp", + "level": "Level", + "service": "Service", + "method": "Method", + "monitorId": "Monitor ID", + "runCount": "Run count", + "failCount": "Fail count", + "lastRunAt": "Last run at", + "lockedAt": "Locked at", + "lastFinisehdAt": "Last finished at", + "lastRunTook": "Last run took" + } + } + }, "maintenanceWindow": { "fallback": { "actionButton": "Let's create your first maintenance window!", diff --git a/client/vite.config.ts b/client/vite.config.ts index 3d8d66335..f381eb5ed 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export default defineConfig(({}) => { - let version = "3.2.1"; + let version = "3.3"; return { base: "/", From b0686c0576d46a8b4e2d3c279aadab340b710b9b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Feb 2026 21:40:36 +0000 Subject: [PATCH 189/392] queue tab --- .../Components/v2/design-elements/Table.tsx | 4 + client/src/Pages/Logs/Metrics.tsx | 50 ++++++ client/src/Pages/Logs/TabQueue.tsx | 46 ++++- client/src/Pages/Logs/TableJobs.tsx | 164 ++++++++++++++++++ client/src/Pages/Logs/TableLogs.tsx | 28 +-- client/src/Types/Queue.ts | 40 +++++ client/src/Utils/TimeUtils.ts | 6 + client/src/locales/en.json | 22 ++- 8 files changed, 335 insertions(+), 25 deletions(-) create mode 100644 client/src/Pages/Logs/Metrics.tsx create mode 100644 client/src/Pages/Logs/TableJobs.tsx create mode 100644 client/src/Types/Queue.ts diff --git a/client/src/Components/v2/design-elements/Table.tsx b/client/src/Components/v2/design-elements/Table.tsx index aea8b40a0..35cd4512f 100644 --- a/client/src/Components/v2/design-elements/Table.tsx +++ b/client/src/Components/v2/design-elements/Table.tsx @@ -29,6 +29,7 @@ import type { TablePaginationOwnProps } from "@mui/material/TablePagination"; import { useTranslation } from "react-i18next"; import { useState, Fragment, useEffect } from "react"; import { useTheme } from "@mui/material/styles"; +import type { SxProps, Theme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; export type Header = { @@ -47,6 +48,7 @@ type DataTableProps = renderExpandedContent?: (row: T) => React.ReactNode; emptyViewText?: string; emptyViewPositive?: boolean; + getRowSx?: (row: T) => SxProps; }; export function DataTable< @@ -64,6 +66,7 @@ export function DataTable< renderExpandedContent, emptyViewText, emptyViewPositive, + getRowSx, }: DataTableProps) { const theme = useTheme(); const [expanded, setExpanded] = useState<(string | number) | null>(null); @@ -204,6 +207,7 @@ export function DataTable< { if (expandableRows) handleExpand(row); diff --git a/client/src/Pages/Logs/Metrics.tsx b/client/src/Pages/Logs/Metrics.tsx new file mode 100644 index 000000000..8f96ec5ac --- /dev/null +++ b/client/src/Pages/Logs/Metrics.tsx @@ -0,0 +1,50 @@ +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/v2/design-elements"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { QueueMetrics } from "@/Types/Queue"; + +interface MetricsProps { + metrics: QueueMetrics | null; +} + +export const Metrics = ({ metrics }: MetricsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + if (!metrics) { + return null; + } + + return ( + + + 0 ? "success" : undefined} + title={t("pages.logs.metrics.activeJobs")} + subtitle={String(metrics.activeJobs)} + /> + 0 ? "error" : undefined} + title={t("pages.logs.metrics.failingJobs")} + subtitle={String(metrics.failingJobs)} + /> + + 0 ? "error" : undefined} + title={t("pages.logs.metrics.totalFailures")} + subtitle={String(metrics.totalFailures)} + /> + + ); +}; diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index e327218d3..15efd56b7 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -1,3 +1,47 @@ +import Stack from "@mui/material/Stack"; +import { TableJobs, TableFailedJobs } from "@/Pages/Logs/TableJobs"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useGet, usePost } from "@/Hooks/UseApi"; +import type { QueueData } from "@/Types/Queue"; +import { Metrics } from "@/Pages/Logs/Metrics"; +import { Button } from "@/Components/v2/inputs"; + export const TabQueue = () => { - return "Queue"; + const theme = useTheme(); + const { t } = useTranslation(); + const { + data: queueData, + // isLoading, + // error, + refetch, + } = useGet("/queue/all-metrics", {}, { refreshInterval: 5000 }); + + const { post, loading: isFlushing } = usePost(); + + const jobs = queueData?.jobs ?? []; + const metrics = queueData?.metrics ?? null; + + const handleFlushQueue = async () => { + await post("/queue/flush", {}); + refetch(); + }; + + return ( + + + + + + + + + ); }; diff --git a/client/src/Pages/Logs/TableJobs.tsx b/client/src/Pages/Logs/TableJobs.tsx new file mode 100644 index 000000000..e9608df9a --- /dev/null +++ b/client/src/Pages/Logs/TableJobs.tsx @@ -0,0 +1,164 @@ +import { Table } from "@/Components/v2/design-elements"; +import { Typography, useTheme } from "@mui/material"; +import prettyMilliseconds from "pretty-ms"; +import { formatTimestamp } from "@/Utils/TimeUtils"; + +import { useTranslation } from "react-i18next"; +import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; +import type { Header } from "@/Components/v2/design-elements"; + +type QueueJobWithId = QueueJobSummary & { id: string | number }; + +interface TableJobsProps { + jobs: QueueJobSummary[]; +} + +export const TableJobs = ({ jobs }: TableJobsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const jobsWithId: QueueJobWithId[] = jobs.map((job) => ({ + ...job, + id: job.monitorId, + })); + + const headers: Header[] = [ + { + id: "id", + content: t("common.table.headers.monitorId"), + render: (row) => {row.monitorId}, + }, + { + id: "url", + content: t("common.table.headers.url"), + render: (row) => {row.monitorUrl}, + }, + { + id: "interval", + content: t("common.table.headers.interval"), + render: (row) => ( + {prettyMilliseconds(row.monitorInterval ?? 0)} + ), + }, + { + id: "type", + content: t("common.table.headers.type"), + render: (row) => {row.monitorType}, + }, + { + id: "active", + content: t("common.table.headers.active"), + render: (row) => {row.active.toString()}, + }, + { + id: "runCount", + content: t("pages.logs.table.headers.runCount"), + render: (row) => {row.runCount}, + }, + { + id: "failCount", + content: t("pages.logs.table.headers.failCount"), + render: (row) => {row.failCount}, + }, + { + id: "lastRun", + content: t("pages.logs.table.headers.lastRunAt"), + render: (row) => {formatTimestamp(row.lastRunAt)}, + }, + { + id: "lockedAt", + content: t("pages.logs.table.headers.lockedAt"), + render: (row) => {formatTimestamp(row.lockedAt)}, + }, + + { + id: "lastFinish", + content: t("pages.logs.table.headers.lastFinishedAt"), + render: (row) => {formatTimestamp(row.lastFinishedAt)}, + }, + { + id: "lastRunTook", + content: t("pages.logs.table.headers.lastRunTook"), + render: (row) => { + const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-"; + return {value}; + }, + }, + ]; + + return ( +
({ + ...(row.lockedAt && { + "& td": { backgroundColor: theme.palette.success.light }, + }), + })} + /> + ); +}; + +type QueueJobFailureWithId = QueueJobFailure & { id: string | number }; + +interface TableFailedJobsProps { + metrics: QueueMetrics | null; +} +export const TableFailedJobs = ({ metrics }: TableFailedJobsProps) => { + const { t } = useTranslation(); + if (!metrics) { + return null; + } + + const jobFailuresWithId: QueueJobFailureWithId[] = metrics.jobsWithFailures.map( + (job) => ({ + ...job, + id: job.monitorId, + }) + ); + + const headers: Header[] = [ + { + id: "monitorId", + content: t("common.table.headers.monitorId"), + render: (row) => { + return {row.monitorId}; + }, + }, + { + id: "monitorUrl", + content: t("common.table.headers.url"), + render: (row) => { + return {row.monitorUrl}; + }, + }, + { + id: "failCount", + content: t("pages.logs.table.headers.failCount"), + render: (row) => { + return {row.failCount}; + }, + }, + { + id: "failedAt", + content: t("pages.logs.table.headers.lastFailedAt"), + render: (row) => { + return {formatTimestamp(row.failedAt)}; + }, + }, + { + id: "failReason", + content: t("pages.logs.table.headers.failReason"), + render: (row) => { + return {row.failReason}; + }, + }, + ]; + + return ( +
+ ); +}; diff --git a/client/src/Pages/Logs/TableLogs.tsx b/client/src/Pages/Logs/TableLogs.tsx index e565739bd..dace0db43 100644 --- a/client/src/Pages/Logs/TableLogs.tsx +++ b/client/src/Pages/Logs/TableLogs.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; import type { RootState } from "@/Types/state"; import { setRowsPerPage } from "@/Features/UI/uiSlice"; +import { formatTimestamp } from "@/Utils/TimeUtils"; type LogWithId = Log & { id: number }; @@ -42,12 +43,6 @@ const LevelBadge = ({ level }: { level: LogLevel }) => { ); }; -const formatTimestamp = (timestamp: string): string => { - if (!timestamp) return "-"; - const date = new Date(timestamp); - return date.toLocaleString(); -}; - export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -60,7 +55,7 @@ export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => id: "timestamp", content: t("pages.logs.table.headers.timestamp"), render: (row) => ( - + {formatTimestamp(row.timestamp)} ), @@ -73,17 +68,13 @@ export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => { id: "service", content: t("pages.logs.table.headers.service"), - render: (row) => ( - {row.service || "-"} - ), + render: (row) => {row.service || "-"}, }, { id: "method", content: t("pages.logs.table.headers.method"), render: (row) => ( - - {row.method || "-"} - + {row.method || "-"} ), }, { @@ -91,13 +82,10 @@ export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => content: t("common.table.headers.message"), render: (row) => ( {row.message || "-"} diff --git a/client/src/Types/Queue.ts b/client/src/Types/Queue.ts new file mode 100644 index 000000000..77a770aeb --- /dev/null +++ b/client/src/Types/Queue.ts @@ -0,0 +1,40 @@ +import type { MonitorType } from "./Monitor"; + +export interface QueueJobFailure { + monitorId: string | number; + monitorUrl: string | null; + monitorType: MonitorType | null; + failedAt: number | null; + failCount: number; + failReason: string | null; +} + +export interface QueueMetrics { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: QueueJobFailure[]; + totalRuns: number; + totalFailures: number; +} + +export interface QueueJobSummary { + monitorId: string | number; + monitorUrl: string | null; + monitorType: MonitorType | null; + monitorInterval: number | null; + active: boolean; + lockedAt: number | null; + runCount: number; + failCount: number; + failReason: string | null; + lastRunAt: number | null; + lastFinishedAt: number | null; + lastRunTook: number | null; + lastFailedAt: number | null; +} + +export interface QueueData { + jobs: QueueJobSummary[]; + metrics: QueueMetrics; +} diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index a19a0bf8b..abd56ce44 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -50,3 +50,9 @@ export const tooltipDateFormatLookup = (range: string) => { } return format; }; + +export const formatTimestamp = (timestamp: string | number | null): string => { + if (!timestamp) return "-"; + const date = new Date(timestamp); + return date.toLocaleString(); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 6ec628238..307161905 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -199,7 +199,8 @@ "save": "Save", "test": "Test", "testNotifications": "Test notifications", - "toggleTheme": "Toggles light & dark" + "toggleTheme": "Toggles light & dark", + "flushQueue": "Flush queue" }, "charts": { "labels": { @@ -230,9 +231,13 @@ "dateTime": "Date & time", "message": "Message", "monitor": "Monitor", + "monitorId": "Monitor ID", "name": "Name", "status": "Status", - "type": "Type" + "type": "Type", + "url": "Url", + "interval": "Interval", + "active": "Active" } }, "toasts": { @@ -787,6 +792,13 @@ "label": "Log level" }, "noLogs": "No logs found", + "metrics": { + "jobs": "Jobs", + "activeJobs": "Active jobs", + "failingJobs": "Failing jobs", + "totalRuns": "Total runs", + "totalFailures": "Total failures" + }, "table": { "headers": { "timestamp": "Timestamp", @@ -798,8 +810,10 @@ "failCount": "Fail count", "lastRunAt": "Last run at", "lockedAt": "Locked at", - "lastFinisehdAt": "Last finished at", - "lastRunTook": "Last run took" + "lastFinishedAt": "Last finished at", + "lastRunTook": "Last run took", + "lastFailedAt": "Last failed at", + "failReason": "Fail reason" } } }, From 9974fa401083f78b89daae499fa28d4002469cba Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 17:24:16 +0000 Subject: [PATCH 190/392] headers --- client/src/Pages/Logs/TabQueue.tsx | 13 +++++++++++++ client/src/locales/en.json | 2 ++ 2 files changed, 15 insertions(+) diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 15efd56b7..7cfd15f0e 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -1,4 +1,5 @@ import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { TableJobs, TableFailedJobs } from "@/Pages/Logs/TableJobs"; import { useTheme } from "@mui/material"; @@ -31,7 +32,19 @@ export const TabQueue = () => { return ( + + {t("pages.logs.jobQueue")} + + + {t("pages.logs.failedJobs")} + - - - ); -}; - -export default Diagnostics; diff --git a/client/src/Pages/Logs/Diagnostics/utils/utils.js b/client/src/Pages/Logs/Diagnostics/utils/utils.js deleted file mode 100644 index a3b75631f..000000000 --- a/client/src/Pages/Logs/Diagnostics/utils/utils.js +++ /dev/null @@ -1,16 +0,0 @@ -export const getPercentage = (value, total) => { - if (!value || !total) return 0; - return (value / total) * 100; -}; - -export const formatBytes = (bytes) => { - if (!bytes) return "N/A"; - - if (bytes === 0) return "0 Bytes"; - - if (bytes >= 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; - } - - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; -}; diff --git a/client/src/Pages/Logs/Logs/index.jsx b/client/src/Pages/Logs/Logs/index.jsx deleted file mode 100644 index d0319a387..000000000 --- a/client/src/Pages/Logs/Logs/index.jsx +++ /dev/null @@ -1,176 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import Typography from "@mui/material/Typography"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; - -import { useFetchLogs } from "../../../Hooks/logHooks.js"; -import { useTheme } from "@emotion/react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - -const LevelBadge = ({ level }) => { - const theme = useTheme(); - - const levelColors = { - info: theme.palette.success.main, - warn: theme.palette.warning.main, - error: theme.palette.error.main, - debug: theme.palette.accent.main, - }; - - const color = levelColors[level] || theme.palette.primary.contrastText; - - return ( - - {level} - - ); -}; - -const formatTimestamp = (timestamp) => { - if (!timestamp) return "-"; - const date = new Date(timestamp); - return date.toLocaleString(); -}; - -const Logs = () => { - const [logLevel, setLogLevel] = useState("all"); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(15); - - const theme = useTheme(); - const { t } = useTranslation(); - const [logs, isLoading, error] = useFetchLogs(); - - const LOG_LEVELS = [ - { _id: "all", name: t("logsPage.logLevelSelect.values.all") }, - { _id: "info", name: t("logsPage.logLevelSelect.values.info") }, - { _id: "warn", name: t("logsPage.logLevelSelect.values.warn") }, - { _id: "error", name: t("logsPage.logLevelSelect.values.error") }, - { _id: "debug", name: t("logsPage.logLevelSelect.values.debug") }, - ]; - - const headers = [ - { - id: "timestamp", - content: t("logsPage.table.timestamp"), - render: (row) => ( - - {formatTimestamp(row.timestamp)} - - ), - }, - { - id: "level", - content: t("logsPage.table.level"), - render: (row) => , - }, - { - id: "service", - content: t("logsPage.table.service"), - render: (row) => ( - {row.service || "-"} - ), - }, - { - id: "method", - content: t("logsPage.table.method"), - render: (row) => ( - {row.method || "-"} - ), - }, - { - id: "message", - content: t("logsPage.table.message"), - render: (row) => ( - - {row.message || "-"} - - ), - }, - ]; - - const filteredLogs = logs - ?.filter((log) => { - if (logLevel === "all") return true; - return log.level === logLevel; - }) - .reverse() - .map((log, idx) => ({ ...log, id: idx })); - - const paginatedLogs = filteredLogs?.slice( - page * rowsPerPage, - page * rowsPerPage + rowsPerPage - ); - - const handleChangePage = (event, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; - - const handleLogLevelChange = (e) => { - setLogLevel(e.target.value); - setPage(0); - }; - - return ( - - - {t("logsPage.logLevelSelect.title")} - + {languages.map((lang) => { + let parsedLang = lang === "en" ? "gb" : lang; + + if (parsedLang.includes("-")) { + parsedLang = parsedLang.split("-")[1].toLowerCase(); + } + + parsedLang = langMap[parsedLang] || parsedLang; + + const flag = parsedLang ? `fi fi-${parsedLang}` : null; + + return ( + + + + {flag && } + + + {lang} + + + + ); + })} + + ); +}; + +export default LanguageSelector; diff --git a/client/src/Components/v2/inputs/SwitchTheme.tsx b/client/src/Components/v2/inputs/SwitchTheme.tsx new file mode 100644 index 000000000..e609f50fb --- /dev/null +++ b/client/src/Components/v2/inputs/SwitchTheme.tsx @@ -0,0 +1,48 @@ +import IconButton from "@mui/material/IconButton"; + +import { Moon, Sun } from "lucide-react"; + +import { setMode } from "@/Features/UI/uiSlice.js"; +import { useTheme } from "@mui/material"; +import { useDispatch, useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; + +export const SwitchTheme = () => { + const mode = useSelector((state: RootState) => state.ui.mode); + const dispatch = useDispatch(); + const theme = useTheme(); + + const handleChange = () => { + dispatch(setMode(mode === "light" ? "dark" : "light")); + }; + return ( + + {mode === "light" ? ( + + ) : ( + + )} + + ); +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 25d9febe3..78f1a51cf 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -16,3 +16,5 @@ export * from "./ImageUpload"; export * from "./ColorPicker"; export { DatePickerComponent as DatePicker } from "./DatePicker"; export { TimePickerComponent as TimePicker } from "./TimePicker"; +export * from "./LanguageSelector"; +export * from "./SwitchTheme"; diff --git a/client/src/Pages/About/index.jsx b/client/src/Pages/About/index.jsx deleted file mode 100644 index ff7bb6c6b..000000000 --- a/client/src/Pages/About/index.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import AppBar from "@/Components/v1/Common/AppBar.jsx"; -import Footer from "@/Components/v1/Common/Footer.jsx"; -import { useTranslation } from "react-i18next"; - -const AboutUs = () => { - const { t } = useTranslation(); - - return ( -
- -

{t("aboutus")}

-
-
- ); -}; - -export default AboutUs; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx new file mode 100644 index 000000000..5756f18cb --- /dev/null +++ b/client/src/Pages/Auth/Login/index.tsx @@ -0,0 +1,14 @@ +import { BaseAuthPage } from "@/Components/v2/design-elements"; + +import { useTranslation } from "react-i18next"; +const LoginPage = () => { + const { t } = useTranslation(); + return ( + + ); +}; + +export default LoginPage; diff --git a/client/src/Pages/Auth/Login/index.jsx b/client/src/Pages/Auth/Login/old.jsx similarity index 100% rename from client/src/Pages/Auth/Login/index.jsx rename to client/src/Pages/Auth/Login/old.jsx diff --git a/client/src/Pages/Auth/components/HeaderAuthControls.tsx b/client/src/Pages/Auth/components/HeaderAuthControls.tsx new file mode 100644 index 000000000..a62048372 --- /dev/null +++ b/client/src/Pages/Auth/components/HeaderAuthControls.tsx @@ -0,0 +1,53 @@ +import Stack, { type StackProps } from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Logo from "@/assets/icons/checkmate-icon.svg?react"; +import { LanguageSelector, SwitchTheme } from "@/Components/v2/inputs"; + +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; + +interface HeaderAuthControlsProps extends StackProps { + hideLogo?: boolean; +} + +export const HeaderAuthControls = ({ + hideLogo = false, + ...stackProps +}: HeaderAuthControlsProps) => { + // Hooks + const theme = useTheme(); + const { t } = useTranslation(); + + return ( + + + {!hideLogo && ( + <> + + {t("common.appName")} + + )} + + + + + + + ); +}; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 22b5b446a..b281b359e 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -7,7 +7,7 @@ import { Navigate, Route, Routes as LibRoutes } from "react-router"; import HomeLayout from "@/Components/v1/Layouts/HomeLayout"; import NotFound from "../Pages/NotFound/index.jsx"; // Auth -import AuthLogin from "../Pages/Auth/Login/index.jsx"; +import AuthLogin from "../Pages/Auth/Login"; import AuthRegister from "../Pages/Auth/Register/index.jsx"; import AuthForgotPassword from "../Pages/Auth/ForgotPassword.jsx"; import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; @@ -360,7 +360,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> Date: Fri, 6 Feb 2026 20:53:48 +0000 Subject: [PATCH 197/392] login UI --- .../v2/design-elements/BasePage.tsx | 21 +++++- .../v2/design-elements/TextLink.tsx | 42 +++++++++++ .../Components/v2/design-elements/index.tsx | 1 + client/src/Hooks/useLoginForm.ts | 13 ++++ client/src/Pages/Auth/Login/index.tsx | 70 ++++++++++++++++++- client/src/Validation/login.ts | 12 ++++ client/src/locales/en.json | 13 +++- 7 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 client/src/Components/v2/design-elements/TextLink.tsx create mode 100644 client/src/Hooks/useLoginForm.ts create mode 100644 client/src/Validation/login.ts diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index b0f5ee26f..65e74b553 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/v2/design-elements/BasePage.tsx @@ -205,12 +205,17 @@ export const MonitorBasePageWithStates = ({ ); }; -interface BaseAuthPageProps extends React.PropsWithChildren { +interface BaseAuthPageProps extends React.PropsWithChildren, StackProps { title: string; subtitle: string; } -export const BaseAuthPage = ({ title, subtitle, children }: BaseAuthPageProps) => { +export const BaseAuthPage = ({ + title, + subtitle, + children, + ...props +}: BaseAuthPageProps) => { const theme = useTheme(); return ( {subtitle}
- {children} + + {children} + ); }; diff --git a/client/src/Components/v2/design-elements/TextLink.tsx b/client/src/Components/v2/design-elements/TextLink.tsx new file mode 100644 index 000000000..704b4b841 --- /dev/null +++ b/client/src/Components/v2/design-elements/TextLink.tsx @@ -0,0 +1,42 @@ +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import type { StackProps } from "@mui/material/Stack"; +import Link from "@mui/material/Link"; +import { Link as RouterLink } from "react-router-dom"; + +import { useTheme } from "@mui/material/styles"; + +interface TextLinkProps extends StackProps { + text: string; + linkText: string; + href: string; + target?: string; +} + +export const TextLink = ({ + text, + linkText, + href, + target = "_self", + ...props +}: TextLinkProps) => { + const theme = useTheme(); + + return ( + + {text} + + {linkText} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index a96fea052..b7b1d484e 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -18,3 +18,4 @@ export * from "./BaseChart"; export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; +export * from "./TextLink"; diff --git a/client/src/Hooks/useLoginForm.ts b/client/src/Hooks/useLoginForm.ts new file mode 100644 index 000000000..d69acd0ab --- /dev/null +++ b/client/src/Hooks/useLoginForm.ts @@ -0,0 +1,13 @@ +import { useMemo } from "react"; +import { loginSchema, type LoginFormData } from "@/Validation/login"; + +export const useLoginForm = () => { + return useMemo(() => { + const defaults: LoginFormData = { + email: "", + password: "", + }; + + return { schema: loginSchema, defaults }; + }, []); +}; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 5756f18cb..9c945f7ed 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,13 +1,79 @@ -import { BaseAuthPage } from "@/Components/v2/design-elements"; +import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; +import { Button, TextField } from "@/Components/v2/inputs"; import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; +import { useLoginForm } from "@/Hooks/useLoginForm"; +import type { LoginFormData } from "@/Validation/login"; + const LoginPage = () => { const { t } = useTranslation(); + + const { schema, defaults } = useLoginForm(); + + const { control, handleSubmit } = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const onSubmit = (data: LoginFormData) => { + console.log("Login submitted:", data); + }; + return ( + > + ( + + )} + /> + ( + + )} + /> + + + + ); }; diff --git a/client/src/Validation/login.ts b/client/src/Validation/login.ts new file mode 100644 index 000000000..03eb8d4e2 --- /dev/null +++ b/client/src/Validation/login.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + email: z + .string() + .min(1, "auth.common.inputs.email.errors.empty") + .email("auth.common.inputs.email.errors.invalid") + .transform((val) => val.toLowerCase().trim()), + password: z.string().min(1, "auth.common.inputs.password.errors.empty"), +}); + +export type LoginFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a803fad00..514ac3db4 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -515,7 +515,18 @@ }, "login": { "title": "Welcome back to Checkmate!", - "subtitle": "Log in to continue" + "subtitle": "Log in to continue", + "submit": "Login", + "links": { + "forgotPassword": { + "text": "Forgot password?", + "linkText": "Reset password" + }, + "register": { + "text": "Don't have an account?", + "linkText": "Register here" + } + } } }, "checks": { From 514570da788d54351d3ed07d06d5e29b2bb9a010 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 21:11:46 +0000 Subject: [PATCH 198/392] login page refactor --- client/src/Features/Auth/authSlice.js | 9 +++++- client/src/Pages/Auth/Login/index.tsx | 40 ++++++++++++++++----------- client/src/Utils/ApiClient.ts | 4 ++- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js index b78a8b47e..1d93ce3fa 100644 --- a/client/src/Features/Auth/authSlice.js +++ b/client/src/Features/Auth/authSlice.js @@ -196,6 +196,13 @@ const authSlice = createSlice({ state.success = true; state.msg = "Logged out successfully"; }, + setAuthState: (state, action) => { + state.isLoading = false; + state.success = action.payload.success; + state.msg = action.payload.msg; + state.authToken = action.payload.data.token; + state.user = action.payload.data.user; + }, }, extraReducers: (builder) => { // Register thunk @@ -249,4 +256,4 @@ const authSlice = createSlice({ }); export default authSlice.reducer; -export const { clearAuthState } = authSlice.actions; +export const { clearAuthState, setAuthState } = authSlice.actions; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 9c945f7ed..49c7bafa7 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,4 +1,4 @@ -import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; +import { BaseAuthPage } from "@/Components/v2/design-elements"; import { Button, TextField } from "@/Components/v2/inputs"; import { useTranslation } from "react-i18next"; @@ -6,19 +6,38 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useLoginForm } from "@/Hooks/useLoginForm"; import type { LoginFormData } from "@/Validation/login"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { setAuthState } from "@/Features/Auth/authSlice"; +import { usePost } from "@/Hooks/UseApi"; const LoginPage = () => { const { t } = useTranslation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const { post, loading } = usePost(); const { schema, defaults } = useLoginForm(); - const { control, handleSubmit } = useForm({ + const { control, handleSubmit, setError } = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); - const onSubmit = (data: LoginFormData) => { - console.log("Login submitted:", data); + const onSubmit = async (data: LoginFormData) => { + if (loading) return; + + const result = await post("/auth/login", data); + + if (result?.success) { + console.log(result); + // dispatch(setAuthState(result)); + // navigate("/uptime"); + } else if (result?.msg === "Incorrect password") { + setError("password", { + message: "auth.login.errors.password.incorrect", + }); + } }; return ( @@ -58,21 +77,10 @@ const LoginPage = () => { - - ); }; diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts index 42a14bf00..c39839558 100644 --- a/client/src/Utils/ApiClient.ts +++ b/client/src/Utils/ApiClient.ts @@ -42,7 +42,9 @@ export const initApiClient = (store: StoreType): void => { const onSuccess = (response: AxiosResponse) => response; const onError = (error: AxiosError) => { if (error.response?.status === 401) { - window.location.href = "/login"; + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } } return Promise.reject(error); }; From 8f2072e4972a4675d80f13a9ff00b849fabccac3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 21:35:25 +0000 Subject: [PATCH 199/392] cleanup --- .../Auth/Login/hooks/useLoadingSubmit.jsx | 22 ---- .../Pages/Auth/Login/hooks/useLoginForm.jsx | 19 ---- .../Pages/Auth/Login/hooks/useLoginSubmit.jsx | 40 ------- .../Auth/Login/hooks/useValidateLoginForm.jsx | 31 ------ client/src/Pages/Auth/Login/index.tsx | 11 +- client/src/Pages/Auth/Login/old.jsx | 102 ------------------ 6 files changed, 3 insertions(+), 222 deletions(-) delete mode 100644 client/src/Pages/Auth/Login/hooks/useLoadingSubmit.jsx delete mode 100644 client/src/Pages/Auth/Login/hooks/useLoginForm.jsx delete mode 100644 client/src/Pages/Auth/Login/hooks/useLoginSubmit.jsx delete mode 100644 client/src/Pages/Auth/Login/hooks/useValidateLoginForm.jsx delete mode 100644 client/src/Pages/Auth/Login/old.jsx diff --git a/client/src/Pages/Auth/Login/hooks/useLoadingSubmit.jsx b/client/src/Pages/Auth/Login/hooks/useLoadingSubmit.jsx deleted file mode 100644 index a779165b6..000000000 --- a/client/src/Pages/Auth/Login/hooks/useLoadingSubmit.jsx +++ /dev/null @@ -1,22 +0,0 @@ -// Hook to avoid double submits and manage loading state -import { useState, useCallback } from "react"; - -const useLoadingSubmit = () => { - const [submitting, setSubmitting] = useState(false); - const executeSubmit = useCallback( - async (submitFunction) => { - if (submitting) return; - setSubmitting(true); - try { - return await submitFunction(); - } finally { - setSubmitting(false); - } - }, - [submitting] - ); - - return { submitting, executeSubmit }; -}; - -export default useLoadingSubmit; diff --git a/client/src/Pages/Auth/Login/hooks/useLoginForm.jsx b/client/src/Pages/Auth/Login/hooks/useLoginForm.jsx deleted file mode 100644 index 61814d723..000000000 --- a/client/src/Pages/Auth/Login/hooks/useLoginForm.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useState } from "react"; - -const useLoginForm = () => { - const [form, setForm] = useState({ - email: "", - password: "", - }); - const onChange = (e) => { - let { name, value } = e.target; - if (name === "email") { - value = value.toLowerCase(); - } - const updatedForm = { ...form, [name]: value }; - setForm(updatedForm); - }; - return [form, onChange]; -}; - -export default useLoginForm; diff --git a/client/src/Pages/Auth/Login/hooks/useLoginSubmit.jsx b/client/src/Pages/Auth/Login/hooks/useLoginSubmit.jsx deleted file mode 100644 index b009793d5..000000000 --- a/client/src/Pages/Auth/Login/hooks/useLoginSubmit.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useDispatch } from "react-redux"; -import { login } from "../../../../Features/Auth/authSlice.js"; -import { useNavigate } from "react-router-dom"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -const useLoginSubmit = () => { - const dispatch = useDispatch(); - const navigate = useNavigate(); - const { t } = useTranslation(); - - const handleLoginSubmit = async (form, setErrors) => { - const action = await dispatch(login(form)); - if (action.payload.success) { - navigate("/uptime"); - createToast({ - body: t("auth.login.toasts.success"), - }); - } else { - if (action.payload) { - if (action.payload.msg === "Incorrect password") - setErrors({ - password: t("auth.login.errors.password.incorrect"), - }); - // dispatch errors - createToast({ - body: t("auth.login.toasts.incorrectPassword"), - }); - } else { - // unknown errors - createToast({ - body: t("common.toasts.unknownError"), - }); - } - } - }; - return [handleLoginSubmit]; -}; - -export default useLoginSubmit; diff --git a/client/src/Pages/Auth/Login/hooks/useValidateLoginForm.jsx b/client/src/Pages/Auth/Login/hooks/useValidateLoginForm.jsx deleted file mode 100644 index 0872213ad..000000000 --- a/client/src/Pages/Auth/Login/hooks/useValidateLoginForm.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useState } from "react"; -import { loginCredentials } from "../../../../Validation/validation.js"; - -const useValidateLoginForm = () => { - const [errors, setErrors] = useState({ - email: "", - password: "", - }); - const validateField = (name, value) => { - const { error } = loginCredentials.validate({ [name]: value }); - setErrors((prev) => ({ - ...prev, - [name]: error?.details?.[0]?.message || "", - })); - }; - const validateForm = (form) => { - const { error } = loginCredentials.validate(form, { abortEarly: false }); - if (error) { - const formErrors = {}; - for (const err of error.details) { - formErrors[err.path[0]] = err.message; - } - setErrors(formErrors); - return false; - } - return true; - }; - return [errors, setErrors, validateField, validateForm]; -}; - -export default useValidateLoginForm; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 49c7bafa7..1e4b48aaf 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -19,7 +19,7 @@ const LoginPage = () => { const { schema, defaults } = useLoginForm(); - const { control, handleSubmit, setError } = useForm({ + const { control, handleSubmit } = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); @@ -30,13 +30,8 @@ const LoginPage = () => { const result = await post("/auth/login", data); if (result?.success) { - console.log(result); - // dispatch(setAuthState(result)); - // navigate("/uptime"); - } else if (result?.msg === "Incorrect password") { - setError("password", { - message: "auth.login.errors.password.incorrect", - }); + dispatch(setAuthState(result)); + navigate("/uptime"); } }; diff --git a/client/src/Pages/Auth/Login/old.jsx b/client/src/Pages/Auth/Login/old.jsx deleted file mode 100644 index 7f5dcb5e4..000000000 --- a/client/src/Pages/Auth/Login/old.jsx +++ /dev/null @@ -1,102 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Button from "@mui/material/Button"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import TextLink from "@/Components/v1/TextLink/index.jsx"; -import AuthPageWrapper from "../components/AuthPageWrapper.jsx"; -// Utils -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; -import useLoginForm from "./hooks/useLoginForm.jsx"; -import useValidateLoginForm from "./hooks/useValidateLoginForm.jsx"; -import useLoginSubmit from "./hooks/useLoginSubmit.jsx"; -import useLoadingSubmit from "./hooks/useLoadingSubmit.jsx"; - -const Login = () => { - // Hooks - const { t } = useTranslation(); - const theme = useTheme(); - const [form, onChange] = useLoginForm(); - const [errors, setErrors, validateField, validateForm] = useValidateLoginForm(); - const [handleLoginSubmit] = useLoginSubmit(); - const { submitting, executeSubmit } = useLoadingSubmit(); - - // Handlers - const handleChange = (e) => { - onChange(e); - validateField(e.target.name, e.target.value); - }; - const onSubmit = async (e) => { - e.preventDefault(); - const isValid = validateForm(form); - if (!isValid) return; - await executeSubmit(() => handleLoginSubmit(form, setErrors)); - }; - return ( - - - - } - /> - - - - - - ); -}; -export default Login; From 16e8035299bb5df804a1428e8a22bfa6a0964492 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 21:50:45 +0000 Subject: [PATCH 200/392] ui --- .../v2/design-elements/BasePage.tsx | 2 +- client/src/Pages/Auth/Login/index.tsx | 14 +++++++- client/src/Pages/Auth/Recovery/index.tsx | 33 +++++++++++++++++++ client/src/Routes/index.jsx | 12 +++++-- client/src/locales/en.json | 11 +++++++ 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 client/src/Pages/Auth/Recovery/index.tsx diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index 65e74b553..ec429d5f6 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/v2/design-elements/BasePage.tsx @@ -247,7 +247,7 @@ export const BaseAuthPage = ({ > {title}
- {subtitle} + {subtitle} { > {t("pages.auth.login.submit")} + + ); }; diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx new file mode 100644 index 000000000..5df9cb1f4 --- /dev/null +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -0,0 +1,33 @@ +import { Button, TextField } from "@/Components/v2/inputs"; +import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; + +import { useTranslation } from "react-i18next"; +const ForgotPasswordPage = () => { + const { t } = useTranslation(); + return ( + + + + + + ); +}; + +export default ForgotPasswordPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index b281b359e..6882fafbf 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -6,10 +6,12 @@ import { useSelector } from "react-redux"; import { Navigate, Route, Routes as LibRoutes } from "react-router"; import HomeLayout from "@/Components/v1/Layouts/HomeLayout"; import NotFound from "../Pages/NotFound/index.jsx"; + // Auth import AuthLogin from "../Pages/Auth/Login"; import AuthRegister from "../Pages/Auth/Register/index.jsx"; -import AuthForgotPassword from "../Pages/Auth/ForgotPassword.jsx"; +// import AuthForgotPassword from "../Pages/Auth/ForgotPassword.jsx"; +import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx"; import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed.jsx"; @@ -382,7 +384,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> Date: Fri, 6 Feb 2026 22:14:42 +0000 Subject: [PATCH 201/392] recovery form, fix email service --- client/src/Hooks/useRecoveryForm.ts | 12 +++++ client/src/Pages/Auth/Recovery/index.tsx | 44 +++++++++++++++++-- client/src/Validation/recovery.ts | 11 +++++ server/src/routes/settingsRoute.ts | 2 +- .../service/infrastructure/emailService.ts | 4 +- 5 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 client/src/Hooks/useRecoveryForm.ts create mode 100644 client/src/Validation/recovery.ts diff --git a/client/src/Hooks/useRecoveryForm.ts b/client/src/Hooks/useRecoveryForm.ts new file mode 100644 index 000000000..5a3633e45 --- /dev/null +++ b/client/src/Hooks/useRecoveryForm.ts @@ -0,0 +1,12 @@ +import { recoverySchema } from "@/Validation/recovery"; + +export const useRecoveryForm = () => { + const defaults = { + email: "", + }; + + return { + schema: recoverySchema, + defaults, + }; +}; diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index 5df9cb1f4..5add53077 100644 --- a/client/src/Pages/Auth/Recovery/index.tsx +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -1,22 +1,58 @@ import { Button, TextField } from "@/Components/v2/inputs"; import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; +import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; +import type { RecoveryFormData } from "@/Validation/recovery"; +import { usePost } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; + const ForgotPasswordPage = () => { const { t } = useTranslation(); + const { post, loading } = usePost(); + + const { schema, defaults } = useRecoveryForm(); + + const { control, handleSubmit } = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const onSubmit = async (data: RecoveryFormData) => { + if (loading) return; + + const result = await post("/auth/recovery/request", data); + + if (result?.success) { + // Navigate to Check email page + } + }; + return ( - ( + + )} /> @@ -24,7 +60,7 @@ const ForgotPasswordPage = () => { alignSelf={"center"} text={t("pages.auth.forgotPassword.links.login.text")} linkText={t("pages.auth.forgotPassword.links.login.linkText")} - href={"/login"} + href="/login" /> ); diff --git a/client/src/Validation/recovery.ts b/client/src/Validation/recovery.ts new file mode 100644 index 000000000..356f28738 --- /dev/null +++ b/client/src/Validation/recovery.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const recoverySchema = z.object({ + email: z + .string() + .min(1, "auth.common.inputs.email.errors.empty") + .email("auth.common.inputs.email.errors.invalid") + .transform((val) => val.toLowerCase().trim()), +}); + +export type RecoveryFormData = z.infer; diff --git a/server/src/routes/settingsRoute.ts b/server/src/routes/settingsRoute.ts index c7db1ed3b..c03c17524 100755 --- a/server/src/routes/settingsRoute.ts +++ b/server/src/routes/settingsRoute.ts @@ -13,7 +13,7 @@ class SettingsRoutes { initRoutes() { this.router.get("/", this.settingsController.getAppSettings); - this.router.put("/", isAllowed(["admin", "superadmin"]), this.settingsController.updateAppSettings); + this.router.patch("/", isAllowed(["admin", "superadmin"]), this.settingsController.updateAppSettings); this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.settingsController.sendTestEmail); } diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 8598f112f..1f46a080a 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -29,18 +29,18 @@ class EmailService { this.mjml2html = mjml2html; this.nodemailer = nodemailer; this.logger = logger; - this.init(); this.templateLookup = {}; this.loadTemplate = () => { return () => {}; }; + this.init(); } get serviceName() { return EmailService.SERVICE_NAME; } - init = async () => { + init = () => { this.loadTemplate = (templateName) => { try { const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); From 0f266488552a1d9bd0cc7739badc3fdc5d5863c8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 22:18:44 +0000 Subject: [PATCH 202/392] remove old page --- client/src/Pages/Auth/ForgotPassword.jsx | 243 ----------------------- client/src/Routes/index.jsx | 1 - 2 files changed, 244 deletions(-) delete mode 100644 client/src/Pages/Auth/ForgotPassword.jsx diff --git a/client/src/Pages/Auth/ForgotPassword.jsx b/client/src/Pages/Auth/ForgotPassword.jsx deleted file mode 100644 index 1dee3b125..000000000 --- a/client/src/Pages/Auth/ForgotPassword.jsx +++ /dev/null @@ -1,243 +0,0 @@ -import { Box, Stack, Typography, Button } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { createToast } from "../../Utils/toastUtils.jsx"; -import { useDispatch, useSelector } from "react-redux"; -import { forgotPassword } from "../../Features/Auth/authSlice.js"; -import { useEffect, useState } from "react"; -import { newOrChangedCredentials } from "../../Validation/validation.js"; -import { useNavigate, useLocation } from "react-router-dom"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import Background from "@/assets/Images/background-grid.svg?react"; -import IconBox from "@/Components/v1/IconBox/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import { Trans, useTranslation } from "react-i18next"; -import "./index.css"; - -const ForgotPassword = () => { - const navigate = useNavigate(); - const dispatch = useDispatch(); - const theme = useTheme(); - const location = useLocation(); - - const { isLoading } = useSelector((state) => state.auth); - const [errors, setErrors] = useState({}); - const [form, setForm] = useState({ - email: "", - }); - - useEffect(() => { - const email = sessionStorage.getItem("email"); - email && setForm({ email: sessionStorage.getItem("email") }); - }, []); - - const { t } = useTranslation(); - - const handleSubmit = async (event) => { - event.preventDefault(); - - const { error } = newOrChangedCredentials.validate(form, { abortEarly: false }); - - if (error) { - // validation errors - const err = - error.details && error.details.length > 0 - ? error.details[0].message // FIXME: Possibly untranslated string - : t("auth.common.errors.validation"); - setErrors({ email: err }); - createToast({ - body: err, - }); - } else { - const action = await dispatch(forgotPassword(form)); - if (action.payload.success) { - sessionStorage.setItem("email", form.email); - navigate("/check-email", { - state: location.state || { from: "/login" }, // Preserve original from, fallback to login - }); - createToast({ - body: t("auth.forgotPassword.toasts.sent").replace("", form.email), - }); - } else { - if (action.payload) { - // dispatch errors - createToast({ - body: action.payload.msg, // FIXME: Potentially untranslated string - }); - } else { - // unknown errors - createToast({ - body: t("common.toasts.unknownError"), - }); - } - } - } - }; - - const handleChange = (event) => { - const { value } = event.target; - setForm({ email: value }); - - const { error } = newOrChangedCredentials.validate( - { email: value }, - { abortEarly: false } - ); - - if (error) setErrors({ email: error.details[0].message }); - else delete errors.email; - }; - - const handleNavigate = () => { - sessionStorage.removeItem("email"); - location.state?.from - ? navigate(location.state.from, { replace: true }) - : navigate("/login"); - }; - - return ( - - - - - - - {t("common.appName")} - - .MuiStack-root": { - border: 1, - borderRadius: theme.spacing(5), - borderColor: theme.palette.primary.lowContrast, - backgroundColor: theme.palette.primary.main, - padding: { - xs: theme.spacing(12), - sm: theme.spacing(20), - }, - }, - }} - > - - - - - - - - {t("auth.forgotPassword.heading")} - {t("auth.forgotPassword.subheadings.stepOne")} - - - - - - - - - - Account Password" - : "auth.forgotPassword.links.login" - } - components={{ - a: ( - - ), - }} - /> - - - - ); -}; - -export default ForgotPassword; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 6882fafbf..acc9ad0c1 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -10,7 +10,6 @@ import NotFound from "../Pages/NotFound/index.jsx"; // Auth import AuthLogin from "../Pages/Auth/Login"; import AuthRegister from "../Pages/Auth/Register/index.jsx"; -// import AuthForgotPassword from "../Pages/Auth/ForgotPassword.jsx"; import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx"; From a053386f4a18b161554fb4e7aabcd04dd2e82b62 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 22:34:50 +0000 Subject: [PATCH 203/392] ui --- client/src/Pages/Auth/Register/index.tsx | 43 +++++++++++++++++++ .../Auth/Register/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 18 ++++++-- client/src/locales/en.json | 20 +++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 client/src/Pages/Auth/Register/index.tsx rename client/src/Pages/Auth/Register/{index.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx new file mode 100644 index 000000000..e51eff6ec --- /dev/null +++ b/client/src/Pages/Auth/Register/index.tsx @@ -0,0 +1,43 @@ +import { BaseAuthPage } from "@/Components/v2/design-elements"; +import { Button, TextField } from "@/Components/v2/inputs"; + +import { useTranslation } from "react-i18next"; +const RegisterPage = () => { + const { t } = useTranslation(); + return ( + + + + + + + + + ); +}; + +export default RegisterPage; diff --git a/client/src/Pages/Auth/Register/index.jsx b/client/src/Pages/Auth/Register/old.jsx similarity index 100% rename from client/src/Pages/Auth/Register/index.jsx rename to client/src/Pages/Auth/Register/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index acc9ad0c1..12716dc35 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -9,7 +9,8 @@ import NotFound from "../Pages/NotFound/index.jsx"; // Auth import AuthLogin from "../Pages/Auth/Login"; -import AuthRegister from "../Pages/Auth/Register/index.jsx"; +import AuthRegister from "@/Pages/Auth/Register"; +import OldAuthRegister from "../Pages/Auth/Register/old.jsx"; import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx"; @@ -62,7 +63,8 @@ import CreateMonitor from "@/Pages/CreateMonitor"; const Routes = () => { const mode = useSelector((state) => state.ui.mode); - const AdminCheckedRegister = withAdminCheck(AuthRegister); + // const AdminCheckedRegister = withAdminCheck(AuthRegister); + const AdminCheckedRegister = AuthRegister; const v2theme = mode === "light" ? lightTheme : darkTheme; return ( @@ -372,7 +374,17 @@ const Routes = () => { } + element={ + <> + + + + + } + /> + } /> Date: Fri, 6 Feb 2026 22:53:41 +0000 Subject: [PATCH 204/392] form --- client/src/Hooks/useRegisterForm.ts | 17 ++++ client/src/Pages/Auth/Register/index.tsx | 99 ++++++++++++++++++++---- client/src/Validation/register.ts | 43 ++++++++++ 3 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 client/src/Hooks/useRegisterForm.ts create mode 100644 client/src/Validation/register.ts diff --git a/client/src/Hooks/useRegisterForm.ts b/client/src/Hooks/useRegisterForm.ts new file mode 100644 index 000000000..a043d8ff6 --- /dev/null +++ b/client/src/Hooks/useRegisterForm.ts @@ -0,0 +1,17 @@ +import { registerSchema, type RegisterFormData } from "@/Validation/register"; + +export const useRegisterForm = () => { + const defaults: RegisterFormData = { + firstName: "", + lastName: "", + email: "", + password: "", + confirm: "", + role: ["superadmin"], + }; + + return { + schema: registerSchema, + defaults, + }; +}; diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index e51eff6ec..e2e4fe802 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -1,34 +1,101 @@ import { BaseAuthPage } from "@/Components/v2/design-elements"; import { Button, TextField } from "@/Components/v2/inputs"; - +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; +import { useRegisterForm } from "@/Hooks/useRegisterForm"; +import type { RegisterFormData } from "@/Validation/register"; import { useTranslation } from "react-i18next"; + const RegisterPage = () => { const { t } = useTranslation(); + const { schema, defaults } = useRegisterForm(); + + const { control, handleSubmit } = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const onSubmit = (data: RegisterFormData) => { + console.log("Registration data:", data); + }; + + const onError = (errors: any) => { + console.log("Validation errors:", errors); + }; + return ( - ( + + )} /> - ( + + )} /> - ( + + )} /> - ( + + )} /> - ( + + )} /> diff --git a/client/src/Types/User.ts b/client/src/Types/User.ts new file mode 100644 index 000000000..e09e87678 --- /dev/null +++ b/client/src/Types/User.ts @@ -0,0 +1,28 @@ +export const UserRoles = ["user", "admin", "superadmin", "demo"] as const; +export type UserRole = (typeof UserRoles)[number]; + +export interface UserProfileImage { + data?: Buffer; + contentType?: string; +} + +export interface User { + id: string; + firstName: string; + lastName: string; + email: string; + avatarImage?: string; + profileImage?: UserProfileImage; + isActive: boolean; + isVerified: boolean; + role: UserRole[]; + teamId: string; + checkTTL?: number; + createdAt: string; + updatedAt: string; +} + +export interface AuthResponse { + user: User; + token: string; +} diff --git a/client/src/Validation/register.ts b/client/src/Validation/register.ts index b32a8a2fc..67b425ecd 100644 --- a/client/src/Validation/register.ts +++ b/client/src/Validation/register.ts @@ -32,6 +32,8 @@ export const registerSchema = z (val) => specialCharPattern.test(val), "Password must contain a special character" ), + teamId: z.string().optional(), + inviteToken: z.string().optional(), confirm: z.string().min(1, "Please confirm your password"), role: z.array(z.string()).min(1, "Role is required"), }) diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index a0273af99..315c62bb0 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -90,6 +90,7 @@ class UserService { user.role = invite.role; user.teamId = invite.teamId; } else { + console.log("WHITFUSKALG"); // This is the first account, create JWT secret to use if one is not supplied by env const jwtSecret = this.crypto.randomBytes(64).toString("hex"); await this.settingsRepository.update({ jwtSecret }); From 5152336591750b5424f6aea3153c3aec6c9fee58 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 23:31:07 +0000 Subject: [PATCH 206/392] fix validation, derrive teamID and Roles from tokens --- client/src/Hooks/useRegisterForm.ts | 3 -- client/src/Pages/Auth/Register/index.tsx | 37 ++++++++++++++++++++-- client/src/Routes/index.jsx | 8 ++++- client/src/Validation/register.ts | 3 -- server/src/service/business/userService.ts | 5 +-- server/src/validation/joi.ts | 4 +-- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/client/src/Hooks/useRegisterForm.ts b/client/src/Hooks/useRegisterForm.ts index 3b5189995..99337a28b 100644 --- a/client/src/Hooks/useRegisterForm.ts +++ b/client/src/Hooks/useRegisterForm.ts @@ -7,9 +7,6 @@ export const useRegisterForm = () => { email: "", password: "", confirm: "", - teamId: "", - inviteToken: "", - role: ["superadmin"], }; return { diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 99ef19951..80cee579f 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -8,11 +8,17 @@ import { useTranslation } from "react-i18next"; import { usePost } from "@/Hooks/UseApi"; import { setAuthState } from "@/Features/Auth/authSlice"; import { useDispatch } from "react-redux"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import type { AuthResponse } from "@/Types/User"; +import { useEffect, useRef } from "react"; interface RegisterPayload { user: Omit; + token?: string; +} + +interface InviteVerifyResponse { + email: string; } const RegisterPage = () => { @@ -21,17 +27,41 @@ const RegisterPage = () => { const { post, loading } = usePost(); const dispatch = useDispatch(); const navigate = useNavigate(); + const { token } = useParams<{ token?: string }>(); - const { control, handleSubmit, setError } = useForm({ + const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>(); + const hasVerified = useRef(false); + + const { control, handleSubmit, setError, reset } = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + useEffect(() => { + if (!token || hasVerified.current) return; + hasVerified.current = true; + + verifyToken("/invite/verify", { token }).then((result) => { + if (result?.success && result?.data) { + reset({ + ...defaults, + email: result.data.email ?? "", + }); + } else { + navigate("/register", { replace: true }); + } + }); + }, [token]); + const onSubmit = async (data: RegisterFormData) => { if (loading) return; const { confirm, ...userData } = data; - const result = await post("/auth/register", { user: userData }); + const payload: RegisterPayload = { user: userData }; + if (token) { + payload.token = token; + } + const result = await post("/auth/register", payload); if (result?.success) { dispatch(setAuthState(result)); @@ -82,6 +112,7 @@ const RegisterPage = () => { render={({ field, fieldState }) => ( { } + element={ + <> + + + + + } /> specialCharPattern.test(val), "Password must contain a special character" ), - teamId: z.string().optional(), - inviteToken: z.string().optional(), confirm: z.string().min(1, "Please confirm your password"), - role: z.array(z.string()).min(1, "Role is required"), }) .refine((data) => data.password === data.confirm, { message: "Passwords do not match", diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 315c62bb0..281c03ab7 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -87,10 +87,10 @@ class UserService { const superAdminExists = await this.usersRepository.findSuperAdmin(); if (superAdminExists) { const invite = await this.invitesRepository.findByTokenAndDelete(inviteToken); - user.role = invite.role; + user.role = invite.role ?? ["user"]; user.teamId = invite.teamId; + user.email = invite.email; } else { - console.log("WHITFUSKALG"); // This is the first account, create JWT secret to use if one is not supplied by env const jwtSecret = this.crypto.randomBytes(64).toString("hex"); await this.settingsRepository.update({ jwtSecret }); @@ -100,6 +100,7 @@ class UserService { } const team = await this.teamsRepository.create(user.email); user.teamId = team.id; + user.role = ["superadmin"]; } const newUser = await this.usersRepository.create({ ...user }, file); diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 49f919a4a..c1e945961 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -51,9 +51,7 @@ const registrationBodyValidation = joi.object({ }), password: joi.string().min(8).required().pattern(passwordPattern), profileImage: joi.any(), - role: joi.array().items(joi.string().valid("superadmin", "admin", "user", "demo")), - teamId: joi.string().allow("").required(), - inviteToken: joi.string().allow("").required(), + inviteToken: joi.string().allow("").optional(), }); const editUserBodyValidation = joi.object({ From 734ec41ca0234acff2477b0d3ffd114c3a4277bd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 6 Feb 2026 23:37:25 +0000 Subject: [PATCH 207/392] cleanup --- client/src/Pages/Auth/Register/old.jsx | 330 ------------------ .../src/Pages/Auth/components/AuthHeader.jsx | 49 --- .../Pages/Auth/components/AuthPageWrapper.jsx | 107 ------ client/src/Routes/index.jsx | 8 +- 4 files changed, 1 insertion(+), 493 deletions(-) delete mode 100644 client/src/Pages/Auth/Register/old.jsx delete mode 100644 client/src/Pages/Auth/components/AuthHeader.jsx delete mode 100644 client/src/Pages/Auth/components/AuthPageWrapper.jsx diff --git a/client/src/Pages/Auth/Register/old.jsx b/client/src/Pages/Auth/Register/old.jsx deleted file mode 100644 index 821c56427..000000000 --- a/client/src/Pages/Auth/Register/old.jsx +++ /dev/null @@ -1,330 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Button from "@mui/material/Button"; -import Box from "@mui/material/Box"; -import PasswordTooltip from "../components/PasswordTooltip.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import { useDispatch, useSelector } from "react-redux"; -import { useParams } from "react-router-dom"; -import { networkService } from "../../../main.jsx"; -import { newOrChangedCredentials } from "../../../Validation/validation.js"; -import { register } from "../../../Features/Auth/authSlice.js"; -import AuthPageWrapper from "../components/AuthPageWrapper.jsx"; -import { createToast } from "../../../Utils/toastUtils.jsx"; -import PropTypes from "prop-types"; - -const getFeedbackStatus = (form, errors, field, criteria) => { - const fieldErrors = errors?.[field]; - const isFieldEmpty = form?.[field]?.length === 0; - const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty"); - const isCorrect = !isFieldEmpty && !hasError; - - if (isCorrect) { - return "success"; - } else if (hasError) { - return "error"; - } else { - return "info"; - } -}; - -const Register = ({ superAdminExists }) => { - // Redux - const { isLoading } = useSelector((state) => state.auth); - - // Local state - const [form, setForm] = useState({ - firstName: "", - lastName: "", - email: "", - password: "", - confirm: "", - role: [], - teamId: "", - }); - - const [errors, setErrors] = useState({}); - const [feedback, setFeedback] = useState({}); - - // Hooks - const theme = useTheme(); - const { t } = useTranslation(); - const { token } = useParams(); - const navigate = useNavigate(); - const dispatch = useDispatch(); - - // Effects - useEffect(() => { - const fetchInvite = async () => { - if (token !== undefined) { - try { - const res = await networkService.verifyInvitationToken(token); - const invite = res.data.data; - const { email } = invite; - setForm((prevForm) => { - if (!prevForm.email) { - return { ...prevForm, email }; - } - return prevForm; - }); - } catch (error) { - navigate("/register", { replace: true }); - } - } - }; - fetchInvite(); - }, [form, token, navigate]); - - // Handlers - const onChange = (e) => { - let { name, value } = e.target; - if (name === "email") value = value.toLowerCase(); - const updatedForm = { ...form, [name]: value }; - - const { error } = newOrChangedCredentials.validate( - { [name]: value }, - { abortEarly: false, context: { password: form.password } } - ); - - setErrors((prev) => ({ - ...prev, - [name]: error?.details?.[0]?.message || "", - })); - - setForm(updatedForm); - }; - - const onPasswordChange = (e) => { - const { name, value } = e.target; - const updatedForm = { ...form, [name]: value }; - setForm(updatedForm); - const validateValue = { [name]: value }; - const validateOptions = { abortEarly: false, context: { password: form.password } }; - if (name === "password" && form.confirm.length > 0) { - validateValue.confirm = form.confirm; - validateOptions.context = { password: value }; - } else if (name === "confirm") { - validateValue.password = form.password; - } - const { error } = newOrChangedCredentials.validate(validateValue, validateOptions); - - const pwdErrors = error?.details.map((error) => ({ - path: error.path[0], - type: error.type, - })); - - const errorsByPath = - pwdErrors && - pwdErrors.reduce((acc, { path, type }) => { - if (!acc[path]) { - acc[path] = []; - } - acc[path].push(type); - return acc; - }, {}); - - const oldErrors = { ...errors }; - if (name === "password") { - oldErrors.password = undefined; - } else if (name === "confirm") { - oldErrors.confirm = undefined; - } - const newErrors = { ...oldErrors, ...errorsByPath }; - - setErrors(newErrors); - - const newFeedback = { - length: getFeedbackStatus(updatedForm, errorsByPath, "password", "string.min"), - special: getFeedbackStatus(updatedForm, errorsByPath, "password", "special"), - number: getFeedbackStatus(updatedForm, errorsByPath, "password", "number"), - uppercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "uppercase"), - lowercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "lowercase"), - confirm: getFeedbackStatus(updatedForm, errorsByPath, "confirm", "different"), - }; - - setFeedback(newFeedback); - }; - - const onSubmit = async (e) => { - e.preventDefault(); - const toSubmit = { - ...form, - role: superAdminExists ? form.role : ["superadmin"], - inviteToken: token ? token : "", - }; - - const { error } = newOrChangedCredentials.validate(toSubmit, { - abortEarly: false, - context: { password: form.password }, - }); - - if (error) { - const formErrors = {}; - for (const err of error.details) { - formErrors[err.path[0]] = err.message; - } - setErrors(formErrors); - return; - } - - delete toSubmit.confirm; - - const action = await dispatch(register({ user: toSubmit, token })); - if (action.payload.success) { - navigate("/uptime"); - createToast({ - body: t("auth.registration.toasts.success"), - }); - } else { - if (action.payload) { - createToast({ - body: action.payload.msg, - }); - } else { - createToast({ - body: t("common.toasts.unknownError"), - }); - } - } - }; - - return ( - - - - {superAdminExists - ? t("auth.registration.description.user") - : t("auth.registration.description.superAdmin")} - - - - - - (e.target.value = e.target.value.toLowerCase())} - onChange={onChange} - error={errors.email ? true : false} - helperText={errors.email ? t(errors.email) : ""} // Localization keys are in validation.js - /> - - - - - - - - - - ); -}; - -Register.propTypes = { - superAdminExists: PropTypes.bool, -}; - -export default Register; diff --git a/client/src/Pages/Auth/components/AuthHeader.jsx b/client/src/Pages/Auth/components/AuthHeader.jsx deleted file mode 100644 index 56b69ef14..000000000 --- a/client/src/Pages/Auth/components/AuthHeader.jsx +++ /dev/null @@ -1,49 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import LanguageSelector from "@/Components/LanguageSelector.jsx"; -import ThemeSwitch from "@/Components/v1/ThemeSwitch/index.jsx"; - -// Utils -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; - -const AuthHeader = ({ hideLogo = false }) => { - // Hooks - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - {!hideLogo && ( - <> - - {t("common.appName")} - - )} - - - - - - - ); -}; - -export default AuthHeader; diff --git a/client/src/Pages/Auth/components/AuthPageWrapper.jsx b/client/src/Pages/Auth/components/AuthPageWrapper.jsx deleted file mode 100644 index a5fbe311b..000000000 --- a/client/src/Pages/Auth/components/AuthPageWrapper.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import Background from "@/assets/Images/background-grid.svg?react"; -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import AuthHeader from "./AuthHeader.jsx"; -import { useTheme } from "@mui/material/styles"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import PropTypes from "prop-types"; - -const AuthPageWrapper = ({ children, heading, welcome }) => { - const theme = useTheme(); - return ( - - - - - - - - - - - - - - - - {welcome} - - {heading} - - {children} - - - ); -}; - -export default AuthPageWrapper; - -AuthPageWrapper.propTypes = { - children: PropTypes.node, - heading: PropTypes.node, - welcome: PropTypes.node, -}; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 71058c0c0..936258595 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -10,7 +10,6 @@ import NotFound from "../Pages/NotFound/index.jsx"; // Auth import AuthLogin from "../Pages/Auth/Login"; import AuthRegister from "@/Pages/Auth/Register"; -import OldAuthRegister from "../Pages/Auth/Register/old.jsx"; import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx"; @@ -63,8 +62,7 @@ import CreateMonitor from "@/Pages/CreateMonitor"; const Routes = () => { const mode = useSelector((state) => state.ui.mode); - // const AdminCheckedRegister = withAdminCheck(AuthRegister); - const AdminCheckedRegister = AuthRegister; + const AdminCheckedRegister = withAdminCheck(AuthRegister); const v2theme = mode === "light" ? lightTheme : darkTheme; return ( @@ -382,10 +380,6 @@ const Routes = () => { } /> - } - /> Date: Sat, 7 Feb 2026 00:49:49 +0000 Subject: [PATCH 208/392] initial commit --- client/src/Pages/Account/TabPassword.tsx | 5 +++ client/src/Pages/Account/TabProfile.tsx | 44 +++++++++++++++++++ client/src/Pages/Account/TabTeam.tsx | 5 +++ client/src/Pages/Account/index.tsx | 39 ++++++++++++++++ .../src/Pages/Account/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 20 +++++++-- client/src/locales/en.json | 31 +++++++++++++ 7 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 client/src/Pages/Account/TabPassword.tsx create mode 100644 client/src/Pages/Account/TabProfile.tsx create mode 100644 client/src/Pages/Account/TabTeam.tsx create mode 100644 client/src/Pages/Account/index.tsx rename client/src/Pages/Account/{index.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx new file mode 100644 index 000000000..0acecd35a --- /dev/null +++ b/client/src/Pages/Account/TabPassword.tsx @@ -0,0 +1,5 @@ +import { Box } from "@mui/material"; + +export const TabPassword = () => { + return {/* TODO: Implement TabPassword */}; +}; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx new file mode 100644 index 000000000..5cc21cb9a --- /dev/null +++ b/client/src/Pages/Account/TabProfile.tsx @@ -0,0 +1,44 @@ +import { Stack } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { ConfigBox } from "@/Components/v2/design-elements"; +import { TextField, Button } from "@/Components/v2/inputs"; +import { ImageUpload } from "@/Components/v2/inputs"; + +export const TabProfile = () => { + const { t } = useTranslation(); + + return ( + + + + + + } + /> + } + /> + + + ); +}; diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx new file mode 100644 index 000000000..0babcad86 --- /dev/null +++ b/client/src/Pages/Account/TabTeam.tsx @@ -0,0 +1,5 @@ +import { Box } from "@mui/material"; + +export const TabTeam = () => { + return {/* TODO: Implement TabTeam */}; +}; diff --git a/client/src/Pages/Account/index.tsx b/client/src/Pages/Account/index.tsx new file mode 100644 index 000000000..b0fd6554e --- /dev/null +++ b/client/src/Pages/Account/index.tsx @@ -0,0 +1,39 @@ +import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TabProfile } from "./TabProfile"; +import { TabPassword } from "./TabPassword"; +import { TabTeam } from "./TabTeam"; + +interface AccountProps { + open?: "profile" | "password" | "team"; +} + +const TAB_MAP = { + profile: 0, + password: 1, + team: 2, +} as const; + +const Account = ({ open = "profile" }: AccountProps) => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(TAB_MAP[open]); + + return ( + + setActiveTab(newValue)} + > + + + + + {activeTab === 0 && } + {activeTab === 1 && } + {activeTab === 2 && } + + ); +}; + +export default Account; diff --git a/client/src/Pages/Account/index.jsx b/client/src/Pages/Account/old.jsx similarity index 100% rename from client/src/Pages/Account/index.jsx rename to client/src/Pages/Account/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 936258595..282a5db56 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -45,7 +45,7 @@ import Notifications from "../Pages/Notifications"; import CreateNotifications from "../Pages/Notifications/create"; // Settings -import Account from "../Pages/Account/index.jsx"; +import Account from "@/Pages/Account"; import EditUser from "../Pages/Account/EditUser/index.jsx"; import Settings from "../Pages/Settings"; @@ -328,15 +328,27 @@ const Routes = () => { /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> Date: Sat, 7 Feb 2026 00:51:04 +0000 Subject: [PATCH 209/392] fix strings --- client/src/Pages/Account/TabProfile.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 5cc21cb9a..6fccfc851 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -10,26 +10,26 @@ export const TabProfile = () => { return ( } /> } /> + + + + + ); +}; diff --git a/client/src/Hooks/useProfileForm.ts b/client/src/Hooks/useProfileForm.ts new file mode 100644 index 000000000..e426f904e --- /dev/null +++ b/client/src/Hooks/useProfileForm.ts @@ -0,0 +1,14 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { profileSchema, type ProfileFormData } from "@/Validation/profile"; + +export const useProfileForm = () => { + return { + resolver: zodResolver(profileSchema), + defaults: { + firstName: "", + lastName: "", + profileImage: null, + deleteProfileImage: false, + } as ProfileFormData, + }; +}; diff --git a/client/src/Validation/profile.ts b/client/src/Validation/profile.ts new file mode 100644 index 000000000..56301c31f --- /dev/null +++ b/client/src/Validation/profile.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +const NAME_PATTERN = /^[A-Za-z'-]+$/; + +export const profileSchema = z.object({ + firstName: z + .string() + .min(1, "First name is required") + .max(50, "First name must be 50 characters or less") + .regex(NAME_PATTERN, "First name can only contain letters, hyphens, and apostrophes"), + lastName: z + .string() + .min(1, "Last name is required") + .max(50, "Last name must be 50 characters or less") + .regex(NAME_PATTERN, "Last name can only contain letters, hyphens, and apostrophes"), + profileImage: z.instanceof(File).optional().nullable(), + deleteProfileImage: z.boolean().optional(), +}); + +export type ProfileFormData = z.infer; From 4945b293340d6bc5cdd1a9bfa945b11984ba865a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 01:13:58 +0000 Subject: [PATCH 211/392] wire up form --- client/src/Features/Auth/authSlice.js | 5 +- client/src/Pages/Account/TabProfile.tsx | 181 +++++++++++++++++++----- client/src/Types/state.ts | 4 +- 3 files changed, 156 insertions(+), 34 deletions(-) diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js index 1d93ce3fa..d6c63b6a9 100644 --- a/client/src/Features/Auth/authSlice.js +++ b/client/src/Features/Auth/authSlice.js @@ -203,6 +203,9 @@ const authSlice = createSlice({ state.authToken = action.payload.data.token; state.user = action.payload.data.user; }, + setUser: (state, action) => { + state.user = action.payload; + }, }, extraReducers: (builder) => { // Register thunk @@ -256,4 +259,4 @@ const authSlice = createSlice({ }); export default authSlice.reducer; -export const { clearAuthState, setAuthState } = authSlice.actions; +export const { clearAuthState, setAuthState, setUser } = authSlice.actions; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 6fccfc851..9108e8c32 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,44 +1,161 @@ -import { Stack } from "@mui/material"; +import { useState } from "react"; +import { Stack, Box } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { useSelector, useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; +import { useProfileForm } from "@/Hooks/useProfileForm"; +import { usePatch, useDelete } from "@/Hooks/UseApi"; +import { setUser, clearAuthState } from "@/Features/Auth/authSlice"; +import type { ProfileFormData } from "@/Validation/profile"; +import type { RootState } from "@/Types/state"; +import type { User } from "@/Types/User"; export const TabProfile = () => { const { t } = useTranslation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const user = useSelector((state: RootState) => state.auth?.user); + const { resolver, defaults } = useProfileForm(); + const { patch, loading: patchLoading } = usePatch(); + const { deleteFn, loading: deleteLoading } = useDelete(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { control, handleSubmit, setValue, watch } = useForm({ + resolver, + defaultValues: { + firstName: user?.firstName ?? defaults.firstName, + lastName: user?.lastName ?? defaults.lastName, + profileImage: defaults.profileImage, + deleteProfileImage: defaults.deleteProfileImage, + }, + }); + + const currentImage = watch("profileImage"); + const deleteImage = watch("deleteProfileImage"); + + const getCurrentImageSrc = () => { + if (deleteImage) return undefined; + if (currentImage) return URL.createObjectURL(currentImage); + if (user?.avatarImage) return `data:image/png;base64,${user.avatarImage}`; + return undefined; + }; + + const handleImageChange = ( + fileObj: { src: string; name: string; file: File } | undefined + ) => { + if (fileObj) { + setValue("profileImage", fileObj.file); + setValue("deleteProfileImage", false); + } else { + setValue("profileImage", null); + setValue("deleteProfileImage", true); + } + }; + + const onSubmit = async (data: ProfileFormData) => { + const fd = new FormData(); + fd.append("firstName", data.firstName); + fd.append("lastName", data.lastName); + + if (data.profileImage) { + fd.append("profileImage", data.profileImage); + } + + if (data.deleteProfileImage) { + fd.append("deleteProfileImage", "true"); + } + + console.log(fd); + + // const result = await patch("/auth/user", fd); + // if (result?.success && result.data) { + // dispatch(setUser(result.data)); + // } + }; + + const handleDeleteAccount = async () => { + const result = await deleteFn("/auth/user"); + if (result?.success) { + dispatch(clearAuthState()); + navigate("/login"); + } + }; + + const showDeleteSection = !user?.role?.includes("demo"); return ( - - - - - - } - /> - } - /> - - + + + ( + + )} + /> + ( + + )} + /> + + } + /> + + } + /> + + + + ); }; diff --git a/client/src/Types/state.ts b/client/src/Types/state.ts index 1575fffb8..b5bfcfdb8 100644 --- a/client/src/Types/state.ts +++ b/client/src/Types/state.ts @@ -1,7 +1,9 @@ +import type { User } from "./User"; + export interface AuthState { isLoading: boolean; authToken: string; - user: string; + user: User | null; success: boolean | null; msg: string | null; } From 4f4203dbb32f4746f870b7f5241a37d2caaa35b5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 19:59:28 +0000 Subject: [PATCH 212/392] remove delete --- client/src/Pages/Account/TabProfile.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 9108e8c32..8a9d04068 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -77,16 +77,6 @@ export const TabProfile = () => { // } }; - const handleDeleteAccount = async () => { - const result = await deleteFn("/auth/user"); - if (result?.success) { - dispatch(clearAuthState()); - navigate("/login"); - } - }; - - const showDeleteSection = !user?.role?.includes("demo"); - return ( <> Date: Sat, 7 Feb 2026 20:11:30 +0000 Subject: [PATCH 213/392] submit --- .../Components/v2/design-elements/index.tsx | 1 + client/src/Pages/Account/TabProfile.tsx | 19 ++++++------------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index b7b1d484e..ebf78c61f 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -19,3 +19,4 @@ export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; +export * from "./ConfirmDialog"; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 8a9d04068..540a5b8b5 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,15 +1,13 @@ -import { useState } from "react"; import { Stack, Box } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useForm, Controller } from "react-hook-form"; import { useSelector, useDispatch } from "react-redux"; -import { useNavigate } from "react-router-dom"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; import { useProfileForm } from "@/Hooks/useProfileForm"; -import { usePatch, useDelete } from "@/Hooks/UseApi"; -import { setUser, clearAuthState } from "@/Features/Auth/authSlice"; +import { usePatch } from "@/Hooks/UseApi"; +import { setUser } from "@/Features/Auth/authSlice"; import type { ProfileFormData } from "@/Validation/profile"; import type { RootState } from "@/Types/state"; import type { User } from "@/Types/User"; @@ -17,12 +15,9 @@ import type { User } from "@/Types/User"; export const TabProfile = () => { const { t } = useTranslation(); const dispatch = useDispatch(); - const navigate = useNavigate(); const user = useSelector((state: RootState) => state.auth?.user); const { resolver, defaults } = useProfileForm(); const { patch, loading: patchLoading } = usePatch(); - const { deleteFn, loading: deleteLoading } = useDelete(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { control, handleSubmit, setValue, watch } = useForm({ resolver, @@ -69,12 +64,10 @@ export const TabProfile = () => { fd.append("deleteProfileImage", "true"); } - console.log(fd); - - // const result = await patch("/auth/user", fd); - // if (result?.success && result.data) { - // dispatch(setUser(result.data)); - // } + const result = await patch("/auth/user", fd); + if (result?.success && result.data) { + dispatch(setUser(result.data)); + } }; return ( From f2b740a8f910f8713582b32f86c6015195ef7f82 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:17:59 +0000 Subject: [PATCH 214/392] put -> patch --- .../v2/design-elements/ConfirmDialog.tsx | 105 ------------------ .../Components/v2/design-elements/index.tsx | 1 - client/src/Utils/NetworkService.js | 2 +- server/src/routes/authRoute.ts | 6 +- 4 files changed, 4 insertions(+), 110 deletions(-) delete mode 100644 client/src/Components/v2/design-elements/ConfirmDialog.tsx diff --git a/client/src/Components/v2/design-elements/ConfirmDialog.tsx b/client/src/Components/v2/design-elements/ConfirmDialog.tsx deleted file mode 100644 index 1fe4d7b89..000000000 --- a/client/src/Components/v2/design-elements/ConfirmDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useId } from "react"; -import { Modal, Stack, Typography, Box } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import { Button } from "@/Components/v2/inputs"; -import { useTranslation } from "react-i18next"; - -interface ConfirmDialogProps { - open: boolean; - onClose: () => void; - title: string; - description?: string; - onConfirm: () => void; - confirmText?: string; - cancelText?: string; - isLoading?: boolean; - confirmColor?: "error" | "primary" | "secondary" | "success" | "warning" | "info"; -} - -export const ConfirmDialog = ({ - open, - onClose, - title, - description, - onConfirm, - confirmText, - cancelText, - isLoading = false, - confirmColor = "error", -}: ConfirmDialogProps) => { - const theme = useTheme(); - const { t } = useTranslation(); - const titleId = useId(); - const descriptionId = useId(); - - return ( - e.stopPropagation()} - > - - - {title} - - {description && ( - - {description} - - )} - - - - - - - ); -}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index ebf78c61f..b7b1d484e 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -19,4 +19,3 @@ export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; -export * from "./ConfirmDialog"; diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index d9b151a10..7f6a8e455 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -374,7 +374,7 @@ class NetworkService { * @param {Object} config - The configuration object. * @param {string} config.userId - The ID of the user to be updated. * @param {Object} config.form - The form data for the user to be updated. - * @returns {Promise} The response from the axios PUT request. + * @returns {Promise} The response from the axios PATCH request. * */ async updateUser(config) { diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index eec6ea11b..97e85d61f 100755 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -26,10 +26,10 @@ class AuthRoutes { this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById); - this.router.put("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); - this.router.put("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); + this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); + this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); - this.router.put("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser); + this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser); this.router.delete("/user", verifyJWT, this.authController.deleteUser); } From af4c9d8f91c7398951e032d05b5daf17917e35b0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:33:11 +0000 Subject: [PATCH 215/392] fix unset --- client/src/Pages/Account/TabProfile.tsx | 137 +++++++++--------- server/src/controllers/authController.ts | 1 - .../users/MongoUsersRepository.ts | 20 +-- server/src/validation/joi.ts | 2 +- 4 files changed, 78 insertions(+), 82 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 540a5b8b5..6b05e0082 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,10 +1,12 @@ import { Stack, Box } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; -import { useSelector, useDispatch } from "react-redux"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { useSelector, useDispatch } from "react-redux"; import { useProfileForm } from "@/Hooks/useProfileForm"; import { usePatch } from "@/Hooks/UseApi"; import { setUser } from "@/Features/Auth/authSlice"; @@ -13,6 +15,7 @@ import type { RootState } from "@/Types/state"; import type { User } from "@/Types/User"; export const TabProfile = () => { + const theme = useTheme(); const { t } = useTranslation(); const dispatch = useDispatch(); const user = useSelector((state: RootState) => state.auth?.user); @@ -71,74 +74,66 @@ export const TabProfile = () => { }; return ( - <> - + + ( + + )} + /> + ( + + )} + /> + + } + /> + + } + /> + - - - + {t("common.buttons.save")} + + ); }; diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 165bec056..d6fe6aeff 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -74,7 +74,6 @@ class AuthController { editUser = async (req: Request, res: Response, next: NextFunction) => { try { await editUserBodyValidation.validateAsync(req.body); - const updatedUser = await this.userService.editUser(req.body, req.file, req.user); res.status(200).json({ diff --git a/server/src/repositories/users/MongoUsersRepository.ts b/server/src/repositories/users/MongoUsersRepository.ts index 023e8aefb..fde134a33 100644 --- a/server/src/repositories/users/MongoUsersRepository.ts +++ b/server/src/repositories/users/MongoUsersRepository.ts @@ -98,10 +98,11 @@ class MongoUsersRepository implements IUsersRepository { updateById = async (id: string, patch: Partial, file?: Express.Multer.File | null): Promise => { const candidateUser = { ...patch }; + let unsetFields: Record | undefined; if (ParseBoolean(candidateUser.deleteProfileImage) === true) { - candidateUser.profileImage = undefined; - candidateUser.avatarImage = undefined; + unsetFields = { profileImage: 1, avatarImage: 1 }; + delete candidateUser.deleteProfileImage; } else if (file) { // 1. Save the full size image candidateUser.profileImage = { @@ -114,13 +115,14 @@ class MongoUsersRepository implements IUsersRepository { candidateUser.avatarImage = avatar; } - const updatedUser = await UserModel.findOneAndUpdate( - { _id: id }, - candidateUser, - { new: true } // Returns updated user instead of pre-update user - ) - .select("-password") - .select("-profileImage"); + delete candidateUser.deleteProfileImage; + + const updateQuery: Record = { $set: candidateUser }; + if (unsetFields) { + updateQuery.$unset = unsetFields; + } + + const updatedUser = await UserModel.findOneAndUpdate({ _id: id }, updateQuery, { new: true }).select("-password").select("-profileImage"); if (!updatedUser) { throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); } diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index c1e945961..f72addc5e 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -60,7 +60,7 @@ const editUserBodyValidation = joi.object({ profileImage: joi.any(), newPassword: joi.string().min(8).pattern(passwordPattern), password: joi.string().min(8).pattern(passwordPattern), - deleteProfileImage: joi.boolean(), + deleteProfileImage: joi.alternatives().try(joi.boolean(), joi.string().valid("true", "false")), }); const recoveryValidation = joi.object({ From b6004241e7cb72504e8d51ef127168318f92e076 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:54:17 +0000 Subject: [PATCH 216/392] validation --- client/src/Hooks/usePasswordForm.ts | 13 +++++++ client/src/Pages/Account/TabPassword.tsx | 46 ++++++++++++++++++++++-- client/src/Pages/Account/TabProfile.tsx | 2 +- client/src/Validation/password.ts | 25 +++++++++++++ client/src/Validation/patterns.ts | 4 +++ client/src/Validation/profile.ts | 7 ++-- client/src/Validation/register.ts | 4 +-- client/src/locales/en.json | 22 ++++++++++++ 8 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 client/src/Hooks/usePasswordForm.ts create mode 100644 client/src/Validation/password.ts create mode 100644 client/src/Validation/patterns.ts diff --git a/client/src/Hooks/usePasswordForm.ts b/client/src/Hooks/usePasswordForm.ts new file mode 100644 index 000000000..d38f73abf --- /dev/null +++ b/client/src/Hooks/usePasswordForm.ts @@ -0,0 +1,13 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { passwordSchema, type PasswordFormData } from "@/Validation/password"; + +export const usePasswordForm = () => { + return { + resolver: zodResolver(passwordSchema), + defaults: { + currentPassword: "", + newPassword: "", + confirm: "", + } as PasswordFormData, + }; +}; diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index 0acecd35a..cd07410ac 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,5 +1,47 @@ -import { Box } from "@mui/material"; +import { Stack } from "@mui/material"; +import { ConfigBox } from "@/Components/v2/design-elements"; +import { TextField } from "@/Components/v2/inputs"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; export const TabPassword = () => { - return {/* TODO: Implement TabPassword */}; + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + + + } + /> + + + + + } + /> + + ); }; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 6b05e0082..8ea8a50a0 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,4 +1,4 @@ -import { Stack, Box } from "@mui/material"; +import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/v2/design-elements"; import { TextField, Button } from "@/Components/v2/inputs"; import { ImageUpload } from "@/Components/v2/inputs"; diff --git a/client/src/Validation/password.ts b/client/src/Validation/password.ts new file mode 100644 index 000000000..c287fcb25 --- /dev/null +++ b/client/src/Validation/password.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { specialCharPattern } from "@/Validation/patterns"; + +export const passwordSchema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z + .string() + .min(1, "New password is required") + .min(8, "Password must be at least 8 characters") + .refine((val) => /[A-Z]/.test(val), "Password must contain an uppercase letter") + .refine((val) => /[a-z]/.test(val), "Password must contain a lowercase letter") + .refine((val) => /\d/.test(val), "Password must contain a number") + .refine( + (val) => specialCharPattern.test(val), + "Password must contain a special character" + ), + confirm: z.string().min(1, "Please confirm your new password"), + }) + .refine((data) => data.newPassword === data.confirm, { + message: "Passwords do not match", + path: ["confirm"], + }); + +export type PasswordFormData = z.infer; diff --git a/client/src/Validation/patterns.ts b/client/src/Validation/patterns.ts new file mode 100644 index 000000000..d1e20fa73 --- /dev/null +++ b/client/src/Validation/patterns.ts @@ -0,0 +1,4 @@ +export const specialCharPattern = /[!?@#$%^&*()\-_=+[\]{};:'",.<>~`|\\/]/; + +// Unicode-aware name pattern (allows letters, marks, apostrophes, parentheses, hyphens, periods, spaces) +export const namePattern = /^[\p{L}\p{M}''()\-\. ]+$/u; diff --git a/client/src/Validation/profile.ts b/client/src/Validation/profile.ts index 56301c31f..eca0ddf5c 100644 --- a/client/src/Validation/profile.ts +++ b/client/src/Validation/profile.ts @@ -1,18 +1,17 @@ import { z } from "zod"; - -const NAME_PATTERN = /^[A-Za-z'-]+$/; +import { namePattern } from "@/Validation/patterns"; export const profileSchema = z.object({ firstName: z .string() .min(1, "First name is required") .max(50, "First name must be 50 characters or less") - .regex(NAME_PATTERN, "First name can only contain letters, hyphens, and apostrophes"), + .regex(namePattern, "First name contains invalid characters"), lastName: z .string() .min(1, "Last name is required") .max(50, "Last name must be 50 characters or less") - .regex(NAME_PATTERN, "Last name can only contain letters, hyphens, and apostrophes"), + .regex(namePattern, "Last name contains invalid characters"), profileImage: z.instanceof(File).optional().nullable(), deleteProfileImage: z.boolean().optional(), }); diff --git a/client/src/Validation/register.ts b/client/src/Validation/register.ts index 001dfa56a..d76c5f37e 100644 --- a/client/src/Validation/register.ts +++ b/client/src/Validation/register.ts @@ -1,7 +1,5 @@ import { z } from "zod"; - -const namePattern = /^[\p{L}\p{M}''()\-\. ]+$/u; -const specialCharPattern = /[!?@#$%^&*()\-_=+[\]{};:'",.<>~`|\\/]/; +import { specialCharPattern, namePattern } from "@/Validation/patterns"; export const registerSchema = z .object({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 21a8e8e0a..451c59e17 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -523,6 +523,28 @@ "title": "Profile photo", "description": "Upload a profile picture" }, + "currentPassword": { + "title": "Current password", + "description": "Enter your current password to verify your identity", + "option": { + "label": "Current password", + "placeholder": "Enter current password" + } + }, + "newPassword": { + "title": "New password", + "description": "Choose a strong password with at least 8 characters", + "option": { + "newPassword": { + "label": "New password", + "placeholder": "Enter new password" + }, + "confirm": { + "label": "Confirm password", + "placeholder": "Confirm new password" + } + } + }, "deleteAccount": { "title": "Delete account", "description": "This action is permanent and cannot be undone" From 021b4e8ae50a72c7418a727cc4fb5f8c727358b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 20:57:03 +0000 Subject: [PATCH 217/392] controllers --- client/src/Pages/Account/TabPassword.tsx | 89 ++++++++++++++++++++---- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index cd07410ac..cdf7ce4c4 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,24 +1,51 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/v2/design-elements"; -import { TextField } from "@/Components/v2/inputs"; +import { TextField, Button } from "@/Components/v2/inputs"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; +import { useForm, Controller } from "react-hook-form"; +import { usePasswordForm } from "@/Hooks/usePasswordForm"; +import type { PasswordFormData } from "@/Validation/password"; export const TabPassword = () => { const { t } = useTranslation(); const theme = useTheme(); + const { resolver, defaults } = usePasswordForm(); + + const { control, handleSubmit } = useForm({ + resolver, + defaultValues: defaults, + }); + + const onSubmit = (data: PasswordFormData) => { + console.log("Password form data:", data); + }; return ( - + ( + + )} /> } /> @@ -27,21 +54,53 @@ export const TabPassword = () => { subtitle={t("pages.account.form.newPassword.description")} rightContent={ - ( + )} /> - ( + + )} /> } /> + ); }; From 7e527b8eb211fbc19a7ba2318945157176fd3864 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 21:05:45 +0000 Subject: [PATCH 218/392] submit --- client/src/Pages/Account/TabPassword.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index cdf7ce4c4..6adcb99c4 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -6,20 +6,29 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; import { useForm, Controller } from "react-hook-form"; import { usePasswordForm } from "@/Hooks/usePasswordForm"; +import { usePatch } from "@/Hooks/UseApi"; import type { PasswordFormData } from "@/Validation/password"; export const TabPassword = () => { const { t } = useTranslation(); const theme = useTheme(); const { resolver, defaults } = usePasswordForm(); + const { patch, loading } = usePatch(); - const { control, handleSubmit } = useForm({ + const { control, handleSubmit, reset, setError } = useForm({ resolver, defaultValues: defaults, }); - const onSubmit = (data: PasswordFormData) => { - console.log("Password form data:", data); + const onSubmit = async (data: PasswordFormData) => { + const fd = new FormData(); + fd.append("password", data.currentPassword); + fd.append("newPassword", data.newPassword); + + const result = await patch("/auth/user", fd); + if (result?.success) { + reset(); + } }; return ( @@ -97,6 +106,7 @@ export const TabPassword = () => { type="submit" variant="contained" color="primary" + loading={loading} sx={{ alignSelf: "flex-end", minWidth: 100 }} > {t("common.buttons.save")} From 2e3b32462f89081317fc87deaa5a7941dea92840 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 7 Feb 2026 21:15:58 +0000 Subject: [PATCH 219/392] remove mongo hash middleware --- server/src/db/models/User.ts | 18 ------------------ server/src/service/business/userService.ts | 22 +++++++++++++++++----- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/server/src/db/models/User.ts b/server/src/db/models/User.ts index 6aa3835a6..ac3895301 100644 --- a/server/src/db/models/User.ts +++ b/server/src/db/models/User.ts @@ -50,24 +50,6 @@ const UserSchema = new Schema( { timestamps: true } ); -UserSchema.pre("save", function (next) { - if (!this.isModified("password")) { - return next(); - } - const salt = bcrypt.genSaltSync(10); - this.password = bcrypt.hashSync(this.password, salt); - next(); -}); - -UserSchema.pre("findOneAndUpdate", function (next) { - const update = this.getUpdate(); - if (update && "password" in update) { - const salt = bcrypt.genSaltSync(10); - (update as any).password = bcrypt.hashSync((update as any).password, salt); - } - next(); -}); - UserSchema.pre("findOneAndDelete", async function (next) { try { const userToDelete = await this.model.findOne(this.getFilter()); diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 281c03ab7..39b05a737 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -16,6 +16,11 @@ const SERVICE_NAME = "userService"; class UserService { static SERVICE_NAME = SERVICE_NAME; + private hashPassword = (password: string): string => { + const salt = bcrypt.genSaltSync(10); + return bcrypt.hashSync(password, salt); + }; + private emailService: any; private settingsService: any; private logger: any; @@ -103,7 +108,12 @@ class UserService { user.role = ["superadmin"]; } - const newUser = await this.usersRepository.create({ ...user }, file); + // Hash password before storing + if (user.password) { + user.password = this.hashPassword(user.password); + } + + const newUser = await this.usersRepository.create(user, file); this.logger.debug({ message: "New user created", @@ -182,7 +192,8 @@ class UserService { throw new AppError({ message: "Incorrect current password", service: SERVICE_NAME, status: 403 }); } // If a match, update the password - updates.password = updates.newPassword; + updates.password = this.hashPassword(updates.newPassword); + delete updates.newPassword; } return await this.usersRepository.updateById(currentUser.id, updates, file); @@ -225,8 +236,8 @@ class UserService { throw new AppError({ message: "New password cannot be same as old password", service: SERVICE_NAME, status: 400 }); } - existingUser.password = password; - await this.usersRepository.updateById(existingUser.id, existingUser, null); + const hashedPassword = this.hashPassword(password); + await this.usersRepository.updateById(existingUser.id, { password: hashedPassword }, null); await this.recoveryTokensRepository.deleteManyByEmail(existingUser.email); existingUser.password = ""; @@ -294,7 +305,8 @@ class UserService { }; setPasswordByUserId = async (userId: any, password: string) => { - const updatedUser = await this.usersRepository.updateById(userId, { password }, null); + const hashedPassword = this.hashPassword(password); + const updatedUser = await this.usersRepository.updateById(userId, { password: hashedPassword }, null); return updatedUser; }; } From 9ca79ea793e434530c5557de0bb8ddfe5b8a05dd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 8 Feb 2026 16:14:35 +0000 Subject: [PATCH 220/392] team initial commit --- client/src/Pages/Account/TabPassword.tsx | 2 +- client/src/Pages/Account/TabTeam.tsx | 30 +++++++- .../Account/components/HeaderTeamControls.tsx | 74 +++++++++++++++++++ .../Pages/Account/components/TeamPanel.jsx | 7 -- .../Pages/Account/components/TeamTable.tsx | 53 +++++++++++++ client/src/locales/en.json | 24 ++++++ 6 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 client/src/Pages/Account/components/HeaderTeamControls.tsx create mode 100644 client/src/Pages/Account/components/TeamTable.tsx diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index 6adcb99c4..85e13fb7e 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -15,7 +15,7 @@ export const TabPassword = () => { const { resolver, defaults } = usePasswordForm(); const { patch, loading } = usePatch(); - const { control, handleSubmit, reset, setError } = useForm({ + const { control, handleSubmit, reset } = useForm({ resolver, defaultValues: defaults, }); diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 0babcad86..48a78033c 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -1,5 +1,31 @@ -import { Box } from "@mui/material"; +import { Stack } from "@mui/material"; +import { useTheme } from "@mui/material"; +import { useState, useMemo } from "react"; +import { HeaderTeamControls } from "./components/HeaderTeamControls"; +import { TeamTable } from "./components/TeamTable"; +import { useGet } from "@/Hooks/UseApi"; +import type { User, UserRole } from "@/Types/User"; export const TabTeam = () => { - return {/* TODO: Implement TabTeam */}; + const theme = useTheme(); + const [filter, setFilter] = useState(""); + + const { data: users } = useGet("/auth/users"); + + const filteredUsers = useMemo(() => { + if (!users) return []; + if (!filter) return users; + + return users.filter((u) => u.role.includes(filter)); + }, [users, filter]); + + return ( + + + + + ); }; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx new file mode 100644 index 000000000..807a153e4 --- /dev/null +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -0,0 +1,74 @@ +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import { Button, Select } from "@/Components/v2/inputs"; +import { Icon } from "@/Components/v2/design-elements"; +import { UserPlus, Mail } from "lucide-react"; + +import { UserRoles } from "@/Types/User"; +import { useTheme } from "@mui/material/styles"; +import type { UserRole } from "@/Types/User"; +import { useTranslation } from "react-i18next"; +import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; + +interface HeaderTeamControlsProps { + filter: UserRole | ""; + onFilterChange: (value: UserRole | "") => void; +} + +export const HeaderTeamControls = ({ + filter, + onFilterChange, +}: HeaderTeamControlsProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + const isSuperAdmin = useIsSuperAdmin(); + + const handleFilterChange = (event: { target: { value: unknown } }) => { + onFilterChange(event.target.value as UserRole | ""); + }; + + return ( + + + {isAdmin && ( + + )} + {isSuperAdmin && ( + + )} + + ); +}; diff --git a/client/src/Pages/Account/components/TeamPanel.jsx b/client/src/Pages/Account/components/TeamPanel.jsx index 6c658cc60..f7892bd01 100644 --- a/client/src/Pages/Account/components/TeamPanel.jsx +++ b/client/src/Pages/Account/components/TeamPanel.jsx @@ -15,13 +15,6 @@ import { useGetInviteToken } from "../../../Hooks/inviteHooks.js"; import { useNavigate } from "react-router-dom"; import { useIsSuperAdmin } from "@/Hooks/useIsAdmin.js"; import AddMemberMenu from "./AddMemberMenu/index.jsx"; -/** - * TeamPanel component manages the organization and team members, - * providing functionalities like renaming the organization, managing team members, - * and inviting new members. - * - * @returns {JSX.Element} - */ const TeamPanel = () => { const theme = useTheme(); diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx new file mode 100644 index 000000000..5c2455bec --- /dev/null +++ b/client/src/Pages/Account/components/TeamTable.tsx @@ -0,0 +1,53 @@ +import Typography from "@mui/material/Typography"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Table } from "@/Components/v2/design-elements"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import { useIsSuperAdmin } from "@/Hooks/useIsAdmin"; +import type { User } from "@/Types/User"; + +interface TeamTableProps { + users: User[]; +} + +export const TeamTable = ({ users }: TeamTableProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isSuperAdmin = useIsSuperAdmin(); + + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => {`${row.firstName} ${row.lastName}`}, + }, + { + id: "email", + content: t("pages.account.team.table.headers.email"), + render: (row) => {row.email}, + }, + { + id: "role", + content: t("pages.account.team.table.headers.role"), + render: (row) => ( + + {row.role.map((r) => t(`common.auth.roles.${r}`)).join(", ")} + + ), + }, + ]; + + const handleRowClick = (row: User) => { + if (isSuperAdmin) { + navigate(`/account/team/${row.id}`); + } + }; + + return ( +
+ ); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 451c59e17..45b329ea0 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -176,6 +176,14 @@ "ClickUpload": "Click to upload", "close": "Close", "common": { + "auth": { + "roles": { + "admin": "Admin", + "demo": "Demo", + "superadmin": "Super admin", + "user": "User" + } + }, "alerts": { "pageSpeedApiKey": { "content": "Warning: You haven't added a Google PageSpeed API key yet. Visit Settings to add one. Without it, the PageSpeed monitor won't function." @@ -549,6 +557,22 @@ "title": "Delete account", "description": "This action is permanent and cannot be undone" } + }, + "team": { + "addMember": "Add member", + "inviteMember": "Invite member", + "filter": { + "placeholder": "Filter by role", + "all": "All roles", + "admin": "Admin", + "member": "Member" + }, + "table": { + "headers": { + "email": "Email", + "role": "Role" + } + } } }, "auth": { From 3fc4160b886880343e7ef81115fc615f0aa9cfd0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 8 Feb 2026 16:23:24 +0000 Subject: [PATCH 221/392] add buttons --- client/src/Pages/Account/TabTeam.tsx | 10 +++ .../Account/components/HeaderTeamControls.tsx | 3 + .../components/InviteTeamMemberDialog.tsx | 72 +++++++++++++++++++ client/src/locales/en.json | 14 ++++ 4 files changed, 99 insertions(+) create mode 100644 client/src/Pages/Account/components/InviteTeamMemberDialog.tsx diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 48a78033c..308becb2a 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -3,12 +3,14 @@ import { useTheme } from "@mui/material"; import { useState, useMemo } from "react"; import { HeaderTeamControls } from "./components/HeaderTeamControls"; import { TeamTable } from "./components/TeamTable"; +import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog"; import { useGet } from "@/Hooks/UseApi"; import type { User, UserRole } from "@/Types/User"; export const TabTeam = () => { const theme = useTheme(); const [filter, setFilter] = useState(""); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const { data: users } = useGet("/auth/users"); @@ -19,13 +21,21 @@ export const TabTeam = () => { return users.filter((u) => u.role.includes(filter)); }, [users, filter]); + const handleOpenInviteDialog = () => setInviteDialogOpen(true); + const handleCloseInviteDialog = () => setInviteDialogOpen(false); + return ( + ); }; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 807a153e4..405e175c0 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -13,11 +13,13 @@ import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; interface HeaderTeamControlsProps { filter: UserRole | ""; onFilterChange: (value: UserRole | "") => void; + onInviteClick?: () => void; } export const HeaderTeamControls = ({ filter, onFilterChange, + onInviteClick, }: HeaderTeamControlsProps) => { const theme = useTheme(); const { t } = useTranslation(); @@ -56,6 +58,7 @@ export const HeaderTeamControls = ({ variant="contained" color="primary" startIcon={} + onClick={onInviteClick} > {t("pages.account.team.inviteMember")} diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx new file mode 100644 index 000000000..8b4fee51f --- /dev/null +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -0,0 +1,72 @@ +import { Stack } from "@mui/material"; +import MenuItem from "@mui/material/MenuItem"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Dialog, TextField, Select, Button } from "@/Components/v2/inputs"; +import type { UserRole } from "@/Types/User"; + +interface InviteTeamMemberDialogProps { + open: boolean; + onClose: () => void; +} + +export const InviteTeamMemberDialog = ({ + open, + onClose, +}: InviteTeamMemberDialogProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + ]; + + return ( + {}} + maxWidth="sm" + fullWidth + confirmText={t("pages.account.team.invite.sendInvite")} + > + + + + + + + ); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 45b329ea0..5aba9ab6d 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -572,6 +572,20 @@ "email": "Email", "role": "Role" } + }, + "invite": { + "title": "Invite team member", + "description": "Send an invitation to join your team", + "email": { + "label": "Email address", + "placeholder": "Enter email address" + }, + "role": { + "label": "Role", + "placeholder": "Select a role" + }, + "sendInvite": "Send invite", + "generateToken": "Generate token" } } }, From 1552018796bcc08b3071f29bcb32f5110a97772b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 8 Feb 2026 16:30:51 +0000 Subject: [PATCH 222/392] optional additionalButtons --- client/src/Components/v2/inputs/Dialog.tsx | 3 +++ .../components/InviteTeamMemberDialog.tsx | 17 +++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/client/src/Components/v2/inputs/Dialog.tsx b/client/src/Components/v2/inputs/Dialog.tsx index 717126edb..b581e7390 100644 --- a/client/src/Components/v2/inputs/Dialog.tsx +++ b/client/src/Components/v2/inputs/Dialog.tsx @@ -23,6 +23,7 @@ export const DialogInput = ({ children, maxWidth, fullWidth = false, + additionalButtons, }: { open: boolean; title?: string; @@ -37,6 +38,7 @@ export const DialogInput = ({ children?: ReactNode; maxWidth?: DialogProps["maxWidth"]; fullWidth?: boolean; + additionalButtons?: ReactNode; }) => { const { t } = useTranslation(); return ( @@ -59,6 +61,7 @@ export const DialogInput = ({ > {cancelText ?? t("common.buttons.cancel")} + {additionalButtons} {onConfirm && ( + } > ))} - ); From 07b04833524a955c7a450a1010de917f773a73b3 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 9 Feb 2026 10:43:29 -0500 Subject: [PATCH 223/392] fix: changes for debounce search in uptime/infrastructure pages --- client/src/Pages/Infrastructure/Monitors/index.tsx | 8 ++++++-- client/src/Pages/Uptime/Monitors/index.tsx | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 507ecb1ef..0db682272 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -20,6 +20,7 @@ import { useSelector, useDispatch } from "react-redux"; import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; import type { RootState } from "@/Types/state"; import { InfraMonitorsTable } from "./Components/MonitorsTable"; +import useDebounce from "@/Utils/debounce"; const InfrastructureMonitors = () => { const { t } = useTranslation(); @@ -43,6 +44,9 @@ const InfrastructureMonitors = () => { const [selectedMonitor, setSelectedMonitor] = useState(null); const isDialogOpen = Boolean(selectedMonitor); + // Debounce search input - 300ms delay + const debouncedSearch = useDebounce(search, 300); + const handleClearFilters = useCallback(() => { setSelectedStatus(""); setSelectedState(""); @@ -69,8 +73,8 @@ const InfrastructureMonitors = () => { [toFilterActive, "isActive"], ]); const activeFilter = [...filterLookup].find(([key]) => key !== undefined); - const field = activeFilter?.[1] || (search ? "name" : sortField || undefined); - const filter = activeFilter?.[0] || search || undefined; + const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined); + const filter = activeFilter?.[0] || debouncedSearch || undefined; // Build URL for monitors with checks const monitorsWithChecksUrl = useMemo(() => { diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 8aaed16d2..a06362744 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -20,6 +20,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import type { RootState } from "@/Types/state"; import { useTheme } from "@mui/material"; +import useDebounce from "@/Utils/debounce"; const UptimeMonitorsPage = () => { const { t } = useTranslation(); @@ -43,6 +44,9 @@ const UptimeMonitorsPage = () => { const [selectedMonitor, setSelectedMonitor] = useState(null); const isDialogOpen = Boolean(selectedMonitor); + // Debounce search input - 300ms delay + const debouncedSearch = useDebounce(search, 300); + // Convert filter selections to API filter values // Status: "up" -> true, "down" -> false // State: "active" -> true, "paused" -> false @@ -65,8 +69,8 @@ const UptimeMonitorsPage = () => { [toFilterActive, "isActive"], ]); const activeFilter = [...filterLookup].find(([key]) => key !== undefined); - const field = activeFilter?.[1] || (search ? "name" : sortField || undefined); - const filter = activeFilter?.[0] || search; + const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined); + const filter = activeFilter?.[0] || debouncedSearch; // Default to all types when none selected const effectiveTypes = From e75606aad3a4fc3bfb825a5af34fd1fb70e1dbac Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 9 Feb 2026 12:38:39 -0500 Subject: [PATCH 224/392] refactor: debounce.jsx to typescript and moving it to Hooks --- client/src/Hooks/useDebounce.ts | 29 +++++++++++++++++++ .../Pages/Infrastructure/Monitors/index.tsx | 2 +- client/src/Pages/Uptime/Monitors/index.tsx | 2 +- client/src/Utils/debounce.jsx | 18 ------------ 4 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 client/src/Hooks/useDebounce.ts delete mode 100644 client/src/Utils/debounce.jsx diff --git a/client/src/Hooks/useDebounce.ts b/client/src/Hooks/useDebounce.ts new file mode 100644 index 000000000..da26e8e59 --- /dev/null +++ b/client/src/Hooks/useDebounce.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from "react"; + +/** + * Custom hook for debouncing values + * @template T - The type of the value being debounced + * @param value - The value to debounce + * @param delay - The debounce delay in milliseconds + * @returns The debounced value + * + * @example + * const debouncedSearch = useDebounce(search, 300); // Debounces the search input with a 300ms delay + */ +const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; + +export default useDebounce; diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 0db682272..303c7d0f7 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -20,7 +20,7 @@ import { useSelector, useDispatch } from "react-redux"; import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; import type { RootState } from "@/Types/state"; import { InfraMonitorsTable } from "./Components/MonitorsTable"; -import useDebounce from "@/Utils/debounce"; +import useDebounce from "@/Hooks/useDebounce"; const InfrastructureMonitors = () => { const { t } = useTranslation(); diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index a06362744..ec4935221 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -20,7 +20,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import type { RootState } from "@/Types/state"; import { useTheme } from "@mui/material"; -import useDebounce from "@/Utils/debounce"; +import useDebounce from "@/Hooks/useDebounce"; const UptimeMonitorsPage = () => { const { t } = useTranslation(); diff --git a/client/src/Utils/debounce.jsx b/client/src/Utils/debounce.jsx deleted file mode 100644 index 11d639f14..000000000 --- a/client/src/Utils/debounce.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useState, useEffect } from "react"; - -const useDebounce = (value, delay) => { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - return debouncedValue; -}; - -export default useDebounce; From c670653ab3a8324e14bf95d4cf868c84c3050be9 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Mon, 9 Feb 2026 12:40:44 -0500 Subject: [PATCH 225/392] fix: format fix --- client/src/Hooks/useDebounce.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Hooks/useDebounce.ts b/client/src/Hooks/useDebounce.ts index da26e8e59..175d079fa 100644 --- a/client/src/Hooks/useDebounce.ts +++ b/client/src/Hooks/useDebounce.ts @@ -10,7 +10,7 @@ import { useState, useEffect } from "react"; * @example * const debouncedSearch = useDebounce(search, 300); // Debounces the search input with a 300ms delay */ -const useDebounce = (value: T, delay: number): T => { +const useDebounce = (value: T, delay: number): T => { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { From d69cec9730d3817dd850001fac01921ef042d3cd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 17:52:14 +0000 Subject: [PATCH 226/392] dialog --- client/src/Hooks/useInviteForm.ts | 14 ++ .../Account/components/HeaderTeamControls.tsx | 4 +- .../components/InviteTeamMemberDialog.tsx | 143 ++++++++++++++---- client/src/Validation/invite.ts | 9 ++ client/src/locales/en.json | 16 +- 5 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 client/src/Hooks/useInviteForm.ts create mode 100644 client/src/Validation/invite.ts diff --git a/client/src/Hooks/useInviteForm.ts b/client/src/Hooks/useInviteForm.ts new file mode 100644 index 000000000..664cc96ab --- /dev/null +++ b/client/src/Hooks/useInviteForm.ts @@ -0,0 +1,14 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { inviteSchema, type InviteFormData } from "@/Validation/invite"; + +export const useInviteForm = () => { + const defaults: InviteFormData = { + email: "", + role: ["user"], + }; + + return { + resolver: zodResolver(inviteSchema), + defaults, + }; +}; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 405e175c0..328deb411 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -60,7 +60,7 @@ export const HeaderTeamControls = ({ startIcon={} onClick={onInviteClick} > - {t("pages.account.team.inviteMember")} + {t("common.buttons.inviteMember")} )} {isSuperAdmin && ( @@ -69,7 +69,7 @@ export const HeaderTeamControls = ({ color="primary" startIcon={} > - {t("pages.account.team.addMember")} + {t("common.buttons.addMember")} )} diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index 5029a5800..24f1f58f9 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -1,9 +1,20 @@ import { Stack } from "@mui/material"; import MenuItem from "@mui/material/MenuItem"; -import { useTheme } from "@mui/material"; +import { useTheme, FormHelperText, Typography } from "@mui/material"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; import { Dialog, TextField, Select, Button } from "@/Components/v2/inputs"; import type { UserRole } from "@/Types/User"; +import { useInviteForm } from "@/Hooks/useInviteForm"; +import type { InviteFormData } from "@/Validation/invite"; +import { usePost } from "@/Hooks/UseApi"; + +const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST; + +interface InviteResponse { + token: string; +} interface InviteTeamMemberDialogProps { open: boolean; @@ -16,29 +27,70 @@ export const InviteTeamMemberDialog = ({ }: InviteTeamMemberDialogProps) => { const theme = useTheme(); const { t } = useTranslation(); + const { resolver, defaults } = useInviteForm(); + + const { control, handleSubmit, reset } = useForm({ + resolver, + defaultValues: defaults, + values: defaults, + }); + + const { post: generateToken, loading: generateLoading } = usePost< + InviteFormData, + InviteResponse + >(); + const { post: sendInvite, loading: sendLoading } = usePost< + InviteFormData, + InviteResponse + >(); + const [inviteLink, setInviteLink] = useState(null); const roleOptions: { value: UserRole; label: string }[] = [ { value: "admin", label: t("common.auth.roles.admin") }, { value: "user", label: t("common.auth.roles.user") }, ]; + const handleGenerateToken = async (data: InviteFormData) => { + const result = await generateToken("/invite", data); + if (result?.data?.token) { + const token = result.data.token; + const link = CLIENT_HOST ? `${CLIENT_HOST}/register/${token}` : token; + setInviteLink(link); + } + }; + + const handleSendInvite = async (data: InviteFormData) => { + const result = await sendInvite("/invite/send", data); + if (result?.success) { + handleClose(); + } + }; + + const handleClose = () => { + reset(); + setInviteLink(null); + onClose(); + }; + return ( {}} + onCancel={handleClose} + onConfirm={handleSubmit(handleSendInvite)} + confirmText={t("common.buttons.sendInvite")} + loading={sendLoading || generateLoading} maxWidth="sm" fullWidth - confirmText={t("pages.account.team.invite.sendInvite")} additionalButtons={ } > @@ -46,27 +98,66 @@ export const InviteTeamMemberDialog = ({ gap={theme.spacing(4)} mt={theme.spacing(4)} > - ( + + )} /> - + ( + <> + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + {inviteLink && ( + <> + + {t("pages.account.team.invite.linkLabel")} + + + + )} ); diff --git a/client/src/Validation/invite.ts b/client/src/Validation/invite.ts new file mode 100644 index 000000000..fecbc0158 --- /dev/null +++ b/client/src/Validation/invite.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { UserRoles } from "@/Types/User"; + +export const inviteSchema = z.object({ + email: z.email("Please enter a valid email address"), + role: z.array(z.enum(UserRoles)).min(1, "Please select a role"), +}); + +export type InviteFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 5aba9ab6d..36b89762c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -167,6 +167,7 @@ "uploadSuccess": "Monitors created successfully!" }, "bytesSent": "Bytes Sent", + "addMember": "Add member", "cancel": "Cancel", "checkHooks": { "failureResolveOne": "Failed to resolve incident." @@ -195,16 +196,20 @@ "home": "Home" }, "buttons": { + "addMember": "Add member", "cancel": "Cancel", "close": "Close", "configure": "Configure", "confirm": "Confirm", "create": "Create", "delete": "Delete", + "generateToken": "Generate token", "incidents": "Incidents", + "inviteMember": "Invite member", "pause": "Pause", "resume": "Resume", "save": "Save", + "sendInvite": "Send invite", "test": "Test", "testNotifications": "Test notifications", "toggleTheme": "Toggles light & dark", @@ -299,6 +304,7 @@ "createMonitor": "Create monitor", "createNew": "Create new", "delete": "Delete", + "generateToken": "Generate token", "DeleteAccountButton": "Remove account", "DeleteAccountTitle": "Remove account", "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", @@ -459,6 +465,7 @@ "discussions": "Discussions", "docs": "Docs", "incidents": "Incidents", + "inviteMember": "Invite member", "infrastructure": "Infrastructure", "logOut": "Log out", "logs": "Logs", @@ -559,8 +566,6 @@ } }, "team": { - "addMember": "Add member", - "inviteMember": "Invite member", "filter": { "placeholder": "Filter by role", "all": "All roles", @@ -584,8 +589,7 @@ "label": "Role", "placeholder": "Select a role" }, - "sendInvite": "Send invite", - "generateToken": "Generate token" + "linkLabel": "Invite link" } } }, @@ -675,8 +679,10 @@ "actions": { "configure": "Configure", "delete": "Delete", + "generateToken": "Generate token", "details": "Details", "incidents": "Incidents", + "inviteMember": "Invite member", "openSite": "Open site", "pause": "Pause", "resume": "Resume" @@ -1341,6 +1347,7 @@ "teamMember": "Team member" }, "save": "Save", + "sendInvite": "Send invite", "selectAll": "Select all", "settingsFailedToClearStats": "Failed to clear stats", "settingsFailedToDeleteMonitors": "Failed to delete all monitors", @@ -1467,6 +1474,7 @@ "description": "Create a new user and share the credentials with them. This method gives the member immediate access to all monitors.", "title": "Register new team member" }, + "addMember": "Add member", "cancel": "Cancel", "changeTeamPassword": { "changePasswordMenu": "Reset Password", From fafd581f06a67b191205f8fd7a33215f0b82cefc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:14:06 +0000 Subject: [PATCH 227/392] add team member initial commit --- client/src/Pages/Account/TabTeam.tsx | 10 +++ .../components/AddTeamMemberDialog.tsx | 87 +++++++++++++++++++ .../Account/components/HeaderTeamControls.tsx | 3 + client/src/locales/en.json | 4 + 4 files changed, 104 insertions(+) create mode 100644 client/src/Pages/Account/components/AddTeamMemberDialog.tsx diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 308becb2a..731f4eabf 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -4,6 +4,7 @@ import { useState, useMemo } from "react"; import { HeaderTeamControls } from "./components/HeaderTeamControls"; import { TeamTable } from "./components/TeamTable"; import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog"; +import { AddTeamMemberDialog } from "./components/AddTeamMemberDialog"; import { useGet } from "@/Hooks/UseApi"; import type { User, UserRole } from "@/Types/User"; @@ -11,6 +12,7 @@ export const TabTeam = () => { const theme = useTheme(); const [filter, setFilter] = useState(""); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); const { data: users } = useGet("/auth/users"); @@ -24,18 +26,26 @@ export const TabTeam = () => { const handleOpenInviteDialog = () => setInviteDialogOpen(true); const handleCloseInviteDialog = () => setInviteDialogOpen(false); + const handleOpenAddMemberDialog = () => setAddMemberDialogOpen(true); + const handleCloseAddMemberDialog = () => setAddMemberDialogOpen(false); + return ( + ); }; diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx new file mode 100644 index 000000000..1ca086684 --- /dev/null +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -0,0 +1,87 @@ +import { Stack } from "@mui/material"; +import MenuItem from "@mui/material/MenuItem"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Dialog, TextField, Select } from "@/Components/v2/inputs"; +import type { UserRole } from "@/Types/User"; + +interface AddTeamMemberDialogProps { + open: boolean; + onClose: () => void; +} + +export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + ]; + + const handleClose = () => { + onClose(); + }; + + return ( + {}} + confirmText={t("common.buttons.addMember")} + maxWidth="sm" + fullWidth + > + + + + + + + + + + ); +}; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 328deb411..57f000170 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -14,12 +14,14 @@ interface HeaderTeamControlsProps { filter: UserRole | ""; onFilterChange: (value: UserRole | "") => void; onInviteClick?: () => void; + onAddMemberClick?: () => void; } export const HeaderTeamControls = ({ filter, onFilterChange, onInviteClick, + onAddMemberClick, }: HeaderTeamControlsProps) => { const theme = useTheme(); const { t } = useTranslation(); @@ -68,6 +70,7 @@ export const HeaderTeamControls = ({ variant="contained" color="primary" startIcon={} + onClick={onAddMemberClick} > {t("common.buttons.addMember")} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 36b89762c..06e923565 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -578,6 +578,10 @@ "role": "Role" } }, + "addMember": { + "title": "Register new team member", + "description": "Create a new user account. Share credentials securely after creation." + }, "invite": { "title": "Invite team member", "description": "Send an invitation to join your team", From 264e6f7d1ff074bbef7e798c0115f8d5daa8c51f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:29:36 +0000 Subject: [PATCH 228/392] form submission --- client/src/Hooks/useAddTeamMemberForm.ts | 20 ++ client/src/Pages/Account/TabTeam.tsx | 7 +- .../components/AddTeamMemberDialog.tsx | 176 ++++++++++++++---- client/src/Validation/addTeamMember.ts | 9 + 4 files changed, 171 insertions(+), 41 deletions(-) create mode 100644 client/src/Hooks/useAddTeamMemberForm.ts create mode 100644 client/src/Validation/addTeamMember.ts diff --git a/client/src/Hooks/useAddTeamMemberForm.ts b/client/src/Hooks/useAddTeamMemberForm.ts new file mode 100644 index 000000000..64cb0d353 --- /dev/null +++ b/client/src/Hooks/useAddTeamMemberForm.ts @@ -0,0 +1,20 @@ +import { + addTeamMemberSchema, + type AddTeamMemberFormData, +} from "@/Validation/addTeamMember"; + +export const useAddTeamMemberForm = () => { + const defaults: AddTeamMemberFormData = { + firstName: "", + lastName: "", + email: "", + password: "", + confirm: "", + role: ["user"], + }; + + return { + schema: addTeamMemberSchema, + defaults, + }; +}; diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx index 731f4eabf..4eb2f8a97 100644 --- a/client/src/Pages/Account/TabTeam.tsx +++ b/client/src/Pages/Account/TabTeam.tsx @@ -14,7 +14,7 @@ export const TabTeam = () => { const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); - const { data: users } = useGet("/auth/users"); + const { data: users, refetch } = useGet("/auth/users"); const filteredUsers = useMemo(() => { if (!users) return []; @@ -29,6 +29,10 @@ export const TabTeam = () => { const handleOpenAddMemberDialog = () => setAddMemberDialogOpen(true); const handleCloseAddMemberDialog = () => setAddMemberDialogOpen(false); + const handleRefetch = () => { + refetch(); + }; + return ( { ); diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index 1ca086684..aed713b1f 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -2,17 +2,39 @@ import { Stack } from "@mui/material"; import MenuItem from "@mui/material/MenuItem"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { Dialog, TextField, Select } from "@/Components/v2/inputs"; -import type { UserRole } from "@/Types/User"; +import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; +import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; +import type { UserRole, User } from "@/Types/User"; +import { usePost } from "@/Hooks/UseApi"; interface AddTeamMemberDialogProps { open: boolean; onClose: () => void; + onSuccess?: () => void; } -export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) => { +interface RegisterPayload { + user: Omit; +} + +export const AddTeamMemberDialog = ({ + open, + onClose, + onSuccess, +}: AddTeamMemberDialogProps) => { const theme = useTheme(); const { t } = useTranslation(); + const { schema, defaults } = useAddTeamMemberForm(); + const { post, loading } = usePost(); + + const { control, handleSubmit, reset, setError } = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + values: defaults, + }); const roleOptions: { value: UserRole; label: string }[] = [ { value: "admin", label: t("common.auth.roles.admin") }, @@ -20,17 +42,38 @@ export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) ]; const handleClose = () => { + reset(); onClose(); }; + const onSubmit = async (data: AddTeamMemberFormData) => { + if (loading) return; + + const { confirm, ...userData } = data; + const payload: RegisterPayload = { user: userData }; + + const result = await post("/auth/register", payload); + + if (result?.success) { + reset(); + onClose(); + onSuccess?.(); + } else if (result?.msg) { + if (result.msg.toLowerCase().includes("email")) { + setError("email", { message: result.msg }); + } + } + }; + return ( {}} + onConfirm={handleSubmit(onSubmit)} confirmText={t("common.buttons.addMember")} + loading={loading} maxWidth="sm" fullWidth > @@ -38,48 +81,101 @@ export const AddTeamMemberDialog = ({ open, onClose }: AddTeamMemberDialogProps) gap={theme.spacing(4)} mt={theme.spacing(4)} > - ( + + )} /> - ( + + )} /> - ( + + )} /> - field.onChange([e.target.value])} + fieldLabel={t("pages.account.team.invite.role.label")} + placeholder={t("pages.account.team.invite.role.placeholder")} + fullWidth > - {option.label} - - ))} - - ( + + {option.label} + + ))} + + )} /> - ( + + )} + /> + ( + + )} /> diff --git a/client/src/Validation/addTeamMember.ts b/client/src/Validation/addTeamMember.ts new file mode 100644 index 000000000..bada2beb4 --- /dev/null +++ b/client/src/Validation/addTeamMember.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { registerSchema } from "@/Validation/register"; +import { UserRoles } from "@/Types/User"; + +export const addTeamMemberSchema = registerSchema.extend({ + role: z.array(z.enum(UserRoles)).min(1, "Please select a role"), +}); + +export type AddTeamMemberFormData = z.infer; From 8b7fd826b3a428475b907d728cb5cb29ab613231 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:49:31 +0000 Subject: [PATCH 229/392] createUser endpoint --- .../components/AddTeamMemberDialog.tsx | 10 +++-- server/src/controllers/authController.ts | 24 ++++++++++++ server/src/controllers/inviteController.ts | 1 - server/src/routes/authRoute.ts | 1 + server/src/service/business/userService.ts | 38 +++++++++++++++++++ server/src/validation/joi.ts | 23 +++++++++++ 6 files changed, 93 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index aed713b1f..170af1724 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -17,7 +17,11 @@ interface AddTeamMemberDialogProps { } interface RegisterPayload { - user: Omit; + firstName: string; + lastName: string; + email: string; + password: string; + role: string[]; } export const AddTeamMemberDialog = ({ @@ -50,9 +54,9 @@ export const AddTeamMemberDialog = ({ if (loading) return; const { confirm, ...userData } = data; - const payload: RegisterPayload = { user: userData }; + const payload: RegisterPayload = userData; - const result = await post("/auth/register", payload); + const result = await post("/auth/users", payload); if (result?.success) { reset(); diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index d6fe6aeff..3fa551808 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -1,10 +1,13 @@ import { Request, Response, NextFunction } from "express"; import { AppError } from "@/utils/AppError.js"; +import { requireTeamId, requireUserRoles } from "./controllerUtils.js"; +import type { UserRole } from "@/types/user.js"; import { registrationBodyValidation, loginValidation, editUserBodyValidation, + createUserBodyValidation, recoveryValidation, recoveryTokenBodyValidation, newPasswordValidation, @@ -50,6 +53,27 @@ class AuthController { } }; + createUser = async (req: Request, res: Response, next: NextFunction) => { + try { + const userData = req.body; + if (userData?.email) { + userData.email = userData.email.toLowerCase(); + } + await createUserBodyValidation.validateAsync(userData); + + const teamId = requireTeamId(req.user?.teamId); + const actorRoles = requireUserRoles(req.user?.role) as UserRole[]; + const newUser = await this.userService.createUser(userData, teamId, actorRoles, req.file); + res.status(201).json({ + success: true, + msg: "User created successfully", + data: newUser, + }); + } catch (error) { + next(error); + } + }; + loginUser = async (req: Request, res: Response, next: NextFunction) => { try { if (req.body?.email) { diff --git a/server/src/controllers/inviteController.ts b/server/src/controllers/inviteController.ts index e6d5d7ccb..bba04fbbd 100755 --- a/server/src/controllers/inviteController.ts +++ b/server/src/controllers/inviteController.ts @@ -15,7 +15,6 @@ class InviteController { } getInviteToken = async (req: Request, res: Response, next: NextFunction) => { - console.log(req.body); try { const teamId = requireTeamId(req.user?.teamId); const userRoles = requireUserRoles(req.user?.role); diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index 97e85d61f..cc1ec3fd6 100755 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -25,6 +25,7 @@ class AuthRoutes { this.router.get("/users/superadmin", this.authController.checkSuperadminExists); this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); + this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser); this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById); this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 39b05a737..d05f027b1 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -7,6 +7,7 @@ import { ITeamsRepository, } from "@/repositories/index.js"; import type { User } from "@/types/index.js"; +import { canManageRole, type UserRole } from "@/types/user.js"; import bcrypt from "bcryptjs"; import { AppError } from "@/utils/AppError.js"; import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; @@ -153,6 +154,43 @@ class UserService { return { user: newUser, token }; }; + createUser = async (userData: Partial, teamId: string, actorRoles: UserRole[], file: any) => { + // Validate that the creator can assign the requested roles + const targetRoles = userData.role ?? []; + for (const targetRole of targetRoles) { + const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole)); + if (!canManage) { + throw new AppError({ + message: "You do not have permission to assign this role", + service: SERVICE_NAME, + method: "createUser", + status: 403, + }); + } + } + + userData.teamId = teamId; + + if (userData.password) { + userData.password = this.hashPassword(userData.password); + } + + const newUser = await this.usersRepository.create(userData, file); + + this.logger.debug({ + message: "New user created by superadmin", + service: SERVICE_NAME, + method: "createUser", + details: newUser.id, + }); + + newUser.profileImage = undefined; + newUser.avatarImage = undefined; + newUser.password = ""; + + return newUser; + }; + loginUser = async (email: string, password: string) => { // Check if user exists const user = await this.usersRepository.findByEmail(email); diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index f72addc5e..f01a8da40 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -712,6 +712,28 @@ const editUserPasswordByIdBodyValidation = joi.object({ password: joi.string().min(8).required().pattern(passwordPattern), }); +const createUserBodyValidation = joi.object({ + firstName: nameValidation.required(), + lastName: nameValidation.required(), + email: joi + .string() + .email() + .required() + .custom((value, helpers) => { + const lowercasedValue = value.toLowerCase(); + if (value !== lowercasedValue) { + return helpers.message({ custom: "Email must be in lowercase" }); + } + return lowercasedValue; + }), + password: joi.string().min(8).required().pattern(passwordPattern), + role: joi + .array() + .items(joi.string().valid(...UserRoles)) + .min(1) + .required(), +}); + export { roleValidatior, loginValidation, @@ -781,4 +803,5 @@ export { editUserByIdBodyValidation, editSuperadminUserByIdBodyValidation, editUserPasswordByIdBodyValidation, + createUserBodyValidation, }; From a46e282633a1f1a5d8258f933ded88997374be5c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 18:57:24 +0000 Subject: [PATCH 230/392] role column --- client/src/Pages/Account/components/TeamTable.tsx | 7 +++++++ client/src/locales/en.json | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx index 5c2455bec..2583d1c39 100644 --- a/client/src/Pages/Account/components/TeamTable.tsx +++ b/client/src/Pages/Account/components/TeamTable.tsx @@ -35,6 +35,13 @@ export const TeamTable = ({ users }: TeamTableProps) => { ), }, + { + id: "created", + content: t("pages.account.team.table.headers.created"), + render: (row) => ( + {new Date(row.createdAt).toLocaleDateString()} + ), + }, ]; const handleRowClick = (row: User) => { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 06e923565..69684af6a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -575,7 +575,8 @@ "table": { "headers": { "email": "Email", - "role": "Role" + "role": "Role", + "created": "Created at" } }, "addMember": { From 08e4b0c39e75e06be3a0dfe9f8fd7376343919a0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 19:15:22 +0000 Subject: [PATCH 231/392] remove unused --- .../components/AddMemberMenu/index.jsx | 74 --- .../AddTeamMember/hooks/useAddTeamMember.jsx | 48 -- .../components/AddTeamMember/index.jsx | 213 --------- .../Account/components/PasswordPanel.jsx | 302 ------------ .../Pages/Account/components/ProfilePanel.jsx | 437 ------------------ .../Pages/Account/components/TeamPanel.jsx | 353 -------------- client/src/Pages/Account/old.jsx | 99 ---- 7 files changed, 1526 deletions(-) delete mode 100644 client/src/Pages/Account/components/AddMemberMenu/index.jsx delete mode 100644 client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx delete mode 100644 client/src/Pages/Account/components/AddTeamMember/index.jsx delete mode 100644 client/src/Pages/Account/components/PasswordPanel.jsx delete mode 100644 client/src/Pages/Account/components/ProfilePanel.jsx delete mode 100644 client/src/Pages/Account/components/TeamPanel.jsx delete mode 100644 client/src/Pages/Account/old.jsx diff --git a/client/src/Pages/Account/components/AddMemberMenu/index.jsx b/client/src/Pages/Account/components/AddMemberMenu/index.jsx deleted file mode 100644 index 96bea3522..000000000 --- a/client/src/Pages/Account/components/AddMemberMenu/index.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useState } from "react"; -import Button from "@mui/material/Button"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import { useTheme } from "@emotion/react"; -import Icon from "@/Components/v1/Icon"; -import { useTranslation } from "react-i18next"; -import Proptypes from "prop-types"; - -const AddMemberMenu = ({ handleInviteOpen, handleIsRegisterOpen }) => { - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - const { t } = useTranslation(); - const theme = useTheme(); - const handleClick = (event) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); - }; - - return ( - <> - - - { - handleClose(); - handleInviteOpen(); - }} - > - {t("teamPanel.inviteTeamMember")} - - { - handleClose(); - handleIsRegisterOpen(true); - }} - > - {t("teamPanel.register")} - - - - ); -}; - -AddMemberMenu.propTypes = { - handleInviteOpen: Proptypes.func.isRequired, - handleIsRegisterOpen: Proptypes.func.isRequired, -}; - -export default AddMemberMenu; diff --git a/client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx b/client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx deleted file mode 100644 index 1237777ff..000000000 --- a/client/src/Pages/Account/components/AddTeamMember/hooks/useAddTeamMember.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useState } from "react"; -import { newOrChangedCredentials } from "../../../../../Validation/validation.js"; -import { useTranslation } from "react-i18next"; -const useAddTeamMember = () => { - const { t } = useTranslation(); - const [errors, setErrors] = useState({}); - - const clearErrors = () => setErrors({}); - - const validateFields = (name, value, formData) => { - const { error } = newOrChangedCredentials.validate( - { [name]: value }, - { abortEarly: false, context: { password: formData.password } } - ); - - setErrors((prev) => ({ - ...prev, - [name]: error?.details?.[0]?.message || "", - })); - }; - - const validateForm = (formData, role) => { - const { error } = newOrChangedCredentials.validate(formData, { - abortEarly: false, - context: { password: formData.password }, - }); - const formErrors = {}; - if (error) { - for (const err of error.details) { - formErrors[err.path[0]] = err.message; - } - } - if (!role[0] || role.length === 0) { - formErrors.role = t( - "teamPanel.registerTeamMember.auth.common.inputs.role.errors.empty" - ); - } - if (Object.keys(formErrors).length > 0) { - setErrors(formErrors); - return false; - } - setErrors({}); - return true; - }; - - return { errors, setErrors, clearErrors, validateFields, validateForm }; -}; -export default useAddTeamMember; diff --git a/client/src/Pages/Account/components/AddTeamMember/index.jsx b/client/src/Pages/Account/components/AddTeamMember/index.jsx deleted file mode 100644 index e5280af16..000000000 --- a/client/src/Pages/Account/components/AddTeamMember/index.jsx +++ /dev/null @@ -1,213 +0,0 @@ -import { Button, Stack } from "@mui/material"; -import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import { useGetInviteToken } from "../../../../Hooks/inviteHooks.js"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -import { useState } from "react"; -import PasswordTooltip from "../../../Auth/components/PasswordTooltip.jsx"; -import useAddTeamMember from "./hooks/useAddTeamMember.jsx"; -import usePasswordFeedback from "../../../Auth/hooks/usePasswordFeedback.jsx"; -import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import PropTypes from "prop-types"; - -const INITIAL_FORM_STATE = { - firstName: "", - lastName: "", - email: "", - password: "", - confirm: "", - teamId: "", -}; - -const INITIAL_ROLE_STATE = ["user"]; -const AddTeamMember = ({ handleIsRegisterOpen, isRegisterOpen, onMemberAdded }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const { errors, setErrors, clearErrors, validateFields, validateForm } = - useAddTeamMember(); - const { feedback, handlePasswordFeedback } = usePasswordFeedback(); - const [getInviteToken, clearToken, isLoading, error, token, addTeamMember] = - useGetInviteToken(); - const [form, setForm] = useState(INITIAL_FORM_STATE); - const [role, setRole] = useState(INITIAL_ROLE_STATE); - const [isLoadingSubmit, setIsLoadingSubmit] = useState(false); - const closeAddMemberModal = () => { - handleIsRegisterOpen(false); - setForm(INITIAL_FORM_STATE); - setRole(INITIAL_ROLE_STATE); - clearErrors(); - clearToken(); - }; - - const onChange = (e) => { - let { name, value } = e.target; - if (name === "email") value = value.toLowerCase(); - const updatedForm = { ...form, [name]: value }; - validateFields(name, value, updatedForm); - setForm(updatedForm); - - if (name === "password" || name === "confirm") { - handlePasswordFeedback(updatedForm, name, value, form, errors, setErrors); - } - }; - - const onsubmitAddMember = async (event) => { - event.preventDefault(); - if (!validateForm(form, role)) return; - try { - setIsLoadingSubmit(true); - await addTeamMember(form, role); - createToast({ - body: t("teamPanel.registerToast.success"), - }); - onMemberAdded(); - closeAddMemberModal(); - } catch (error) { - const errorMsg = error.response?.data?.msg || error.message || "unknownError"; - createToast({ - type: "error", - body: t(errorMsg), - }); - } finally { - setIsLoadingSubmit(false); - } - }; - const tErr = (key) => (key ? t([`teamPanel.registerTeamMember.${key}`, key]) : ""); - return ( - <> - - - - - - - - - setToInvite((prev) => ({ - ...prev, - role: [event.target.value], - })) - } - items={[ - { _id: "admin", name: t("roles.admin") }, - { _id: "user", name: t("roles.teamMember") }, - ]} - /> - {token && {t("teamPanel.inviteLink")}} - {token && ( - - )} - - - - - - - - ); -}; - -TeamPanel.propTypes = { - // No props are being passed to this component, hence no specific PropTypes are defined. -}; - -export default TeamPanel; diff --git a/client/src/Pages/Account/old.jsx b/client/src/Pages/Account/old.jsx deleted file mode 100644 index 3bf480336..000000000 --- a/client/src/Pages/Account/old.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState } from "react"; -import PropTypes from "prop-types"; -import { useNavigate } from "react-router-dom"; -import { useSelector } from "react-redux"; -import { Box, Tab, useTheme } from "@mui/material"; -import CustomTabList from "@/Components/v1/Tab/index.jsx"; -import TabContext from "@mui/lab/TabContext"; -import ProfilePanel from "./components/ProfilePanel.jsx"; -import PasswordPanel from "./components/PasswordPanel.jsx"; -import TeamPanel from "./components/TeamPanel.jsx"; -import { useTranslation } from "react-i18next"; - -/** - * Account component renders a settings page with tabs for Profile, Password, and Team settings. - * @param {string} [props.open] - Specifies the initially open tab: 'profile', 'password', or 'team'. - * @returns {JSX.Element} - */ -const Account = ({ open = "profile" }) => { - const theme = useTheme(); - const navigate = useNavigate(); - const [focusedTab, setFocusedTab] = useState(null); // Track focused tab - const tab = open; - const handleTabChange = (event, newTab) => { - navigate(`/account/${newTab}`); - }; - const { user } = useSelector((state) => state.auth); - const { t } = useTranslation(); - - const requiredRoles = ["superadmin", "admin"]; - let tabList = [ - { name: t("menu.profile"), value: "profile" }, - { name: t("menu.password"), value: "password" }, - { name: t("menu.team"), value: "team" }, - ]; - - const hideTeams = !requiredRoles.some((role) => user.role.includes(role)); - - if (hideTeams) { - tabList = [ - { name: t("menu.profile"), value: "profile" }, - { name: t("menu.password"), value: "password" }, - ]; - } - - // Remove password for demo - if (user.role.includes("demo")) { - tabList = [{ name: t("menu.profile"), value: "profile" }]; - } - - const handleKeyDown = (event) => { - const currentIndex = tabList.findIndex((item) => item.value === tab); - if (event.key === "Tab") { - const nextIndex = (currentIndex + 1) % tabList.length; - setFocusedTab(tabList[nextIndex].value); - } else if (event.key === "Enter") { - event.preventDefault(); - navigate(`/account/${focusedTab}`); - } - }; - - const handleFocus = (tabName) => { - setFocusedTab(tabName); - }; - - return ( - - - - {tabList.map((item, index) => ( - handleFocus(item.value)} - tabIndex={index} - /> - ))} - - - - {!hideTeams && } - - - ); -}; - -Account.propTypes = { - open: PropTypes.oneOf(["profile", "password", "team"]), -}; - -export default Account; From 550d5cc555abcd9acd81779c0cfdb94328de4b5c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 19:26:58 +0000 Subject: [PATCH 232/392] initial commit --- client/src/Pages/Account/EditUser/index.tsx | 83 +++++++++++++++++++ .../Account/EditUser/{index.jsx => old.jsx} | 0 client/src/Routes/index.jsx | 6 +- 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/Account/EditUser/index.tsx rename client/src/Pages/Account/EditUser/{index.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx new file mode 100644 index 000000000..cd06581ed --- /dev/null +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -0,0 +1,83 @@ +import { Stack, MenuItem } from "@mui/material"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { ConfigBox, BasePage, Breadcrumb } from "@/Components/v2/design-elements"; +import { TextField, Button, Select } from "@/Components/v2/inputs"; +import { UserRoles } from "@/Types/User"; +import { useState } from "react"; + +const EditUserPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email] = useState(""); + const [roles, setRoles] = useState([]); + + const editableRoles = UserRoles.filter((role) => role !== "superadmin"); + + return ( + + + + + setFirstName(e.target.value)} + autoComplete="given-name" + /> + setLastName(e.target.value)} + autoComplete="family-name" + /> + + + } + /> + setRoles(e.target.value as string[])} + placeholder={t("pages.account.team.invite.role.placeholder")} + sx={{ minWidth: 200 }} + > + {editableRoles.map((role) => ( + + {t(`common.auth.roles.${role}`)} + + ))} + + } + /> + + + + ); +}; + +export default EditUserPage; diff --git a/client/src/Pages/Account/EditUser/index.jsx b/client/src/Pages/Account/EditUser/old.jsx similarity index 100% rename from client/src/Pages/Account/EditUser/index.jsx rename to client/src/Pages/Account/EditUser/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 282a5db56..4c68c0367 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -46,7 +46,7 @@ import CreateNotifications from "../Pages/Notifications/create"; // Settings import Account from "@/Pages/Account"; -import EditUser from "../Pages/Account/EditUser/index.jsx"; +import EditUser from "../Pages/Account/EditUser"; import Settings from "../Pages/Settings"; import Maintenance from "../Pages/Maintenance"; @@ -354,7 +354,9 @@ const Routes = () => { path="account/team/:userId" element={ - + + + } /> From c4ab56095ec0f67b19bb5a8e81274062432e9d0d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 19:46:11 +0000 Subject: [PATCH 233/392] strings --- client/src/Pages/Account/EditUser/index.tsx | 9 ++++-- client/src/Pages/Auth/Register/index.tsx | 12 +++---- client/src/locales/en.json | 36 +++++++++++++-------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index cd06581ed..b596bcd32 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -27,19 +27,22 @@ const EditUserPage = () => { rightContent={ setFirstName(e.target.value)} autoComplete="given-name" /> setLastName(e.target.value)} autoComplete="family-name" /> diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 80cee579f..75d27649f 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -86,8 +86,8 @@ const RegisterPage = () => { render={({ field, fieldState }) => ( @@ -99,8 +99,8 @@ const RegisterPage = () => { render={({ field, fieldState }) => ( @@ -113,8 +113,8 @@ const RegisterPage = () => { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 69684af6a..8704450a2 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -237,6 +237,28 @@ "resolved": "Resolved", "responseTime": "Response time" }, + "form": { + "name": { + "option": { + "firstName": { + "label": "First name", + "placeholder": "Enter your first name" + }, + "lastName": { + "label": "Last name", + "placeholder": "Enter your last name" + } + } + }, + "email": { + "option": { + "email": { + "label": "Email", + "placeholder": "you@example.com" + } + } + } + }, "table": { "empty": "Nothing here", "headers": { @@ -634,19 +656,7 @@ "register": { "title": "Welcome back to Checkmate!", "subtitle": "Sign up to get started", - "submit": "Register", - "form": { - "option": { - "name": { - "label": "Name", - "placeholder": "Your name" - }, - "surname": { - "label": "Surname", - "placeholder": "Your surname" - } - } - } + "submit": "Register" }, "forgotPassword": { "title": "Forgot your password?", From 197e075ba13340901238dd9540587043737a3569 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 20:07:45 +0000 Subject: [PATCH 234/392] strings --- client/src/Pages/Account/EditUser/index.tsx | 77 ++++++++++++++----- client/src/Pages/Account/TabProfile.tsx | 8 +- .../components/AddTeamMemberDialog.tsx | 15 ++-- .../components/InviteTeamMemberDialog.tsx | 7 +- client/src/locales/en.json | 27 ++++--- 5 files changed, 88 insertions(+), 46 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index b596bcd32..5a10898f6 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -1,10 +1,16 @@ -import { Stack, MenuItem } from "@mui/material"; +import { Stack, Typography, IconButton, Divider } from "@mui/material"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { ConfigBox, BasePage, Breadcrumb } from "@/Components/v2/design-elements"; -import { TextField, Button, Select } from "@/Components/v2/inputs"; +import { TextField, Button, Autocomplete } from "@/Components/v2/inputs"; import { UserRoles } from "@/Types/User"; import { useState } from "react"; +import { Trash2 } from "lucide-react"; + +interface RoleOption { + id: string; + name: string; +} const EditUserPage = () => { const theme = useTheme(); @@ -17,6 +23,17 @@ const EditUserPage = () => { const editableRoles = UserRoles.filter((role) => role !== "superadmin"); + const roleOptions: RoleOption[] = editableRoles.map((role) => ({ + id: role, + name: t(`common.auth.roles.${role}`), + })); + + const selectedRoles = roleOptions.filter((r) => roles.includes(r.id)); + + const handleRemoveRole = (roleToRemove: string) => { + setRoles(roles.filter((role) => role !== roleToRemove)); + }; + return ( @@ -50,25 +67,47 @@ const EditUserPage = () => { } /> setRoles(e.target.value as string[])} - placeholder={t("pages.account.team.invite.role.placeholder")} - sx={{ minWidth: 200 }} - > - {editableRoles.map((role) => ( - + option.name} + onChange={(_: unknown, newValue: RoleOption[]) => { + setRoles(newValue.map((r) => r.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + /> + {selectedRoles.length > 0 && ( + - {t(`common.auth.roles.${role}`)} - - ))} - + {selectedRoles.map((role, index) => ( + + {role.name} + handleRemoveRole(role.id)} + aria-label="Remove role" + > + + + {index < selectedRoles.length - 1 && } + + ))} + + )} + } /> - + {role.name} + handleRemoveRole(role.id)} + aria-label="Remove role" + > + + + {index < selectedRoles.length - 1 && } + + ))} + + )} + + } + /> + + + ); }; diff --git a/client/src/Validation/editUser.ts b/client/src/Validation/editUser.ts new file mode 100644 index 000000000..6fb42a6be --- /dev/null +++ b/client/src/Validation/editUser.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { namePattern } from "@/Validation/patterns"; +import { UserRoles } from "@/Types/User"; + +export const editUserSchema = z.object({ + firstName: z + .string() + .min(1, "First name is required") + .max(50, "First name must be 50 characters or less") + .regex(namePattern, "First name contains invalid characters"), + lastName: z + .string() + .min(1, "Last name is required") + .max(50, "Last name must be 50 characters or less") + .regex(namePattern, "Last name contains invalid characters"), + role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), +}); + +export type EditUserFormData = z.infer; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index f01a8da40..63be09932 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -689,7 +689,6 @@ const editUserByIdParamValidation = joi.object({ const editUserByIdBodyValidation = joi.object({ firstName: nameValidation.required(), lastName: nameValidation.required(), - email: joi.string().email().required(), role: joi .array() .items(joi.string().valid(...UserRoles)) @@ -700,7 +699,6 @@ const editUserByIdBodyValidation = joi.object({ const editSuperadminUserByIdBodyValidation = joi.object({ firstName: nameValidation.required(), lastName: nameValidation.required(), - email: joi.string().email().required(), role: joi .array() .items(joi.string().valid(...UserRoles)) From 48081d85ee7aa2c05963ea4f81a18bb4aebc4149 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 20:48:12 +0000 Subject: [PATCH 237/392] cleanup --- .../Pages/Account/EditUser/hooks/editUser.js | 102 ------------ client/src/Pages/Account/EditUser/old.jsx | 136 ---------------- .../components/ChangePasswordModal/index.jsx | 149 ------------------ .../Account/components/RoleTable/index.jsx | 44 ------ 4 files changed, 431 deletions(-) delete mode 100644 client/src/Pages/Account/EditUser/hooks/editUser.js delete mode 100644 client/src/Pages/Account/EditUser/old.jsx delete mode 100644 client/src/Pages/Account/components/ChangePasswordModal/index.jsx delete mode 100644 client/src/Pages/Account/components/RoleTable/index.jsx diff --git a/client/src/Pages/Account/EditUser/hooks/editUser.js b/client/src/Pages/Account/EditUser/hooks/editUser.js deleted file mode 100644 index aeb50ffff..000000000 --- a/client/src/Pages/Account/EditUser/hooks/editUser.js +++ /dev/null @@ -1,102 +0,0 @@ -import { useState, useEffect } from "react"; -import { ROLES } from "../../../../Utils/roleUtils.js"; -import { editUserValidation } from "../../../../Validation/validation.js"; -import Joi from "joi"; -import { createToast } from "../../../../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; -export const useEditUserForm = (user) => { - const [searchInput, setSearchInput] = useState(""); - const [form, setForm] = useState({ - firstName: "", - lastName: "", - email: "", - role: [], - }); - - // Effect to set user - useEffect(() => { - if (user) { - setForm({ - firstName: user?.firstName, - lastName: user?.lastName, - email: user?.email, - role: user?.role, - }); - } - }, [user]); - - const handleRoleChange = (value) => { - const hasSuperAdmin = form.role.includes(ROLES.SUPERADMIN); - const newRoles = value.map((item) => item.role); - if (hasSuperAdmin && !newRoles.includes(ROLES.SUPERADMIN)) { - newRoles.push(ROLES.SUPERADMIN); - } - - setForm({ - ...form, - role: newRoles, - }); - }; - - const handleDeleteRole = (role) => { - if (role === ROLES.SUPERADMIN) return; - setForm({ ...form, role: form?.role?.filter((r) => r !== role) }); - }; - - const handleSearchInput = (value) => { - setSearchInput(value); - }; - - return [ - form, - setForm, - handleRoleChange, - handleDeleteRole, - searchInput, - handleSearchInput, - ]; -}; - -export const useValidateEditUserForm = () => { - const [errors, setErrors] = useState({}); - const { t } = useTranslation(); - const validateForm = (form) => { - const { error } = editUserValidation.validate(form, { - abortEarly: false, - }); - const errs = error?.details; - const errsObj = errs?.reduce((acc, curr) => { - acc[curr.path[0]] = curr.message; - return acc; - }, {}); - setErrors(errsObj); - - if (errs?.length > 0) { - createToast({ - variant: "warning", - hasIcon: true, - title: t("editUserPage.toast.validationErrors"), - body: errs.map((err) => t(err.message)), - }); - return false; - } - return true; - }; - - const validateField = (name, value) => { - const fieldSchema = Joi.object({ - [name]: editUserValidation.extract(name), - }); - - const { error } = fieldSchema.validate({ [name]: value }); - - setErrors((prev) => { - const prevErrors = { ...prev }; - if (error) prevErrors[name] = error.details[0].message; - else delete prevErrors[name]; - return prevErrors; - }); - }; - - return [errors, validateForm, validateField]; -}; diff --git a/client/src/Pages/Account/EditUser/old.jsx b/client/src/Pages/Account/EditUser/old.jsx deleted file mode 100644 index 2b2ac5128..000000000 --- a/client/src/Pages/Account/EditUser/old.jsx +++ /dev/null @@ -1,136 +0,0 @@ -// Components -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Search from "@/Components/v1/Inputs/Search/index.jsx"; -import Button from "@mui/material/Button"; -import RoleTable from "../components/RoleTable/index.jsx"; -import ChangePasswordModal from "@/Pages/Account/components/ChangePasswordModal/index.jsx"; -// Utils -import { useParams } from "react-router-dom"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { useGetUser, useEditUser } from "../../../Hooks/userHooks.js"; -import { EDITABLE_ROLES, ROLES } from "../../../Utils/roleUtils.js"; -import { useEditUserForm, useValidateEditUserForm } from "./hooks/editUser.js"; -import { useSelector } from "react-redux"; - -const EditUser = () => { - const { user } = useSelector((state) => state.auth); - const { userId } = useParams(); - const isSameUser = user?._id === userId; - const theme = useTheme(); - const { t } = useTranslation(); - const BREADCRUMBS = [ - { name: t("menu.team"), path: "/account/team" }, - { name: t("editUserPage.title"), path: "" }, - ]; - - const [userToEdit, isLoading, error] = useGetUser(userId); - const [editUser, isSaving, saveError, changePassword] = useEditUser(userId); - const [ - form, - setForm, - handleRoleChange, - handleDeleteRole, - searchInput, - handleSearchInput, - ] = useEditUserForm(userToEdit); - const [errors, validateForm, validateField] = useValidateEditUserForm(); - - const onChange = (e) => { - const name = e.target.name; - const value = e.target.value; - validateField(name, value); - setForm({ ...form, [name]: value }); - }; - - const handleSubmit = (e) => { - e.preventDefault(); - const valid = validateForm(form); - if (valid) { - editUser(form); - } - }; - - return ( - - - {t("editUserPage.title")} - - - - - role !== ROLES.SUPERADMIN) - .map((role) => ({ role, _id: role })) || [] - } - options={EDITABLE_ROLES} - multiple={true} - handleChange={handleRoleChange} - /> - - - - {!isSameUser && ( - - )} - - - - ); -}; - -export default EditUser; diff --git a/client/src/Pages/Account/components/ChangePasswordModal/index.jsx b/client/src/Pages/Account/components/ChangePasswordModal/index.jsx deleted file mode 100644 index 725a9b00e..000000000 --- a/client/src/Pages/Account/components/ChangePasswordModal/index.jsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useState } from "react"; -import { Button, Stack } from "@mui/material"; -import { GenericDialog } from "@/Components/v1/Dialog/genericDialog"; -import TextInput from "@/Components/v1/Inputs/TextInput"; -import PasswordTooltip from "@/Pages/Auth/components/PasswordTooltip"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { createToast } from "../../../../Utils/toastUtils"; -import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments"; -import usePasswordFeedback from "@/Pages/Auth/hooks/usePasswordFeedback"; -import PropTypes from "prop-types"; - -const ChangePasswordModal = ({ isSaving, isLoading, changePassword }) => { - const INITIAL_FORM_STATE = { - password: "", - confirm: "", - }; - const theme = useTheme(); - const { t } = useTranslation(); - const { feedback, handlePasswordFeedback } = usePasswordFeedback(); - const [form, setForm] = useState(INITIAL_FORM_STATE); - const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); - const [errors, setErrors] = useState({}); - const [isLoadingSubmit, setIsLoadingSubmit] = useState(false); - const closeChangePasswordModal = () => { - setIsChangePasswordOpen(false); - setForm(INITIAL_FORM_STATE); - }; - - const onChange = (e) => { - let { name, value } = e.target; - const updatedForm = { ...form, [name]: value }; - setForm(updatedForm); - - handlePasswordFeedback(updatedForm, name, value, form, errors, setErrors); - }; - const isFormValid = - form.password.length > 1 && - form.confirm.length > 1 && - !errors.password && - !errors.confirm; - const onsubmitChangePassword = async (event) => { - event.preventDefault(); - if (!isFormValid) return; - const newPasswordForm = { - password: form.password, - }; - try { - setIsLoadingSubmit(true); - await changePassword(newPasswordForm); - closeChangePasswordModal(); - } catch (error) { - const errorMsg = error.response?.data?.msg || error.message || "unknownError"; - createToast({ - type: "error", - body: t(errorMsg), - }); - } finally { - setIsLoadingSubmit(false); - } - }; - - return ( - <> - - - - } - sx={{ mb: theme.spacing(5) }} - /> - - } - sx={{ mb: theme.spacing(5) }} - /> - - - - - - - - ); -}; - -ChangePasswordModal.propTypes = { - isSaving: PropTypes.bool.isRequired, - isLoading: PropTypes.bool.isRequired, - changePassword: PropTypes.func.isRequired, -}; - -export default ChangePasswordModal; diff --git a/client/src/Pages/Account/components/RoleTable/index.jsx b/client/src/Pages/Account/components/RoleTable/index.jsx deleted file mode 100644 index 90a30dcd4..000000000 --- a/client/src/Pages/Account/components/RoleTable/index.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import Typography from "@mui/material/Typography"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Icon from "@/Components/v1/Icon"; - -import { useTranslation } from "react-i18next"; -import { ROLES } from "../../../../Utils/roleUtils.js"; - -const RoleTable = ({ roles, handleDeleteRole }) => { - const { t } = useTranslation(); - const HEADERS = [ - { - id: "name", - content: {t("editUserPage.table.roleHeader")}, - render: (row) => { - return row; - }, - }, - { - id: "delete", - content: {t("editUserPage.table.actionHeader")}, - render: (row) => { - if (row === ROLES.SUPERADMIN) return null; - return ( - { - handleDeleteRole(row); - }} - style={{ cursor: "pointer" }} - /> - ); - }, - }, - ]; - return ( - - ); -}; - -export default RoleTable; From 48fb422e0fbd946f2ac86a48fbca5e6d0eeea8b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 21:00:27 +0000 Subject: [PATCH 238/392] initial commit --- client/src/Pages/Auth/SetNewPassword/index.tsx | 14 ++++++++++++++ client/src/Routes/index.jsx | 10 ++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/Auth/SetNewPassword/index.tsx diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx new file mode 100644 index 000000000..d6810e9d3 --- /dev/null +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -0,0 +1,14 @@ +import { BaseAuthPage } from "@/Components/v2/design-elements"; + +const SetNewPasswordPage = () => { + return ( + + Test + + ); +}; + +export default SetNewPasswordPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 4c68c0367..cd585b0f6 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -12,7 +12,7 @@ import AuthLogin from "../Pages/Auth/Login"; import AuthRegister from "@/Pages/Auth/Register"; import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; -import AuthSetNewPassword from "../Pages/Auth/SetNewPassword.jsx"; +import AuthSetNewPassword from "../Pages/Auth/SetNewPassword"; import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed.jsx"; // Uptime @@ -423,7 +423,13 @@ const Routes = () => { /> } + element={ + <> + + + + + } /> Date: Mon, 9 Feb 2026 21:21:17 +0000 Subject: [PATCH 239/392] basic UI --- .../src/Pages/Auth/SetNewPassword/index.tsx | 33 ++++++++++++++++--- .../Auth/{SetNewPassword.jsx => old.jsx} | 0 client/src/locales/en.json | 5 ++- 3 files changed, 33 insertions(+), 5 deletions(-) rename client/src/Pages/Auth/{SetNewPassword.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx index d6810e9d3..cf9f27acb 100644 --- a/client/src/Pages/Auth/SetNewPassword/index.tsx +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -1,12 +1,37 @@ -import { BaseAuthPage } from "@/Components/v2/design-elements"; +import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; +import { Button, TextField } from "@/Components/v2/inputs"; +import { useTranslation } from "react-i18next"; const SetNewPasswordPage = () => { + const { t } = useTranslation(); return ( - Test + + + + ); }; diff --git a/client/src/Pages/Auth/SetNewPassword.jsx b/client/src/Pages/Auth/old.jsx similarity index 100% rename from client/src/Pages/Auth/SetNewPassword.jsx rename to client/src/Pages/Auth/old.jsx diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 4b3885cf2..95c374428 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -665,6 +665,10 @@ "linkText": "login" } } + }, + "setNewPassword": { + "title": "Reset your password", + "subtitle": "Your new password must be different from previously used passwords." } }, "checks": { @@ -1064,7 +1068,6 @@ "duration": { "label": "Duration" }, - "startTime": { "label": "Start time" } From f5372b4d869c54171d8a0a1edddf9960f117fc14 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 21:29:55 +0000 Subject: [PATCH 240/392] ui and strings --- .../src/Pages/Auth/SetNewPassword/index.tsx | 36 ++++++++++++++++++- client/src/locales/en.json | 9 +++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx index cf9f27acb..804881546 100644 --- a/client/src/Pages/Auth/SetNewPassword/index.tsx +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -1,9 +1,17 @@ -import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; +import { + BaseAuthPage, + BulletPointCheck, + TextLink, +} from "@/Components/v2/design-elements"; import { Button, TextField } from "@/Components/v2/inputs"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; const SetNewPasswordPage = () => { + const theme = useTheme(); const { t } = useTranslation(); + return ( { fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")} placeholder={t("pages.auth.common.form.option.password.placeholder")} /> + + + + + + + + diff --git a/client/src/Validation/setNewPassword.ts b/client/src/Validation/setNewPassword.ts new file mode 100644 index 000000000..26e13beb2 --- /dev/null +++ b/client/src/Validation/setNewPassword.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { specialCharPattern } from "@/Validation/patterns"; + +export const setNewPasswordSchema = z + .object({ + password: z + .string() + .min(1, "Password is required") + .min(8, "Password must be at least 8 characters") + .refine((val) => /[A-Z]/.test(val), "Password must contain an uppercase letter") + .refine((val) => /[a-z]/.test(val), "Password must contain a lowercase letter") + .refine((val) => /\d/.test(val), "Password must contain a number") + .refine( + (val) => specialCharPattern.test(val), + "Password must contain a special character" + ), + confirm: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.password === data.confirm, { + message: "Passwords do not match", + path: ["confirm"], + }); + +export type SetNewPasswordFormData = z.infer; From b47e17fe91c31ac7810da32b0a83f6868bd3df8e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 22:07:02 +0000 Subject: [PATCH 242/392] cleanup --- client/src/Pages/Auth/CheckEmail.jsx | 248 --------------- .../src/Pages/Auth/NewPasswordConfirmed.jsx | 142 --------- .../Pages/Auth/components/PasswordTooltip.jsx | 116 ------- .../Pages/Auth/hooks/usePasswordFeedback.jsx | 71 ----- .../Pages/Auth/hooks/useValidatePassword.jsx | 70 ----- client/src/Pages/Auth/index.css | 112 ------- client/src/Pages/Auth/old.jsx | 283 ------------------ client/src/Routes/index.jsx | 10 - 8 files changed, 1052 deletions(-) delete mode 100644 client/src/Pages/Auth/CheckEmail.jsx delete mode 100644 client/src/Pages/Auth/NewPasswordConfirmed.jsx delete mode 100644 client/src/Pages/Auth/components/PasswordTooltip.jsx delete mode 100644 client/src/Pages/Auth/hooks/usePasswordFeedback.jsx delete mode 100644 client/src/Pages/Auth/hooks/useValidatePassword.jsx delete mode 100644 client/src/Pages/Auth/index.css delete mode 100644 client/src/Pages/Auth/old.jsx diff --git a/client/src/Pages/Auth/CheckEmail.jsx b/client/src/Pages/Auth/CheckEmail.jsx deleted file mode 100644 index 10cc2ecc6..000000000 --- a/client/src/Pages/Auth/CheckEmail.jsx +++ /dev/null @@ -1,248 +0,0 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; -import { useEffect, useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useDispatch } from "react-redux"; -import { useNavigate, useLocation } from "react-router-dom"; -import { createToast } from "../../Utils/toastUtils.jsx"; -import { forgotPassword } from "../../Features/Auth/authSlice.js"; -import { Trans, useTranslation } from "react-i18next"; -import Background from "@/assets/Images/background-grid.svg?react"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import IconBox from "@/Components/v1/IconBox/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import "./index.css"; - -const CheckEmail = () => { - const theme = useTheme(); - const navigate = useNavigate(); - const dispatch = useDispatch(); - const { t } = useTranslation(); - const location = useLocation(); - - const [email, setEmail] = useState(); - const [disabled, setDisabled] = useState(false); - useEffect(() => { - setEmail(sessionStorage.getItem("email")); - }, []); - - // TODO - fix - const openMail = () => { - window.location.href = "mailto:"; - }; - - const toastFail = [ - { - body: t("auth.forgotPassword.toasts.emailNotFound"), - }, - { - body: t("auth.forgotPassword.toasts.redirect").replace("", "3"), - }, - { - body: t("auth.forgotPassword.toasts.redirect").replace("", "2"), - }, - { - body: t("auth.forgotPassword.toasts.redirect").replace("", "1"), - }, - ]; - - const resendToken = async () => { - setDisabled(true); // prevent resent button from being spammed - if (!email) { - let index = 0; - const interval = setInterval(() => { - if (index < toastFail.length) { - createToast(toastFail[index]); - index++; - } else { - clearInterval(interval); - navigate("/forgot-password"); - } - }, 1000); - } else { - const form = { email: email }; - const action = await dispatch(forgotPassword(form)); - if (action.payload.success) { - createToast({ - body: t("auth.forgotPassword.toasts.sent").replace("", form.email), - }); - setDisabled(false); - } else { - if (action.payload) { - // dispatch errors - createToast({ - body: action.payload.msg, // FIXME: Potential untranslated string - }); - } else { - // unknown errors - createToast({ - body: t("common.toasts.unknownError"), - }); - } - } - } - }; - - const handleNavigate = () => { - sessionStorage.removeItem("email"); - location.state?.from - ? navigate(location.state.from, { replace: true }) - : navigate("/login"); - }; - - return ( - - - - - - - {t("common.appName")} - - .MuiStack-root": { - border: 1, - borderRadius: theme.spacing(5), - borderColor: theme.palette.primary.lowContrast, - backgroundColor: theme.palette.primary.main, - padding: { - xs: theme.spacing(12), - sm: theme.spacing(20), - }, - }, - }} - > - - - - - - - - {t("auth.forgotPassword.heading")} - - - {email || "username@email.com"} - - ), - }} - /> - - - - - - ), - }} - /> - - - - - - Account Password" - : "auth.forgotPassword.links.login" - } - components={{ - a: ( - - ), - }} - /> - - - - ); -}; - -export default CheckEmail; diff --git a/client/src/Pages/Auth/NewPasswordConfirmed.jsx b/client/src/Pages/Auth/NewPasswordConfirmed.jsx deleted file mode 100644 index c92e52552..000000000 --- a/client/src/Pages/Auth/NewPasswordConfirmed.jsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { useDispatch } from "react-redux"; -import { clearAuthState } from "../../Features/Auth/authSlice.js"; -import Background from "@/assets/Images/background-grid.svg?react"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import IconBox from "@/Components/v1/IconBox/index.jsx"; -import Icon from "@/Components/v1/Icon"; -import { Trans, useTranslation } from "react-i18next"; -import "./index.css"; - -const NewPasswordConfirmed = () => { - const theme = useTheme(); - const navigate = useNavigate(); - const dispatch = useDispatch(); - const { t } = useTranslation(); - - const handleNavigate = () => { - dispatch(clearAuthState()); - navigate("/login"); - }; - - return ( - - - - - - - {t("common.appName")} - - .MuiStack-root": { - border: 1, - borderRadius: theme.spacing(5), - borderColor: theme.palette.primary.lowContrast, - backgroundColor: theme.palette.primary.main, - padding: { - xs: theme.spacing(12), - sm: theme.spacing(20), - }, - }, - }} - > - - - - - - - - {t("auth.forgotPassword.heading")} - - {t("auth.forgotPassword.subheadings.stepFour")} - - - - - - - - - ), - }} - /> - - - - ); -}; - -export default NewPasswordConfirmed; diff --git a/client/src/Pages/Auth/components/PasswordTooltip.jsx b/client/src/Pages/Auth/components/PasswordTooltip.jsx deleted file mode 100644 index 361850aca..000000000 --- a/client/src/Pages/Auth/components/PasswordTooltip.jsx +++ /dev/null @@ -1,116 +0,0 @@ -import Check from "@/Components/v1/Check/Check.jsx"; -import Stack from "@mui/material/Stack"; -import { Tooltip, useTheme } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; - -const PasswordTooltip = ({ feedback, form, children }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const hasPassword = form.password.length > 0; - const hasInvalidFeedback = Object.values(feedback).some( - (status) => status !== "success" - ); - const showPasswordTooltip = hasPassword && hasInvalidFeedback; - return ( - - - - - - - - - } - slotProps={{ - tooltip: { - sx: { - backgroundColor: theme.palette.tertiary.main, - border: `0.5px solid ${theme.palette.primary.lowContrast}90`, - borderRadius: theme.spacing(4), - color: theme.palette.primary.contrastText, - width: "auto", - maxWidth: { xs: "25vw", md: "none" }, - whiteSpace: { xs: "normal", md: "nowrap" }, - paddingTop: theme.spacing(8), - px: theme.spacing(8), - }, - }, - arrow: { - sx: { - color: theme.palette.tertiary.main, - }, - }, - }} - > - {children} - - ); -}; - -PasswordTooltip.propTypes = { - feedback: PropTypes.shape({ - length: PropTypes.string, - special: PropTypes.string, - number: PropTypes.string, - uppercase: PropTypes.string, - lowercase: PropTypes.string, - confirm: PropTypes.string, - }), - form: PropTypes.shape({ - password: PropTypes.string.isRequired, - }), - children: PropTypes.node, -}; - -export default PasswordTooltip; diff --git a/client/src/Pages/Auth/hooks/usePasswordFeedback.jsx b/client/src/Pages/Auth/hooks/usePasswordFeedback.jsx deleted file mode 100644 index 73e8fb738..000000000 --- a/client/src/Pages/Auth/hooks/usePasswordFeedback.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from "react"; -import { newOrChangedCredentials } from "../../../Validation/validation.js"; - -const usePasswordFeedback = () => { - const [feedback, setFeedback] = useState({}); - const getFeedbackStatus = (form, errors, field, criteria) => { - const fieldErrors = errors?.[field]; - const isFieldEmpty = form?.[field]?.length === 0; - const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty"); - const isCorrect = !isFieldEmpty && !hasError; - - if (isCorrect) { - return "success"; - } else if (hasError) { - return "error"; - } else { - return "info"; - } - }; - - const handlePasswordFeedback = (updatedForm, name, value, form, errors, setErrors) => { - const validateValue = { [name]: value }; - const validateOptions = { abortEarly: false, context: { password: form.password } }; - if (name === "password" && form.confirm.length > 0) { - validateValue.confirm = form.confirm; - validateOptions.context = { password: value }; - } else if (name === "confirm") { - validateValue.password = form.password; - } - const { error } = newOrChangedCredentials.validate(validateValue, validateOptions); - - const pwdErrors = error?.details.map((error) => ({ - path: error.path[0], - type: error.type, - })); - - const errorsByPath = - pwdErrors && - pwdErrors.reduce((acc, { path, type }) => { - if (!acc[path]) { - acc[path] = []; - } - acc[path].push(type); - return acc; - }, {}); - - const oldErrors = { ...errors }; - if (name === "password") { - oldErrors.password = undefined; - } else if (name === "confirm") { - oldErrors.confirm = undefined; - } - const newErrors = { ...oldErrors, ...errorsByPath }; - - setErrors(newErrors); - - const newFeedback = { - length: getFeedbackStatus(updatedForm, errorsByPath, "password", "string.min"), - special: getFeedbackStatus(updatedForm, errorsByPath, "password", "special"), - number: getFeedbackStatus(updatedForm, errorsByPath, "password", "number"), - uppercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "uppercase"), - lowercase: getFeedbackStatus(updatedForm, errorsByPath, "password", "lowercase"), - confirm: getFeedbackStatus(updatedForm, errorsByPath, "confirm", "different"), - }; - - setFeedback(newFeedback); - }; - return { feedback, handlePasswordFeedback, getFeedbackStatus }; -}; - -export default usePasswordFeedback; diff --git a/client/src/Pages/Auth/hooks/useValidatePassword.jsx b/client/src/Pages/Auth/hooks/useValidatePassword.jsx deleted file mode 100644 index 014804d35..000000000 --- a/client/src/Pages/Auth/hooks/useValidatePassword.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useMemo, useState } from "react"; -import { newOrChangedCredentials } from "../../../Validation/validation.js"; - -const getFeedbackStatus = (form, errors, field, criteria) => { - const fieldErrors = errors[field]; - const isFieldEmpty = form[field].length === 0; - const hasError = fieldErrors?.includes(criteria) || fieldErrors?.includes("empty"); - const isCorrect = !isFieldEmpty && !hasError; - - if (isCorrect) { - return "success"; - } else if (hasError) { - return "error"; - } else { - return "info"; - } -}; - -function useValidatePassword() { - const [errors, setErrors] = useState({}); - const [form, setForm] = useState({ - password: "", - confirm: "", - }); - - const handleChange = (event) => { - const { value, name } = event.target; - setForm((prev) => ({ ...prev, [name]: value })); - - const validateValue = { [name]: value }; - const validateOptions = { abortEarly: false, context: { password: form.password } }; - if (name === "password" && form.confirm.length > 0) { - validateValue.confirm = form.confirm; - validateOptions.context = { password: value }; - } else if (name === "confirm") { - validateValue.password = form.password; - } - - const { error } = newOrChangedCredentials.validate(validateValue, validateOptions); - const errors = error?.details.map((error) => ({ - path: error.path[0], - type: error.type, - })); - const errorsByPath = - errors && - errors.reduce((acc, { path, type }) => { - if (!acc[path]) { - acc[path] = []; - } - acc[path].push(type); - return acc; - }, {}); - setErrors(() => (errorsByPath ? { ...errorsByPath } : {})); - }; - - const feedbacks = useMemo( - () => ({ - length: getFeedbackStatus(form, errors, "password", "string.min"), - special: getFeedbackStatus(form, errors, "password", "special"), - number: getFeedbackStatus(form, errors, "password", "number"), - uppercase: getFeedbackStatus(form, errors, "password", "uppercase"), - lowercase: getFeedbackStatus(form, errors, "password", "lowercase"), - confirm: getFeedbackStatus(form, errors, "confirm", "different"), - }), - [form, errors] - ); - - return { handleChange, feedbacks, form, errors }; -} -export { useValidatePassword }; diff --git a/client/src/Pages/Auth/index.css b/client/src/Pages/Auth/index.css deleted file mode 100644 index 86ab05c13..000000000 --- a/client/src/Pages/Auth/index.css +++ /dev/null @@ -1,112 +0,0 @@ -/* AUTH */ -.auth { - height: 100vh; -} -.auth h1 { - font-size: var(--env-var-font-size-xlarge); - font-weight: 600; -} -.auth button:not(.MuiIconButton-root) { - font-size: var(--env-var-font-size-medium-plus); -} - -.auth p + span { - opacity: 0.8; - cursor: pointer; - transition: opacity 300ms ease-in; -} -.auth p > span:not(.email-sent-to) { - text-decoration: underline; - text-underline-offset: 2px; - cursor: pointer; - transition: all 200ms; -} -.auth p > span:not(.email-sent-to):hover { - text-underline-offset: 4px; -} -.auth p + span:hover { - opacity: 1; -} -.auth button:not(.MuiIconButton-root) { - user-select: none; - border-radius: var(--env-var-radius-2); - line-height: 1; -} -.auth .field svg { - width: 24px; - height: 24px; -} -.auth .field h3.MuiTypography-root, -.auth .field .input-error, -.auth .check span.MuiTypography-root { - font-size: var(--env-var-font-size-medium); -} -.auth form + form { - margin: var(--env-var-spacing-1-plus) 0; -} -.auth .email-sent-to { - font-weight: 600; - cursor: default; -} - -.auth > .MuiStack-root:nth-of-type(2) { - height: var(--env-var-nav-bar-height); -} -.auth .background-pattern-svg { - position: absolute; - top: 0; - left: 50%; - transform: translate(-50%, -30%); - z-index: -1; - - width: 100%; - max-width: 800px; - height: 100%; - max-height: 800px; - - background-position: center; - background-size: cover; - background-repeat: no-repeat; -} - -.auth .field { - position: relative; -} -.auth .input-error { - position: absolute; - top: calc(100% - 2px); -} - -@media (max-width: 800px) { - .auth h1 { - font-size: var(--env-var-font-size-large-plus); - } - .auth button:not(.MuiIconButton-root), - .auth .field input, - .auth p, - .auth span { - font-size: var(--env-var-font-size-medium); - } - .auth .check span.MuiTypography-root, - .auth .field .input-error, - .auth .field h3.MuiTypography-root { - font-size: var(--env-var-font-size-small-plus); - } - .auth .check span > span { - display: none; - } - .auth form + form { - margin: var(--env-var-spacing-1-plus) 0; - } - .auth .background-pattern-svg { - max-width: 750px; - max-height: 750px; - } - - .forgot-password-page h1, - .check-email-page h1, - .password-confirmed-page h1, - .set-new-password-page h1 { - font-size: 18px; - } -} diff --git a/client/src/Pages/Auth/old.jsx b/client/src/Pages/Auth/old.jsx deleted file mode 100644 index ada5d6246..000000000 --- a/client/src/Pages/Auth/old.jsx +++ /dev/null @@ -1,283 +0,0 @@ -import { useId } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { useNavigate } from "react-router-dom"; -import { useParams } from "react-router-dom"; -import { useTheme } from "@emotion/react"; -import { Box, Stack, Typography, Button } from "@mui/material"; -import { setNewPassword } from "../../Features/Auth/authSlice.js"; -import { createToast } from "../../Utils/toastUtils.jsx"; -import { newOrChangedCredentials } from "../../Validation/validation.js"; -import Check from "@/Components/v1/Check/Check.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import IconBox from "@/Components/v1/IconBox/index.jsx"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import Background from "@/assets/Images/background-grid.svg?react"; -import Icon from "@/Components/v1/Icon"; -import "./index.css"; -import { useValidatePassword } from "./hooks/useValidatePassword.jsx"; -import { Trans, useTranslation } from "react-i18next"; - -const SetNewPassword = () => { - const navigate = useNavigate(); - const dispatch = useDispatch(); - const theme = useTheme(); - const { t } = useTranslation(); - - const passwordId = useId(); - const confirmPasswordId = useId(); - - const { form, errors, handleChange, feedbacks } = useValidatePassword(); - - const { isLoading } = useSelector((state) => state.auth); - const { token } = useParams(); - - const handleSubmit = async (e) => { - e.preventDefault(); - const { error } = newOrChangedCredentials.validate(form, { - abortEarly: false, - context: { password: form.password }, - }); - - if (error) { - createToast({ - body: - error.details && error.details.length > 0 - ? error.details[0].message // FIXME: Potential untranslated string - : t("auth.common.errors.validation"), - }); - } else { - const action = await dispatch(setNewPassword({ token, form })); - if (action.payload.success) { - navigate("/new-password-confirmed"); - createToast({ - body: t("auth.forgotPassword.toasts.success"), - }); - } else { - const errorMessage = action.payload - ? action.payload.msg // FIXME: Potential untranslated string - : t("auth.forgotPassword.toasts.error"); - createToast({ - body: errorMessage, - }); - } - } - }; - - return ( - - - - - - - {t("common.appName")} - - .MuiStack-root": { - border: 1, - borderRadius: theme.spacing(5), - borderColor: theme.palette.primary.lowContrast, - backgroundColor: theme.palette.primary.main, - padding: { - xs: theme.spacing(12), - sm: theme.spacing(20), - }, - }, - }} - > - - - - - - - - {t("auth.forgotPassword.heading")} - {t("auth.forgotPassword.subheadings.stepThree")} - - - - } - /> - - - } - /> - - - - - - - - - - - - - - - - navigate("/login")} - sx={{ userSelect: "none" }} - /> - ), - }} - /> - - - - ); -}; - -export default SetNewPassword; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index cd585b0f6..9f7238aa5 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -11,9 +11,7 @@ import NotFound from "../Pages/NotFound/index.jsx"; import AuthLogin from "../Pages/Auth/Login"; import AuthRegister from "@/Pages/Auth/Register"; import AuthForgotPassword from "@/Pages/Auth/Recovery"; -import AuthCheckEmail from "../Pages/Auth/CheckEmail.jsx"; import AuthSetNewPassword from "../Pages/Auth/SetNewPassword"; -import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed.jsx"; // Uptime import Uptime from "../Pages/Uptime/Monitors"; @@ -417,10 +415,6 @@ const Routes = () => { } /> - } - /> { } /> - } - /> Date: Mon, 9 Feb 2026 22:19:10 +0000 Subject: [PATCH 243/392] initial commit --- client/src/Pages/NotFound/index.jsx | 78 ----------------------------- client/src/Pages/NotFound/index.tsx | 62 +++++++++++++++++++++++ client/src/Routes/index.jsx | 24 +++++---- client/src/locales/en.json | 7 ++- 4 files changed, 83 insertions(+), 88 deletions(-) delete mode 100644 client/src/Pages/NotFound/index.jsx create mode 100644 client/src/Pages/NotFound/index.tsx diff --git a/client/src/Pages/NotFound/index.jsx b/client/src/Pages/NotFound/index.jsx deleted file mode 100644 index 27e3173b0..000000000 --- a/client/src/Pages/NotFound/index.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import PropTypes from "prop-types"; -import NotFoundSvg from "@/assets/Images/sushi_404.svg"; -import { Button, Stack, Typography } from "@mui/material"; -import { useNavigate } from "react-router-dom"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; - -/** - * Support for defaultProps will be removed from function components in a future major release - * So That why we're using JavaScript default parameters instead. - */ -const DefaultValue = { - title: "Oh no! You dropped your sushi!", - desc: "Either the URL doesn’t exist, or you don’t have access to it.", -}; - -/** - * Renders an error page component with a customizable title and description. - * - * @component - * @example - * // Usage: - * - * - * @param {Object} props - The component props. - * @param {string} [props.title="Oh no! You dropped your sushi!"] - The title of the error page. - * @param {string} [props.desc="Either the URL doesn’t exist, or you don’t have access to it."] - The description of the error page. - * @returns {JSX.Element} The rendered error page component. - */ -const NotFound = ({ title = DefaultValue.title, desc = DefaultValue.desc }) => { - const navigate = useNavigate(); - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - 404 - - {title} - - {desc} - - - - ); -}; - -NotFound.propTypes = { - title: PropTypes.string, - desc: PropTypes.string, -}; - -export default NotFound; diff --git a/client/src/Pages/NotFound/index.tsx b/client/src/Pages/NotFound/index.tsx new file mode 100644 index 000000000..9263a8d9c --- /dev/null +++ b/client/src/Pages/NotFound/index.tsx @@ -0,0 +1,62 @@ +import NotFoundSvg from "@/assets/Images/sushi_404.svg"; +import { Button } from "@/Components/v2/inputs"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; + +import { useNavigate } from "react-router-dom"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +interface NotFoundProps { + title: string; + desc: string; +} + +const NotFoundPage = ({ title, desc }: NotFoundProps) => { + const navigate = useNavigate(); + const theme = useTheme(); + const { t } = useTranslation(); + + if (!title || title === "") { + title = t("pages.notFound.title"); + } + + if (!desc || desc === "") { + desc = t("pages.notFound.subtitle"); + } + + return ( + + + 404 + + {title} + + {desc} + + + + ); +}; + +export default NotFoundPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 9f7238aa5..4711906c7 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -5,25 +5,25 @@ import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; import { useSelector } from "react-redux"; import { Navigate, Route, Routes as LibRoutes } from "react-router"; import HomeLayout from "@/Components/v1/Layouts/HomeLayout"; -import NotFound from "../Pages/NotFound/index.jsx"; +import NotFound from "@/Pages/NotFound"; // Auth -import AuthLogin from "../Pages/Auth/Login"; +import AuthLogin from "@/Pages/Auth/Login"; import AuthRegister from "@/Pages/Auth/Register"; import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthSetNewPassword from "../Pages/Auth/SetNewPassword"; // Uptime -import Uptime from "../Pages/Uptime/Monitors"; -import UptimeDetails from "../Pages/Uptime/Details"; +import Uptime from "@/Pages/Uptime/Monitors"; +import UptimeDetails from "@/Pages/Uptime/Details"; // PageSpeed -import PageSpeed from "../Pages/PageSpeed/Monitors/index"; -import PageSpeedDetails from "../Pages/PageSpeed/Details/"; +import PageSpeed from "@/Pages/PageSpeed/Monitors/"; +import PageSpeedDetails from "@/Pages/PageSpeed/Details/"; // Infrastructure -import Infrastructure from "../Pages/Infrastructure/Monitors"; -import InfrastructureDetails from "../Pages/Infrastructure/Details/index"; +import Infrastructure from "@/Pages/Infrastructure/Monitors"; +import InfrastructureDetails from "@/Pages/Infrastructure/Details/index"; // Server Status import ServerUnreachable from "../Pages/ServerUnreachable.jsx"; @@ -442,7 +442,13 @@ const Routes = () => { /> } + element={ + <> + + + + + } /> ); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 35132ce6c..cbd9ea7b7 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -221,7 +221,8 @@ "test": "Test", "testNotifications": "Test notifications", "toggleTheme": "Toggles light & dark", - "flushQueue": "Flush queue" + "flushQueue": "Flush queue", + "notFound": "Go to the main dashboard" }, "charts": { "labels": { @@ -550,6 +551,10 @@ "now": "Now", "os": "OS", "pages": { + "notFound": { + "title": "Oh no! You dropped your sushi!", + "subtitle": "Either the URL doesn’t exist, or you don’t have access to it." + }, "account": { "tabs": { "profile": "Profile", From 06c571d89fd34c3d27bbe8a112c15b9722728e1b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 22:22:01 +0000 Subject: [PATCH 244/392] img -> box --- client/src/Pages/NotFound/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/NotFound/index.tsx b/client/src/Pages/NotFound/index.tsx index 9263a8d9c..709f808be 100644 --- a/client/src/Pages/NotFound/index.tsx +++ b/client/src/Pages/NotFound/index.tsx @@ -1,5 +1,6 @@ import NotFoundSvg from "@/assets/Images/sushi_404.svg"; import { Button } from "@/Components/v2/inputs"; +import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; @@ -34,10 +35,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => { gap={theme.spacing(2)} alignItems="center" > - 404 Date: Mon, 9 Feb 2026 22:56:56 +0000 Subject: [PATCH 245/392] update slice --- client/src/Features/Auth/authSlice.js | 262 -------------------------- client/src/Features/Auth/authSlice.ts | 53 ++++++ client/src/Features/UI/uiSlice.js | 2 - 3 files changed, 53 insertions(+), 264 deletions(-) delete mode 100644 client/src/Features/Auth/authSlice.js create mode 100644 client/src/Features/Auth/authSlice.ts diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js deleted file mode 100644 index d6c63b6a9..000000000 --- a/client/src/Features/Auth/authSlice.js +++ /dev/null @@ -1,262 +0,0 @@ -import { networkService } from "../../main"; -import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; -import axios from "axios"; - -const initialState = { - isLoading: false, - authToken: "", - user: "", - success: null, - msg: null, -}; - -export const register = createAsyncThunk( - "auth/register", - async ({ user, token }, thunkApi) => { - try { - const res = await networkService.registerUser(user, token); - return res.data; - } catch (error) { - if (error.response.data) { - return thunkApi.rejectWithValue(error.response.data); - } - const payload = { - status: false, - msg: error.message ? error.message : "Unknown error", - }; - return thunkApi.rejectWithValue(payload); - } - } -); - -export const login = createAsyncThunk("auth/login", async (form, thunkApi) => { - try { - const res = await networkService.loginUser(form); - return res.data; - } catch (error) { - if (error.response && error.response.data) { - return thunkApi.rejectWithValue(error.response.data); - } - const payload = { - status: false, - msg: error.message ? error.message : "Unknown error", - }; - return thunkApi.rejectWithValue(payload); - } -}); - -export const update = createAsyncThunk("auth/update", async (data, thunkApi) => { - const { localData: form } = data; - try { - const fd = new FormData(); - form.firstName && fd.append("firstName", form.firstName); - form.lastName && fd.append("lastName", form.lastName); - form.password && fd.append("password", form.password); - form.newPassword && fd.append("newPassword", form.newPassword); - if (form.file && form.file !== "") { - const imageResult = await axios.get(form.file, { - responseType: "blob", - }); - fd.append("profileImage", imageResult.data); - } - form.deleteProfileImage && fd.append("deleteProfileImage", form.deleteProfileImage); - - const res = await networkService.updateUser({ - form: fd, - }); - return res.data; - } catch (error) { - if (error.response && error.response.data) { - return thunkApi.rejectWithValue(error.response.data); - } - const payload = { - status: false, - msg: error.message ? error.message : "Unknown error", - }; - return thunkApi.rejectWithValue(payload); - } -}); - -export const deleteUser = createAsyncThunk("auth/delete", async (_, thunkApi) => { - try { - const res = await networkService.deleteUser(); - return res.data; - } catch (error) { - if (error.response && error.response.data) { - return thunkApi.rejectWithValue(error.response.data); - } - const payload = { - status: false, - msg: error.message ? error.message : "Unknown error", - }; - return thunkApi.rejectWithValue(payload); - } -}); - -export const forgotPassword = createAsyncThunk( - "auth/forgotPassword", - async (form, thunkApi) => { - try { - const res = await networkService.forgotPassword(form); - return res.data; - } catch (error) { - if (error.response.data) { - return thunkApi.rejectWithValue(error.response.data); - } - const payload = { - status: false, - msg: error.message ? error.message : "Unknown error", - }; - return thunkApi.rejectWithValue(payload); - } - } -); - -export const setNewPassword = createAsyncThunk( - "auth/setNewPassword", - async (data, thunkApi) => { - const { token, form } = data; - try { - await networkService.validateRecoveryToken({ recoveryToken: token }); - const res = await networkService.setNewPassword({ - recoveryToken: token, - form: form, - }); - return res.data; - } catch (error) { - if (error.response.data) { - return thunkApi.rejectWithValue(error.response.data); - } - const payload = { - status: false, - msg: error.message ? error.message : "Unknown error", - }; - return thunkApi.rejectWithValue(payload); - } - } -); - -const handleAuthFulfilled = (state, action) => { - state.isLoading = false; - state.success = action.payload.success; - state.msg = action.payload.msg; - state.authToken = action.payload.data.token; - state.user = action.payload.data.user; -}; -const handleAuthRejected = (state, action) => { - state.isLoading = false; - state.success = false; - state.msg = action.payload ? action.payload.msg : "Failed to login or register"; -}; -const handleUpdateFulfilled = (state, action) => { - state.isLoading = false; - state.success = action.payload.success; - state.msg = action.payload.msg; - state.user = action.payload.data; -}; -const handleUpdateRejected = (state, action) => { - state.isLoading = false; - state.success = false; - state.msg = action.payload ? action.payload.msg : "Failed to update profile data."; -}; -const handleDeleteFulfilled = (state, action) => { - state.isLoading = false; - state.success = action.payload.success; - state.msg = action.payload.msg; -}; -const handleDeleteRejected = (state, action) => { - state.isLoading = false; - state.success = false; - state.msg = action.payload ? action.payload.msg : "Failed to delete account."; -}; -const handleForgotFulfilled = (state, action) => { - state.isLoading = false; - state.success = action.payload.success; - state.msg = action.payload.msg; -}; -const handleForgotRejected = (state, action) => { - state.isLoading = false; - state.success = false; - state.msg = action.payload ? action.payload.msg : "Failed to send reset instructions."; -}; -const handleNewPasswordRejected = (state, action) => { - state.isLoading = false; - state.success = false; - state.msg = action.payload ? action.payload.msg : "Failed to reset password."; -}; - -const authSlice = createSlice({ - name: "auth", - initialState, - reducers: { - clearAuthState: (state) => { - state.authToken = ""; - state.user = ""; - state.isLoading = false; - state.success = true; - state.msg = "Logged out successfully"; - }, - setAuthState: (state, action) => { - state.isLoading = false; - state.success = action.payload.success; - state.msg = action.payload.msg; - state.authToken = action.payload.data.token; - state.user = action.payload.data.user; - }, - setUser: (state, action) => { - state.user = action.payload; - }, - }, - extraReducers: (builder) => { - // Register thunk - builder - .addCase(register.pending, (state) => { - state.isLoading = true; - }) - .addCase(register.fulfilled, handleAuthFulfilled) - .addCase(register.rejected, handleAuthRejected); - - // Login thunk - builder - .addCase(login.pending, (state) => { - state.isLoading = true; - }) - .addCase(login.fulfilled, handleAuthFulfilled) - .addCase(login.rejected, handleAuthRejected); - - // Update thunk - builder - .addCase(update.pending, (state) => { - state.isLoading = true; - }) - .addCase(update.fulfilled, handleUpdateFulfilled) - .addCase(update.rejected, handleUpdateRejected); - - // Delete thunk - builder - .addCase(deleteUser.pending, (state) => { - state.isLoading = true; - }) - .addCase(deleteUser.fulfilled, handleDeleteFulfilled) - .addCase(deleteUser.rejected, handleDeleteRejected); - - // Forgot password thunk - builder - .addCase(forgotPassword.pending, (state) => { - state.isLoading = true; - }) - .addCase(forgotPassword.fulfilled, handleForgotFulfilled) - .addCase(forgotPassword.rejected, handleForgotRejected); - - // Set new password thunk - builder - .addCase(setNewPassword.pending, (state) => { - state.isLoading = true; - }) - .addCase(setNewPassword.fulfilled, handleAuthFulfilled) - .addCase(setNewPassword.rejected, handleNewPasswordRejected); - }, -}); - -export default authSlice.reducer; -export const { clearAuthState, setAuthState, setUser } = authSlice.actions; diff --git a/client/src/Features/Auth/authSlice.ts b/client/src/Features/Auth/authSlice.ts new file mode 100644 index 000000000..aa4401c79 --- /dev/null +++ b/client/src/Features/Auth/authSlice.ts @@ -0,0 +1,53 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { User } from "@/Types/User"; + +interface AuthState { + isLoading: boolean; + authToken: string; + user: User | null; + success: boolean | null; + msg: string | null; +} + +const initialState: AuthState = { + isLoading: false, + authToken: "", + user: null, + success: null, + msg: null, +}; + +const authSlice = createSlice({ + name: "auth", + initialState, + reducers: { + clearAuthState: (state) => { + state.authToken = ""; + state.user = null; + state.isLoading = false; + state.success = true; + state.msg = "Logged out successfully"; + }, + setAuthState: ( + state, + action: PayloadAction<{ + success: boolean; + msg: string; + data: { token: string; user: User }; + }> + ) => { + state.isLoading = false; + state.success = action.payload.success; + state.msg = action.payload.msg; + state.authToken = action.payload.data.token; + state.user = action.payload.data.user; + }, + setUser: (state, action: PayloadAction) => { + state.user = action.payload; + }, + }, +}); + +export type { AuthState }; +export default authSlice.reducer; +export const { clearAuthState, setAuthState, setUser } = authSlice.actions; diff --git a/client/src/Features/UI/uiSlice.js b/client/src/Features/UI/uiSlice.js index 014940a9a..0e8ca8608 100644 --- a/client/src/Features/UI/uiSlice.js +++ b/client/src/Features/UI/uiSlice.js @@ -4,8 +4,6 @@ const initialMode = window?.matchMedia?.("(prefers-color-scheme: dark)")?.matche ? "dark" : "light"; -// Initial state for UI settings. -// Add more settings as needed (e.g., theme preferences, user settings) const initialState = { monitors: { rowsPerPage: 10, From 3d1bd94279f1694bca4b4c821a41fe92e23be283 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 23:02:26 +0000 Subject: [PATCH 246/392] remove unused hooks --- client/src/Hooks/checkHooks.js | 193 --------------------------- client/src/Hooks/inviteHooks.js | 65 --------- client/src/Hooks/logHooks.js | 111 --------------- client/src/Hooks/useNotifications.js | 58 -------- client/src/Hooks/userHooks.js | 81 ----------- 5 files changed, 508 deletions(-) delete mode 100644 client/src/Hooks/checkHooks.js delete mode 100644 client/src/Hooks/inviteHooks.js delete mode 100644 client/src/Hooks/logHooks.js delete mode 100644 client/src/Hooks/useNotifications.js delete mode 100644 client/src/Hooks/userHooks.js diff --git a/client/src/Hooks/checkHooks.js b/client/src/Hooks/checkHooks.js deleted file mode 100644 index 2d7edd677..000000000 --- a/client/src/Hooks/checkHooks.js +++ /dev/null @@ -1,193 +0,0 @@ -import { useState, useEffect } from "react"; -import { networkService } from "../main.jsx"; -import { createToast } from "../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -const useFetchChecksTeam = ({ - status, - sortOrder, - limit, - dateRange, - filter, - ack, - page, - rowsPerPage, - enabled = true, - updateTrigger, -}) => { - 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, - ack, - 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, - ack, - page, - rowsPerPage, - enabled, - updateTrigger, - ]); - - return [checks, checksCount, isLoading, networkError]; -}; - -const useFetchChecksByMonitor = ({ - monitorId, - type, - status, - sortOrder, - limit, - dateRange, - filter, - ack, - page, - rowsPerPage, - enabled = true, - updateTrigger, -}) => { - 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 || !type) { - return; - } - - const config = { - monitorId, - type, - status, - sortOrder, - limit, - dateRange, - filter, - ack, - page, - rowsPerPage, - }; - - try { - setIsLoading(true); - const res = await networkService.getChecksByMonitor(config); - setChecks(res.data.data.checks); - setChecksCount(res.data.data.checksCount); - } catch (error) { - setNetworkError(true); - createToast({ body: error.message }); - } finally { - setIsLoading(false); - } - }; - - fetchChecks(); - }, [ - monitorId, - type, - status, - sortOrder, - limit, - dateRange, - filter, - ack, - page, - rowsPerPage, - enabled, - updateTrigger, - ]); - - return [checks, checksCount, isLoading, networkError]; -}; - -const useFetchChecksSummaryByTeamId = ({ dateRange, updateTrigger } = {}) => { - const [summary, setSummary] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - const [networkError, setNetworkError] = useState(false); - - useEffect(() => { - const fetchSummary = async () => { - try { - setIsLoading(true); - - const res = await networkService.getChecksAndSummaryByTeamId({ dateRange }); - setSummary(res.data.data); - } catch (error) { - setNetworkError(true); - createToast({ body: error.message }); - } finally { - setIsLoading(false); - } - }; - - fetchSummary(); - }, [dateRange, updateTrigger]); - - return [summary, isLoading, networkError]; -}; - -const useResolveIncident = () => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); - - const resolveIncident = async (checkId, setUpdateTrigger) => { - try { - setIsLoading(true); - await networkService.updateCheckStatus({ - checkId, - ack: true, - }); - setUpdateTrigger((prev) => !prev); - } catch (error) { - createToast({ body: t("checkHooks.failureResolveOne") }); - } finally { - setIsLoading(false); - } - }; - - return [resolveIncident, isLoading]; -}; - -export { - useFetchChecksByMonitor, - useFetchChecksTeam, - useFetchChecksSummaryByTeamId, - useResolveIncident, -}; diff --git a/client/src/Hooks/inviteHooks.js b/client/src/Hooks/inviteHooks.js deleted file mode 100644 index c862eec31..000000000 --- a/client/src/Hooks/inviteHooks.js +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from "react"; -import { networkService } from "../main.jsx"; -import { useTranslation } from "react-i18next"; - -const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST; - -const useGetInviteToken = () => { - const { t } = useTranslation(); - - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - const [token, setToken] = useState(undefined); - - const clearToken = () => { - setToken(undefined); - }; - const fetchToken = async (email, role) => { - const response = await networkService.requestInvitationToken({ email, role }); - const token = response?.data?.data?.token; - if (typeof token === "undefined") { - throw new Error(t("inviteNoTokenFound")); - } - return token; - }; - const getInviteToken = async ({ email, role }) => { - try { - setIsLoading(true); - const token = await fetchToken(email, role); - let inviteLink = token; - - if (typeof CLIENT_HOST !== "undefined") { - inviteLink = `${CLIENT_HOST}/register/${token}`; - } - setToken(inviteLink); - return token; - } catch (error) { - setError(error); - } finally { - setIsLoading(false); - } - }; - - const addTeamMember = async (formData, role) => { - try { - setIsLoading(true); - const token = await fetchToken(formData.email, role); - const toSubmit = { - ...formData, - inviteToken: token, - }; - delete toSubmit.confirm; - const responseRegister = await networkService.registerUser(toSubmit); - return responseRegister; - } catch (error) { - setError(error); - throw error; - } finally { - setIsLoading(false); - } - }; - - return [getInviteToken, clearToken, isLoading, error, token, addTeamMember]; -}; - -export { useGetInviteToken }; diff --git a/client/src/Hooks/logHooks.js b/client/src/Hooks/logHooks.js deleted file mode 100644 index d5162de5a..000000000 --- a/client/src/Hooks/logHooks.js +++ /dev/null @@ -1,111 +0,0 @@ -import { useState, useEffect } from "react"; -import { networkService } from "../main.jsx"; -import { createToast } from "../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -const useFetchLogs = () => { - const { t } = useTranslation(); - const [logs, setLogs] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - - useEffect(() => { - const fetchLogs = async () => { - try { - setIsLoading(true); - const response = await networkService.getLogs(); - setLogs(response.data.data); - } catch (error) { - setError(error); - createToast({ - message: error.message, - }); - } finally { - setIsLoading(false); - } - }; - fetchLogs(); - }, [t]); - return [logs, isLoading, error]; -}; - -const useFetchQueueData = (trigger) => { - const [jobs, setJobs] = useState(undefined); - const [metrics, setMetrics] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - - useEffect(() => { - const fetchJobs = async () => { - try { - setIsLoading(true); - const response = await networkService.getQueueData(); - if (response.status === 200) { - setJobs(response.data.data.jobs); - setMetrics(response.data.data.metrics); - } - } catch (error) { - setError(error); - } finally { - setIsLoading(false); - } - }; - - fetchJobs(); - }, [trigger]); - - return [jobs, metrics, isLoading, error]; -}; - -const useFlushQueue = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - - const flushQueue = async (trigger, setTrigger) => { - try { - setIsLoading(true); - await networkService.flushQueue(); - createToast({ - body: "Queue flushed", - }); - } catch (error) { - setError(error); - createToast({ - body: error.message, - }); - } finally { - setIsLoading(false); - setTrigger(!trigger); - } - }; - return [flushQueue, isLoading, error]; -}; - -const useFetchDiagnostics = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - const [diagnostics, setDiagnostics] = useState(undefined); - - const fetchDiagnostics = async () => { - try { - setIsLoading(true); - const response = await networkService.getDiagnostics(); - setDiagnostics(response.data.data); - } catch (error) { - createToast({ - body: error.message, - }); - setError(error); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchDiagnostics(); - }, []); - - return [diagnostics, fetchDiagnostics, isLoading, error]; -}; - -export { useFetchLogs, useFetchQueueData, useFlushQueue, useFetchDiagnostics }; diff --git a/client/src/Hooks/useNotifications.js b/client/src/Hooks/useNotifications.js deleted file mode 100644 index 6cf84e45f..000000000 --- a/client/src/Hooks/useNotifications.js +++ /dev/null @@ -1,58 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { createToast } from "../Utils/toastUtils.jsx"; -import { networkService } from "../main.jsx"; -import { useTranslation } from "react-i18next"; - -const useGetNotificationsByTeamId = (updateTrigger) => { - const [notifications, setNotifications] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const { t } = useTranslation(); - - const getNotifications = useCallback(async () => { - try { - setIsLoading(true); - const response = await networkService.getNotificationsByTeamId(); - setNotifications(response?.data?.data ?? []); - } catch (error) { - setError(error); - createToast({ - body: t("notifications.fetch.failed"), - }); - } finally { - setIsLoading(false); - } - }, [t]); - - useEffect(() => { - getNotifications(); - }, [getNotifications, updateTrigger]); - - return [notifications, isLoading, error]; -}; - -const useTestAllNotifications = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - const { t } = useTranslation(); - const testAllNotifications = async ({ monitorId }) => { - try { - setIsLoading(true); - await networkService.testAllNotifications({ monitorId }); - createToast({ - body: t("notifications.test.success"), - }); - } catch (error) { - createToast({ - body: error.response?.data?.msg || error.message, - }); - setError(error); - } finally { - setIsLoading(false); - } - }; - - return [testAllNotifications, isLoading, error]; -}; - -export { useGetNotificationsByTeamId, useTestAllNotifications }; diff --git a/client/src/Hooks/userHooks.js b/client/src/Hooks/userHooks.js deleted file mode 100644 index 3d822f979..000000000 --- a/client/src/Hooks/userHooks.js +++ /dev/null @@ -1,81 +0,0 @@ -import { useEffect, useState, useCallback } from "react"; -import { networkService } from "../main.jsx"; -import { createToast } from "../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -export const useGetUser = (userId) => { - const [user, setUser] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const fetchUser = useCallback(async () => { - try { - setIsLoading(true); - const response = await networkService.getUserById({ userId }); - setUser(response?.data?.data); - } catch (error) { - createToast({ - body: error.message, - }); - setError(error); - } finally { - setIsLoading(false); - } - }, [userId]); - - useEffect(() => { - if (userId) { - fetchUser(); - } - }, [userId, fetchUser]); - - return [user, isLoading, error]; -}; - -export const useEditUser = (userId) => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { t } = useTranslation(); - - const editUser = useCallback( - async (user) => { - try { - setIsLoading(true); - await networkService.editUser({ userId, user }); - createToast({ - body: t("editUserPage.toast.successUserUpdate"), - }); - } catch (error) { - createToast({ - body: error.message, - }); - setError(error); - } finally { - setIsLoading(false); - } - }, - [userId, t] - ); - - const changePassword = useCallback( - async (passwordForm) => { - try { - setIsLoading(true); - - await networkService.changePasswordByAdmin({ userId, passwordForm }); - createToast({ - body: t("teamPanel.changeTeamPassword.success"), - }); - } catch (error) { - createToast({ - body: error.message, - }); - setError(error); - } finally { - setIsLoading(false); - } - }, - [userId, t] - ); - - return [editUser, isLoading, error, changePassword]; -}; From 2091d8dcd78bce7ee02fb7893ff3d711f3b4b206 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 23:16:14 +0000 Subject: [PATCH 247/392] remove monitorHooks --- .../src/Components/v1/ActionsMenu/index.jsx | 228 ------------------ client/src/Hooks/UseApi.ts | 34 +++ client/src/Hooks/monitorHooks.js | 170 ------------- client/src/Pages/Settings/index.jsx | 29 +-- client/src/Pages/Uptime/BulkImport/Upload.jsx | 73 ------ client/src/Pages/Uptime/BulkImport/index.jsx | 117 --------- client/src/Routes/index.jsx | 6 +- 7 files changed, 48 insertions(+), 609 deletions(-) delete mode 100644 client/src/Components/v1/ActionsMenu/index.jsx delete mode 100644 client/src/Hooks/monitorHooks.js delete mode 100644 client/src/Pages/Uptime/BulkImport/Upload.jsx delete mode 100644 client/src/Pages/Uptime/BulkImport/index.jsx diff --git a/client/src/Components/v1/ActionsMenu/index.jsx b/client/src/Components/v1/ActionsMenu/index.jsx deleted file mode 100644 index ea0d029b8..000000000 --- a/client/src/Components/v1/ActionsMenu/index.jsx +++ /dev/null @@ -1,228 +0,0 @@ -// Components -import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Icon from "../Icon"; -import Dialog from "../Dialog/index.jsx"; - -// Utils -import { useState } from "react"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { createToast } from "../../../Utils/toastUtils.jsx"; - -import PropTypes from "prop-types"; -import { usePauseMonitor, useDeleteMonitor } from "../../../Hooks/monitorHooks.js"; - -const ActionsMenu = ({ - monitor, - isAdmin, - updateRowCallback, - pauseCallback, - setIsLoading = () => {}, -}) => { - const [anchorEl, setAnchorEl] = useState(null); - const [actions, setActions] = useState({}); - const [isOpen, setIsOpen] = useState(false); - const theme = useTheme(); - const [pauseMonitor, isPausing, error] = usePauseMonitor(); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - - const handleRemove = async (event) => { - event.preventDefault(); - event.stopPropagation(); - let monitor = { id: actions.id }; - await deleteMonitor({ monitor }); - updateRowCallback(); - }; - - const handlePause = async () => { - try { - setIsLoading(true); - await pauseMonitor({ monitorId: monitor.id }); - pauseCallback(); - } catch (error) { - createToast({ body: "Failed to pause monitor." }); - } finally { - setIsLoading(false); - } - }; - - const openMenu = (event, id, url) => { - event.preventDefault(); - event.stopPropagation(); - setAnchorEl(event.currentTarget); - setActions({ id: id, url: url }); - }; - - const openRemove = (e) => { - closeMenu(e); - setIsOpen(true); - }; - - const closeMenu = (e) => { - e.stopPropagation(); - setAnchorEl(null); - }; - - const navigate = useNavigate(); - return ( - <> - { - event.stopPropagation(); - openMenu(event, monitor.id, monitor.type === "ping" ? null : monitor.url); - }} - sx={{ - "&:focus": { - outline: "none", - }, - "& svg path": { - stroke: theme.palette.primary.contrastTextTertiary, - }, - }} - > - - - - closeMenu(e)} - disableScrollLock - slotProps={{ - paper: { - sx: { - "& ul": { - p: theme.spacing(2.5), - backgroundColor: theme.palette.primary.main, - }, - "& li": { m: 0, color: theme.palette.primary.contrastTextSecondary }, - /* - This should not be set automatically on the last of type - "& li:last-of-type": { - color: theme.palette.error.main, - }, */ - }, - }, - }} - > - {actions.url !== null ? ( - { - closeMenu(e); - e.stopPropagation(); - window.open(actions.url, "_blank", "noreferrer"); - }} - > - Open site - - ) : ( - "" - )} - { - e.stopPropagation(); - navigate(`/uptime/${actions.id}`); - }} - > - Details - - {/* TODO - pass monitor id to Incidents page */} - { - e.stopPropagation(); - navigate(`/incidents/${actions.id}`); - }} - > - Incidents - - {isAdmin && ( - { - e.stopPropagation(); - - navigate(`/uptime/configure/${actions.id}`); - }} - > - Configure - - )} - {isAdmin && ( - { - e.stopPropagation(); - navigate(`/uptime/create/${actions.id}`); - }} - > - Clone - - )} - {isAdmin && ( - { - closeMenu(e); - - e.stopPropagation(); - handlePause(e); - }} - > - {monitor?.isActive === true ? "Pause" : "Resume"} - - )} - {isAdmin && ( - { - e.stopPropagation(); - openRemove(e); - }} - sx={{ "&.MuiButtonBase-root": { color: theme.palette.error.main } }} - > - Remove - - )} - - { - e.stopPropagation(); - setIsOpen(false); - }} - confirmationButtonLabel="Delete" - /* Do we need stop propagation? */ - onConfirm={(e) => { - console.log(e); - e.stopPropagation(); - handleRemove(e); - }} - isLoading={isDeleting} - modelTitle="modal-delete-monitor" - modelDescription="delete-monitor-confirmation" - /> - - ); -}; - -ActionsMenu.propTypes = { - monitor: PropTypes.shape({ - id: PropTypes.string, - url: PropTypes.string, - type: PropTypes.string, - isActive: PropTypes.bool, - }).isRequired, - isAdmin: PropTypes.bool, - updateRowCallback: PropTypes.func, - pauseCallback: PropTypes.func, - setIsLoading: PropTypes.func, -}; - -export default ActionsMenu; diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts index dfd256bdd..e1faa2b29 100644 --- a/client/src/Hooks/UseApi.ts +++ b/client/src/Hooks/UseApi.ts @@ -179,3 +179,37 @@ export const useDelete = () => { return { deleteFn, loading, error }; }; + +export const useLazyGet = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { toastError } = useToast(); + + const getFn = async ( + endpoint: string, + config?: AxiosRequestConfig + ): Promise | null> => { + setLoading(true); + setError(null); + + try { + const res = await get>(endpoint, { + ...config, + headers: { + ...config?.headers, + }, + }); + return res.data; + } catch (err: any) { + console.error(err); + const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; + toastError(errMsg); + setError(errMsg); + return null; + } finally { + setLoading(false); + } + }; + + return { get: getFn, loading, error }; +}; diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js deleted file mode 100644 index 006c5b76e..000000000 --- a/client/src/Hooks/monitorHooks.js +++ /dev/null @@ -1,170 +0,0 @@ -import { useEffect, useState } from "react"; -import { networkService } from "../main.jsx"; -import { createToast } from "../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -export const useFetchMonitorsByTeamId = ({ types, filter, updateTrigger }) => { - const [isLoading, setIsLoading] = useState(false); - const [monitors, setMonitors] = useState(undefined); - const [networkError, setNetworkError] = useState(false); - - useEffect(() => { - const fetchMonitors = async () => { - try { - setIsLoading(true); - const res = await networkService.getMonitorsByTeamId({ - types, - filter, - }); - if (res?.data?.data) { - setMonitors(res.data.data); - } - } catch (error) { - setNetworkError(true); - createToast({ - body: error.message, - }); - } finally { - setIsLoading(false); - } - }; - fetchMonitors(); - }, [types, filter, updateTrigger]); - return [monitors, isLoading, networkError]; -}; - -export const useDeleteMonitor = () => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); - const deleteMonitor = async (monitorId, successCallback = () => {}) => { - try { - setIsLoading(true); - await networkService.deleteMonitorById({ monitorId }); - successCallback(); - createToast({ body: t("monitorDeleted") }); - } catch (error) { - createToast({ body: t("failedDeleteMonitor") }); - } finally { - setIsLoading(false); - } - }; - return [deleteMonitor, isLoading]; -}; - -export const usePauseMonitor = () => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { t } = useTranslation(); - - const pauseMonitor = async (monitorId, successCallback = () => {}) => { - try { - setIsLoading(true); - const res = await networkService.pauseMonitorById({ monitorId }); - successCallback(); - if (res.data.data.isActive === false) { - createToast({ body: t("monitorPaused") }); - } else { - createToast({ body: t("monitorResumed") }); - } - } catch (error) { - setError(error.message); - createToast({ body: t("failedPauseMonitor") }); - } finally { - setIsLoading(false); - } - }; - - return [pauseMonitor, isLoading, error]; -}; - -export const useAddDemoMonitors = () => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); - const addDemoMonitors = async () => { - try { - setIsLoading(true); - await networkService.addDemoMonitors(); - createToast({ body: t("monitorHooks.successAddDemoMonitors") }); - } catch (error) { - createToast({ body: t("monitorHooks.failureAddDemoMonitors") }); - } finally { - setIsLoading(false); - } - }; - return [addDemoMonitors, isLoading]; -}; - -export const useDeleteAllMonitors = () => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); - const deleteAllMonitors = async () => { - try { - setIsLoading(true); - await networkService.deleteAllMonitors(); - createToast({ body: t("settingsMonitorsDeleted") }); - } catch (error) { - createToast({ body: t("settingsFailedToDeleteMonitors") }); - } finally { - setIsLoading(false); - } - }; - return [deleteAllMonitors, isLoading]; -}; - -export const useDeleteMonitorStats = () => { - const { t } = useTranslation(); - const [isLoading, setIsLoading] = useState(false); - const deleteMonitorStats = async () => { - setIsLoading(true); - try { - await networkService.deleteChecksByTeamId(); - createToast({ body: t("settingsStatsCleared") }); - } catch (error) { - createToast({ body: t("settingsFailedToClearStats") }); - } finally { - setIsLoading(false); - } - }; - - return [deleteMonitorStats, isLoading]; -}; - -export const useCreateBulkMonitors = () => { - const [isLoading, setIsLoading] = useState(false); - - const createBulkMonitors = async (file, user) => { - setIsLoading(true); - - const formData = new FormData(); - formData.append("csvFile", file); - - try { - const response = await networkService.createBulkMonitors(formData); - return [true, response.data, null]; - } catch (err) { - const errorMessage = err?.response?.data?.msg || err.message; - return [false, null, errorMessage]; - } finally { - setIsLoading(false); - } - }; - - return [createBulkMonitors, isLoading]; -}; - -export const useFetchJson = () => { - const [isLoading, setIsLoading] = useState(false); - const fetchJson = async () => { - try { - setIsLoading(true); - const res = await networkService.fetchJson(); - createToast({ body: "JSON fetched successfully" }); - return res?.data?.data ?? []; - } catch (error) { - createToast({ body: "Failed to create monitor." }); - } finally { - setIsLoading(false); - } - }; - return [fetchJson, isLoading]; -}; diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index bd3513a2c..071f414fd 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -26,14 +26,9 @@ import { } from "@/Features/UI/uiSlice.js"; import SettingsStats from "./SettingsStats.jsx"; -import { useFetchSettings, useSaveSettings } from "@/Hooks/settingsHooks.js"; +import { useFetchSettings, useSaveSettings } from "@/Hooks/settingsHooks"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { - useAddDemoMonitors, - useDeleteAllMonitors, - useDeleteMonitorStats, - useFetchJson, -} from "@/Hooks/monitorHooks.js"; +import { usePost, useDelete, useLazyGet } from "@/Hooks/UseApi"; // Constants const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }]; @@ -64,8 +59,6 @@ const Settings = () => { setIsEmailPasswordSet, }); - const [addDemoMonitors, isAddingDemoMonitors] = useAddDemoMonitors(); - const [isSaving, saveError, saveSettings] = useSaveSettings({ setSettingsData, setIsApiKeySet, @@ -73,9 +66,12 @@ const Settings = () => { setIsEmailPasswordSet, setEmailPasswordHasBeenReset, }); - const [deleteAllMonitors, isDeletingMonitors] = useDeleteAllMonitors(); - const [deleteMonitorStats, isDeletingMonitorStats] = useDeleteMonitorStats(); - const [fetchJson, isFetchingJson] = useFetchJson(); + + // New API hooks to replace monitorHooks + const { post: postDemoMonitors, loading: isAddingDemoMonitors } = usePost(); + const { deleteFn: deleteAllMonitorsFn, loading: isDeletingMonitors } = useDelete(); + const { deleteFn: deleteMonitorStatsFn, loading: isDeletingMonitorStats } = useDelete(); + const { get: fetchJson, loading: isFetchingJson } = useLazyGet(); // Setup const isAdmin = useIsAdmin(); @@ -130,22 +126,23 @@ const Settings = () => { } if (name === "deleteStats") { - await deleteMonitorStats(); + await deleteMonitorStatsFn("/checks/team"); return; } if (name === "demo") { - await addDemoMonitors(); + await postDemoMonitors("/monitors/demo", {}); return; } if (name === "deleteMonitors") { - await deleteAllMonitors(); + await deleteAllMonitorsFn("/monitors/"); return; } if (name === "export") { - const json = await fetchJson(); + const res = await fetchJson("/monitors/export/json"); + const json = res?.data ?? []; if (!json || json.length === 0) { return; } diff --git a/client/src/Pages/Uptime/BulkImport/Upload.jsx b/client/src/Pages/Uptime/BulkImport/Upload.jsx deleted file mode 100644 index 24bc0ec38..000000000 --- a/client/src/Pages/Uptime/BulkImport/Upload.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useTheme } from "@emotion/react"; -import { useState, useRef } from "react"; -import { Button, Typography } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; - -const UploadFile = ({ onFileSelect }) => { - // Changed prop to onFileSelect - const theme = useTheme(); - const [file, setFile] = useState(); - const [error, setError] = useState(""); - const inputRef = useRef(); - const { t } = useTranslation(); - - const handleSelectFile = () => { - inputRef.current.click(); - }; - - const handleFileChange = (e) => { - setError(""); - const selectedFile = e.target.files[0]; - - // Basic file validation - if (!selectedFile) return; - - if (!selectedFile.name.toLowerCase().endsWith(".csv")) { - setError(t("bulkImport.invalidFileType")); - return; - } - - setFile(selectedFile); - onFileSelect(selectedFile); // Pass the file directly to parent - }; - - return ( -
- - - {file?.name || t("bulkImport.noFileSelected")} - - - {error} - - -
- ); -}; - -UploadFile.prototype = { - onFileSelect: PropTypes.func.isRequired, -}; - -export default UploadFile; diff --git a/client/src/Pages/Uptime/BulkImport/index.jsx b/client/src/Pages/Uptime/BulkImport/index.jsx deleted file mode 100644 index 6f2f7bcb5..000000000 --- a/client/src/Pages/Uptime/BulkImport/index.jsx +++ /dev/null @@ -1,117 +0,0 @@ -// React, Redux, Router -import { useTheme } from "@emotion/react"; -import { useState } from "react"; -// MUI -import { Box, Stack, Typography, Button, Link } from "@mui/material"; - -//Components -import { createToast } from "../../../Utils/toastUtils.jsx"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import UploadFile from "./Upload.jsx"; -import { useSelector } from "react-redux"; -import { useNavigate } from "react-router-dom"; -import { Trans, useTranslation } from "react-i18next"; -import { useCreateBulkMonitors } from "../../../Hooks/monitorHooks.js"; - -const BulkImport = () => { - const theme = useTheme(); - const { user } = useSelector((state) => state.auth); - const navigate = useNavigate(); - const { t } = useTranslation(); - const [selectedFile, setSelectedFile] = useState(null); - - const crumbs = [ - { name: t("uptime"), path: "/uptime" }, - { name: t("bulkImport.title"), path: `/uptime/bulk-import` }, - ]; - - const [createBulkMonitors, hookLoading] = useCreateBulkMonitors(); - - const handleSubmit = async () => { - if (!selectedFile) { - createToast({ body: t("bulkImport.noFileSelected") }); - return; - } - - const [success, data, error] = await createBulkMonitors(selectedFile, user); - - if (success) { - // You can use `data` here if needed - createToast({ body: t("bulkImport.uploadSuccess") }); - navigate("/uptime"); - } else { - createToast({ body: error || t("bulkImport.uploadFailed") }); - } - }; - - return ( - - - - - {t("bulkImport.title")} - - - - - {t("bulkImport.selectFileTips")} - - - - ), - sample: ( - - ), - }} - /> - - - - - setSelectedFile(file)} /> - - - - - - - - - ); -}; - -export default BulkImport; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 4711906c7..07dc1793c 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -53,7 +53,6 @@ import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; import ProtectedRoute from "../Components/v1/ProtectedRoute"; import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute"; import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; -import BulkImport from "../Pages/Uptime/BulkImport/index.jsx"; import Logs from "../Pages/Logs"; import CreateMonitor from "@/Pages/CreateMonitor"; @@ -87,10 +86,7 @@ const Routes = () => { } /> - } - /> + Date: Mon, 9 Feb 2026 23:28:46 +0000 Subject: [PATCH 248/392] remove settings hooks --- .../src/Components/v1/HOC/withAdminCheck.jsx | 16 ++-- client/src/Hooks/settingsHooks.js | 73 ------------------- client/src/Pages/Settings/index.jsx | 43 +++++++---- 3 files changed, 36 insertions(+), 96 deletions(-) delete mode 100644 client/src/Hooks/settingsHooks.js diff --git a/client/src/Components/v1/HOC/withAdminCheck.jsx b/client/src/Components/v1/HOC/withAdminCheck.jsx index 8646c99b6..6343b2e4e 100644 --- a/client/src/Components/v1/HOC/withAdminCheck.jsx +++ b/client/src/Components/v1/HOC/withAdminCheck.jsx @@ -2,19 +2,19 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { logger } from "../../../Utils/Logger.js"; -import { networkService } from "../../../main.jsx"; +import { useLazyGet } from "@/Hooks/UseApi"; const withAdminCheck = (WrappedComponent) => { const WithAdminCheck = (props) => { const navigate = useNavigate(); - const [isChecking, setIsChecking] = useState(true); const [superAdminExists, setSuperAdminExists] = useState(false); + const [hasChecked, setHasChecked] = useState(false); + const { get: checkSuperAdmin, loading: isChecking } = useLazyGet(); useEffect(() => { - networkService - .doesSuperAdminExist() + checkSuperAdmin("/auth/users/superadmin") .then((response) => { - if (response?.data?.data === true) { + if (response?.data === true) { navigate("/login"); } else { setSuperAdminExists(false); @@ -24,11 +24,11 @@ const withAdminCheck = (WrappedComponent) => { logger.error(error); }) .finally(() => { - setIsChecking(false); + setHasChecked(true); }); - }, [navigate]); + }, [navigate, checkSuperAdmin]); - if (isChecking) { + if (!hasChecked || isChecking) { return null; } diff --git a/client/src/Hooks/settingsHooks.js b/client/src/Hooks/settingsHooks.js deleted file mode 100644 index 4693d8daa..000000000 --- a/client/src/Hooks/settingsHooks.js +++ /dev/null @@ -1,73 +0,0 @@ -import { useState, useEffect } from "react"; -import { networkService } from "../main.jsx"; -import { createToast } from "../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -const useFetchSettings = ({ setSettingsData, setIsApiKeySet, setIsEmailPasswordSet }) => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - useEffect(() => { - const fetchSettings = async () => { - setIsLoading(true); - try { - const response = await networkService.getAppSettings(); - setSettingsData(response?.data?.data); - setIsApiKeySet(response?.data?.data?.pagespeedKeySet); - setIsEmailPasswordSet(response?.data?.data?.emailPasswordSet); - } catch (error) { - createToast({ body: "Failed to fetch settings" }); - setError(error); - } finally { - setIsLoading(false); - } - }; - fetchSettings(); - }, [setSettingsData]); - - return [isLoading, error]; -}; - -const useSaveSettings = ({ - setSettingsData, - setIsApiKeySet, - setApiKeyHasBeenReset, - setIsEmailPasswordSet, - setEmailPasswordHasBeenReset, -}) => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - const { t } = useTranslation(); - - const saveSettings = async (settings) => { - setIsLoading(true); - try { - const settingsResponse = await networkService.updateAppSettings({ settings }); - // if (settings.checkTTL) { - // await networkService.updateChecksTTL({ - // ttl: settings.checkTTL, - // }); - // } - setIsApiKeySet(settingsResponse.data.data.pagespeedKeySet); - setIsEmailPasswordSet(settingsResponse.data.data.emailPasswordSet); - if (settingsResponse.data.data.pagespeedKeySet === true) { - setApiKeyHasBeenReset(false); - } - if (settingsResponse.data.data.emailPasswordSet === true) { - setEmailPasswordHasBeenReset(false); - } - setSettingsData(settingsResponse.data.data); - - createToast({ body: t("settingsSuccessSaved") }); - } catch (error) { - createToast({ body: t("settingsFailedToSave") }); - - setError(error); - } finally { - setIsLoading(false); - } - }; - - return [isLoading, error, saveSettings]; -}; - -export { useFetchSettings, useSaveSettings }; diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index 071f414fd..fb6185784 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -13,7 +13,7 @@ import SettingsExport from "./SettingsExport.jsx"; import Button from "@mui/material/Button"; // Utils import { settingsValidation } from "@/Validation/validation.js"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useTheme } from "@emotion/react"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; @@ -26,9 +26,8 @@ import { } from "@/Features/UI/uiSlice.js"; import SettingsStats from "./SettingsStats.jsx"; -import { useFetchSettings, useSaveSettings } from "@/Hooks/settingsHooks"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { usePost, useDelete, useLazyGet } from "@/Hooks/UseApi"; +import { useGet, usePost, useDelete, useLazyGet, usePut } from "@/Hooks/UseApi"; // Constants const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }]; @@ -53,19 +52,33 @@ const Settings = () => { const [emailPasswordHasBeenReset, setEmailPasswordHasBeenReset] = useState(false); // Network - const [isSettingsLoading, settingsError] = useFetchSettings({ - setSettingsData, - setIsApiKeySet, - setIsEmailPasswordSet, - }); + const { data: fetchedSettings, isLoading: isSettingsLoading } = useGet("/settings"); - const [isSaving, saveError, saveSettings] = useSaveSettings({ - setSettingsData, - setIsApiKeySet, - setApiKeyHasBeenReset, - setIsEmailPasswordSet, - setEmailPasswordHasBeenReset, - }); + useEffect(() => { + if (fetchedSettings) { + setSettingsData(fetchedSettings); + setIsApiKeySet(fetchedSettings?.pagespeedKeySet); + setIsEmailPasswordSet(fetchedSettings?.emailPasswordSet); + } + }, [fetchedSettings]); + + const { put: saveSettingsFn, loading: isSaving } = usePut(); + + const saveSettings = async (settings) => { + const response = await saveSettingsFn("/settings", { settings }); + if (response?.data) { + const data = response.data; + setIsApiKeySet(data.pagespeedKeySet); + setIsEmailPasswordSet(data.emailPasswordSet); + if (data.pagespeedKeySet === true) { + setApiKeyHasBeenReset(false); + } + if (data.emailPasswordSet === true) { + setEmailPasswordHasBeenReset(false); + } + setSettingsData(data); + } + }; // New API hooks to replace monitorHooks const { post: postDemoMonitors, loading: isAddingDemoMonitors } = usePost(); From 34762ef4752ad06da153cecf6497ee45f8b48017 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 23:37:07 +0000 Subject: [PATCH 249/392] remove network servcie --- client/src/App.jsx | 2 - client/src/Hooks/useSendTestEmail.js | 60 - client/src/Pages/ServerUnreachable.jsx | 4 +- client/src/Pages/Settings/SettingsEmail.jsx | 19 +- client/src/Utils/NetworkService.js | 1187 ------------------- client/src/Utils/NetworkServiceProvider.jsx | 16 - client/src/main.jsx | 7 +- 7 files changed, 19 insertions(+), 1276 deletions(-) delete mode 100644 client/src/Hooks/useSendTestEmail.js delete mode 100644 client/src/Utils/NetworkService.js delete mode 100644 client/src/Utils/NetworkServiceProvider.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index da83f9061..194a5bf5b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -7,7 +7,6 @@ import lightTheme from "./Utils/Theme/lightTheme"; import darkTheme from "./Utils/Theme/darkTheme"; import { CssBaseline, GlobalStyles } from "@mui/material"; import { logger } from "./Utils/Logger"; // Import the logger -import { networkService } from "./main"; import { Routes } from "./Routes"; import AppLayout from "@/Components/v1/Layouts/AppLayout"; @@ -18,7 +17,6 @@ function App() { useEffect(() => { return () => { logger.cleanup(); - networkService.cleanup(); }; }, []); diff --git a/client/src/Hooks/useSendTestEmail.js b/client/src/Hooks/useSendTestEmail.js deleted file mode 100644 index 7223a489a..000000000 --- a/client/src/Hooks/useSendTestEmail.js +++ /dev/null @@ -1,60 +0,0 @@ -import { useState } from "react"; -import { networkService } from "../main.jsx"; -import { useSelector } from "react-redux"; -import { createToast } from "../Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; - -const useSendTestEmail = () => { - const [isSending, setIsSending] = useState(false); - const [error, setError] = useState(null); - const user = useSelector((state) => state.auth.user); - const { t } = useTranslation(); - - /** - * Send a test email with optional email configuration - * @param {Object} emailConfig - Optional email configuration parameters - */ - const sendTestEmail = async (emailConfig = null) => { - try { - setIsSending(true); - setError(null); - - // Send the test email with or without configuration - const response = await networkService.sendTestEmail({ - to: user.email, - emailConfig, - }); - - if (typeof response?.data?.data?.messageId !== "undefined") { - createToast({ - body: t("settingsTestEmailSuccess", "Test email sent successfully"), - }); - } else { - throw new Error( - response?.data?.error || - t("settingsTestEmailFailed", "Failed to send test email") - ); - } - } catch (error) { - createToast({ - body: t("failedToSendEmail"), - }); - setError(error); - createToast({ - body: t( - "settingsTestEmailFailedWithReason", - "Failed to send test email: {{reason}}", - { - reason: error.message || t("settingsTestEmailUnknownError", "Unknown error"), - } - ), - variant: "error", - }); - } finally { - setIsSending(false); - } - }; - return [isSending, error, sendTestEmail]; -}; - -export { useSendTestEmail }; diff --git a/client/src/Pages/ServerUnreachable.jsx b/client/src/Pages/ServerUnreachable.jsx index bfbbfa90a..5051ee95f 100644 --- a/client/src/Pages/ServerUnreachable.jsx +++ b/client/src/Pages/ServerUnreachable.jsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Box, Typography, Button, Stack } from "@mui/material"; import { useTheme } from "@emotion/react"; import { useNavigate } from "react-router-dom"; -import { networkService } from "@/Utils/NetworkService.js"; +import { get } from "@/Utils/ApiClient"; import Alert from "@/Components/v1/Alert/index.jsx"; import { createToast } from "@/Utils/toastUtils.jsx"; import { useTranslation } from "react-i18next"; @@ -24,7 +24,7 @@ const ServerUnreachable = () => { try { // Try to connect to the backend with a simple API call // We'll use any lightweight endpoint that doesn't require authentication - await networkService.axiosInstance.get("/health", { timeout: 5000 }); + await get("/health", { timeout: 5000 }); // If successful, show toast and navigate to login page createToast({ diff --git a/client/src/Pages/Settings/SettingsEmail.jsx b/client/src/Pages/Settings/SettingsEmail.jsx index 6f0b02137..859ff604c 100644 --- a/client/src/Pages/Settings/SettingsEmail.jsx +++ b/client/src/Pages/Settings/SettingsEmail.jsx @@ -12,7 +12,8 @@ import { PropTypes } from "prop-types"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import { useSendTestEmail } from "@/Hooks/useSendTestEmail.js"; +import { usePost } from "@/Hooks/UseApi"; +import { useSelector } from "react-redux"; import { createToast } from "@/Utils/toastUtils.jsx"; const SettingsEmail = ({ @@ -48,7 +49,8 @@ const SettingsEmail = ({ const [password, setPassword] = useState(""); // Network - const [isSending, , sendTestEmail] = useSendTestEmail(); // Using empty placeholder for unused error variable + const { post: sendTestEmailFn, loading: isSending } = usePost(); + const user = useSelector((state) => state.auth.user); // Handlers const handlePasswordChange = (e) => { @@ -94,7 +96,10 @@ const SettingsEmail = ({ } // Send test email with current form values - sendTestEmail(emailConfig); + sendTestEmailFn("/settings/test-email", { + to: user.email, + ...emailConfig, + }); }; if (!isAdmin) { @@ -297,6 +302,14 @@ const SettingsEmail = ({ +
+						{JSON.stringify({
+							systemEmailHost,
+							systemEmailAddress,
+							systemEmailPassword,
+						})}
+					
+ {systemEmailHost && systemEmailPort && diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js deleted file mode 100644 index 7f6a8e455..000000000 --- a/client/src/Utils/NetworkService.js +++ /dev/null @@ -1,1187 +0,0 @@ -import axios from "axios"; -import i18next from "i18next"; -const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL; -const FALLBACK_BASE_URL = "http://localhost:5000/api/v1"; -import { clearAuthState } from "../Features/Auth/authSlice"; -class NetworkService { - constructor(store, dispatch, navigate) { - this.store = store; - this.dispatch = dispatch; - this.navigate = navigate; - let baseURL = BASE_URL; - this.axiosInstance = axios.create(); - this.setBaseUrl(baseURL); - this.unsubscribe = store.subscribe(() => { - const state = store.getState(); - if (BASE_URL !== undefined) { - baseURL = BASE_URL; - } else if (state?.settings?.apiBaseUrl ?? null) { - baseURL = state.settings.apiBaseUrl; - } else { - baseURL = FALLBACK_BASE_URL; - } - this.setBaseUrl(baseURL); - }); - this.axiosInstance.interceptors.request.use( - (config) => { - const currentLanguage = i18next.language || "en"; - - const { authToken } = store.getState().auth; - - config.headers = { - Authorization: `Bearer ${authToken}`, - "Accept-Language": currentLanguage === "gb" ? "en" : currentLanguage, - ...config.headers, - }; - - return config; - }, - (error) => { - return Promise.reject(error); - } - ); - this.axiosInstance.interceptors.response.use( - (response) => response, - (error) => { - // Handle network errors (server unreachable) - if (error.code === "ERR_NETWORK") { - // Navigate to server unreachable page - navigate("/server-unreachable"); - // Return an empty resolved promise to stop the error propagation - return Promise.reject(error); - } - - // Handle authentication errors - - if (error.response && error.response.status === 401) { - dispatch(clearAuthState()); - navigate("/login"); - } else if (error.request && !error.response) { - return Promise.reject(error); - } - return Promise.reject(error); - } - ); - } - - setBaseUrl = (url) => { - this.axiosInstance.defaults.baseURL = url; - }; - - cleanup() { - if (this.unsubscribe) { - this.unsubscribe(); - } - } - - /** - * Fetch the games associated with a monitor - * - * @async - * @returns {Promise} The response from the axios GET request. - */ - async getMonitorGames() { - return this.axiosInstance.get(`/monitors/games`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * - * ************************************ - * Create a new monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The monitor ID to be sent in the param. - * @returns {Promise} The response from the axios GET request. - */ - - async getMonitorById(config) { - return this.axiosInstance.get(`/monitors/${config.monitorId}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * - * ************************************ - * Create a new monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {Object} config.monitor - The monitor object to be sent in the request body. - * @returns {Promise} The response from the axios POST request. - */ - async createMonitor(config) { - const { monitor } = config; - - return this.axiosInstance.post(`/monitors`, monitor); - } - - /** - * ************************************ - * Get all uptime monitors for a Team - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.teamId - The ID of the team whose monitors are to be retrieved. - * @param {number} [config.limit] - The maximum number of checks to retrieve. 0 for all, -1 for none - * @param {Array} [config.types] - The types of monitors to retrieve. - * @param {number} [config.page] - The page number for pagination. - * @param {number} [config.rowsPerPage] - The number of rows per page for pagination. - * @param {string} [config.filter] - The filter to apply to the monitors. - * @param {string} [config.field] - The field to sort by. - * @param {string} [config.order] - The order in which to sort the field. - * @returns {Promise} The response from the axios GET request. - */ - - async getMonitorsByTeamId(config) { - const { types, filter } = config ?? {}; - const params = new URLSearchParams(); - - if (types) { - types.forEach((type) => { - params.append("type", type); - }); - } - if (filter !== undefined && filter !== null && filter !== "") - params.append("filter", filter); - - return this.axiosInstance.get(`/monitors/team?${params.toString()}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Get stats for a monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The ID of the monitor whose statistics are to be retrieved. - * @param {string} config.sortOrder - The order in which to sort the retrieved statistics. - * @param {number} config.limit - The maximum number of statistics to retrieve. - * @param {string} config.dateRange - The date range for which to retrieve statistics. - * @param {number} config.numToDisplay - The number of checks to display. - * @param {boolean} config.normalize - Whether to normalize the retrieved statistics. - * @returns {Promise} The response from the axios GET request. - */ - async getStatsByMonitorId(config) { - const params = new URLSearchParams(); - if (config.sortOrder) params.append("sortOrder", config.sortOrder); - if (config.limit) params.append("limit", config.limit); - if (config.dateRange) params.append("dateRange", config.dateRange); - if (config.numToDisplay) params.append("numToDisplay", config.numToDisplay); - if (config.normalize) params.append("normalize", config.normalize); - - return this.axiosInstance.get( - `/monitors/stats/${config.monitorId}?${params.toString()}` - ); - } - - async getHardwareDetailsByMonitorId(config) { - const params = new URLSearchParams(); - if (config.dateRange) params.append("dateRange", config.dateRange); - - return this.axiosInstance.get( - `/monitors/hardware/details/${config.monitorId}?${params.toString()}` - ); - } - async getPageSpeedDetailsByMonitorId(config) { - const params = new URLSearchParams(); - if (config.dateRange) params.append("dateRange", config.dateRange); - - return this.axiosInstance.get( - `/monitors/pagespeed/details/${config.monitorId}?${params.toString()}` - ); - } - async getUptimeDetailsById(config) { - const params = new URLSearchParams(); - if (config.dateRange) params.append("dateRange", config.dateRange); - if (config.normalize) params.append("normalize", config.normalize); - - return this.axiosInstance.get( - `/monitors/uptime/details/${config.monitorId}?${params.toString()}` - ); - } - - /** - * ************************************ - * Updates a single monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The ID of the monitor to be updated. - * @param {Object} config.updatedFields - The fields to be updated for the monitor. - * @returns {Promise} The response from the axios PUT request. - */ - async updateMonitor(config) { - const { monitorId, updatedFields } = config; - const payload = updatedFields; - return this.axiosInstance.patch(`/monitors/${monitorId}`, payload, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Deletes a single monitor by its ID - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The ID of the monitor to be deleted. - * @returns {Promise} The response from the axios DELETE request. - */ - async deleteMonitorById(config) { - return this.axiosInstance.delete(`/monitors/${config.monitorId}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Deletes all checks for all monitor by teamID - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.teamId - The team ID of the monitors to be deleted. - * @returns {Promise} The response from the axios DELETE request. - */ - async deleteChecksByTeamId(config) { - return this.axiosInstance.delete(`/checks/team`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - /** - * ************************************ - * Pauses a single monitor by its ID - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The ID of the monitor to be paused. - * @returns {Promise} The response from the axios POST request. - */ - async pauseMonitorById(config) { - return this.axiosInstance.post( - `/monitors/pause/${config.monitorId}`, - {}, - { - headers: { - "Content-Type": "application/json", - }, - } - ); - } - - /** - * ************************************ - * Adds demo monitors - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @returns {Promise} The response from the axios POST request. - */ - async addDemoMonitors(config) { - return this.axiosInstance.post( - `/monitors/demo`, - {}, - { - headers: { - "Content-Type": "application/json", - }, - } - ); - } - - /** - * ************************************ - * Deletes all monitors for a team by team ID - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @returns {Promise} The response from the axios DELETE request. - */ - async deleteAllMonitors(config) { - return this.axiosInstance.delete(`/monitors/`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Gets the certificate expiry for a monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The ID of the monitor whose certificate expiry is to be retrieved. - * @returns {Promise} The response from the axios GET request. - * - */ - async getCertificateExpiry(config) { - return this.axiosInstance.get(`/monitors/certificate/${config.monitorId}`); - } - - async registerUser(user, token) { - return this.axiosInstance.post(`/auth/register`, { user, token }); - } - - /** - * ************************************ - * Logs in a user - * ************************************ - * - * @async - * @param {Object} form - The form data for the user to be logged in. - * @returns {Promise} The response from the axios POST request. - * - */ - async loginUser(form) { - return this.axiosInstance.post(`/auth/login`, form); - } - - /** - * ************************************ - * Updates a user - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.userId - The ID of the user to be updated. - * @param {Object} config.form - The form data for the user to be updated. - * @returns {Promise} The response from the axios PATCH request. - * - */ - async updateUser(config) { - return this.axiosInstance.patch(`/auth/user`, config.form); - } - - /** - * ************************************ - * Deletes a user - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.userId - The ID of the user to be deleted. - * - **/ - async deleteUser() { - return this.axiosInstance.delete(`/auth/user`); - } - - /** - * ************************************ - * Forgot password request - * ************************************ - * - * @async - * @param {Object} form - The form data for the password recovery request. - * @returns {Promise} The response from the axios POST request. - * - */ - async forgotPassword(form) { - return this.axiosInstance.post(`/auth/recovery/request`, form); - } - - /** - * ************************************ - * Validates a recovery token - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.recoveryToken - The recovery token to be validated. - * @returns {Promise} The response from the axios POST request. - * - */ - async validateRecoveryToken(config) { - return this.axiosInstance.post("/auth/recovery/validate", { - recoveryToken: config.recoveryToken, - }); - } - - /** - * ************************************ - * Requests password recovery - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.recoveryToken - The token for recovery request. - * @param {Object} config.form - The form data for the password recovery request. - * @returns {Promise} The response from the axios POST request. - * - */ - async setNewPassword(config) { - return this.axiosInstance.post("/auth/recovery/reset", { - ...config.form, - recoveryToken: config.recoveryToken, - }); - } - - /** - * ************************************ - * Checks if an admin user exists - * ************************************ - * - * @async - * @returns {Promise} The response from the axios GET request. - * - */ - async doesSuperAdminExist() { - return this.axiosInstance.get("/auth/users/superadmin"); - } - - /** - * ************************************ - * Get all users - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @returns {Promise} The response from the axios GET request. - * - */ - async getAllUsers(config) { - return this.axiosInstance.get("/auth/users"); - } - - /** - * ************************************ - * Requests an invitation token - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.email - The email of the user to be invited. - * @param {string} config.role - The role of the user to be invited. - * @returns {Promise} The response from the axios POST request. - * - */ - async requestInvitationToken(config) { - return this.axiosInstance.post(`/invite`, { email: config.email, role: config.role }); - } - /** - * ************************************ - * Sends an invitation token - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.email - The email of the user to be invited. - * @param {string} config.role - The role of the user to be invited. - * @returns {Promise} The response from the axios POST request. - * - */ - async sendInvitationToken(config) { - return this.axiosInstance.post(`/invite/send`, { - email: config.email, - role: config.role, - }); - } - - /** - * ************************************ - * Verifies an invitation token - * ************************************ - * - * @async - * @param {string} token - The invitation token to be verified. - * @returns {Promise} The response from the axios POST request. - * - */ - async verifyInvitationToken(token) { - return this.axiosInstance.post(`/invite/verify`, { - token, - }); - } - - /** - * ************************************ - * Get all checks for a given monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The ID of the monitor. - * @param {string} config.sortOrder - The order in which to sort the checks. - * @param {number} config.limit - The maximum number of checks to retrieve. - * @param {string} config.dateRange - The range of dates for which to retrieve checks. - * @param {string} config.filter - The filter to apply to the checks. - * @param {boolean} config.ack - The acknowledgment status to apply to the checks. - * @param {number} config.page - The page number to retrieve in a paginated list. - * @param {number} config.rowsPerPage - The number of rows per page in a paginated list. - * @returns {Promise} The response from the axios GET request. - * - */ - - getChecksByMonitor = async (config) => { - const params = new URLSearchParams(); - if (config.type) params.append("type", config.type); - if (config.sortOrder) params.append("sortOrder", config.sortOrder); - if (config.limit) params.append("limit", config.limit); - if (config.dateRange) params.append("dateRange", config.dateRange); - if (config.filter) params.append("filter", config.filter); - if (config.ack !== undefined) params.append("ack", config.ack); - if (config.page) params.append("page", config.page); - if (config.rowsPerPage) params.append("rowsPerPage", config.rowsPerPage); - if (config.status !== undefined) params.append("status", config.status); - - return this.axiosInstance.get(`/checks/${config.monitorId}?${params.toString()}`); - }; - - /** - * ************************************ - * Get all checks for a given user - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.userId - The ID of the user. - * @param {string} config.sortOrder - The order in which to sort the checks. - * @param {number} config.limit - The maximum number of checks to retrieve. - * @param {string} config.dateRange - The range of dates for which to retrieve checks. - * @param {string} config.filter - The filter to apply to the checks. - * @param {boolean} config.ack - The acknowledgment status to apply to the checks. - * @param {number} config.page - The page number to retrieve in a paginated list. - * @param {number} config.rowsPerPage - The number of rows per page in a paginated list. - * @returns {Promise} The response from the axios GET request. - * - */ - getChecksByTeam = async (config) => { - const params = new URLSearchParams(); - if (config.sortOrder) params.append("sortOrder", config.sortOrder); - if (config.limit) params.append("limit", config.limit); - if (config.dateRange) params.append("dateRange", config.dateRange); - if (config.filter) params.append("filter", config.filter); - if (config.ack !== undefined) params.append("ack", config.ack); - if (config.page) params.append("page", config.page); - if (config.rowsPerPage) params.append("rowsPerPage", config.rowsPerPage); - return this.axiosInstance.get(`/checks/team?${params.toString()}`); - }; - - /** - * ************************************ - * Get summary of checks by team ID - * ************************************ - * - * @async - * @returns {Promise} The response from the axios GET request. - */ - getChecksAndSummaryByTeamId = async ({ dateRange }) => { - return this.axiosInstance.get(`/checks/team/summary?dateRange=${dateRange}`); - }; - - /** - * ************************************ - * Update the status of a check - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.checkId - The ID of the check to update. - * @param {boolean} config.ack - The acknowledgment to update the check to. - * @returns {Promise} The response from the axios PUT request. - * - */ - async updateCheckStatus(config) { - return this.axiosInstance.patch(`/checks/check/${config.checkId}`, { - ack: config.ack, - }); - } - - /** - * ************************************ - * Update the status of all checks for a given monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.monitorId - The ID of the monitor. - * @param {boolean} config.ack - The acknowledgment to update the checks to. - * @returns {Promise} The response from the axios PUT request. - * - */ - async updateMonitorChecksStatus(config) { - return this.axiosInstance.patch(`/checks/monitor/${config.monitorId}`, { - ack: config.ack, - }); - } - - /** - * ************************************ - * Update the status of all checks for a given team - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {boolean} config.ack - The acknowledgment to update the checks to. - * @returns {Promise} The response from the axios PUT request. - * - */ - async updateAllChecksStatus(config) { - return this.axiosInstance.patch(`/checks/team/`, { - ack: config.ack, - }); - } - - /** - * ************************************ - * Get all checks for a given user - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {number} config.ttl - TTL for checks - * @returns {Promise} The response from the axios GET request. - * - */ - async updateChecksTTL(config) { - return this.axiosInstance.patch( - `/checks/team/ttl`, - { ttl: config.ttl }, - { - headers: { - "Content-Type": "application/json", - }, - } - ); - } - - /** - * ************************************ - * Get app settings - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @returns {Promise} The response from the axios GET request. - * - */ - - async getAppSettings(config) { - return this.axiosInstance.get("/settings", { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * - * ************************************ - * Create a new monitor - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {Object} config.settings - The monitor object to be sent in the request body. - * @returns {Promise} The response from the axios POST request. - */ - async updateAppSettings(config) { - return this.axiosInstance.patch(`/settings`, config.settings, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Test a notification integration - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} config.platform - The notification platform (slack, discord, telegram, webhook). - * @param {Object} config.payload - The payload with configuration for the notification. - * @returns {Promise} The response from the axios POST request. - */ - async testNotification(config) { - return this.axiosInstance.post("/notifications/test", config.notification, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async testAllNotifications(config) { - const { monitorId } = config; - return this.axiosInstance.post("/notifications/test/all", { - monitorId, - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Creates a maintenance window - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {Object} config.maintenanceWindow - The maintenance window object to be sent in the request body. - * @returns {Promise} The response from the axios POST request. - * - */ - - async createMaintenanceWindow(config) { - return this.axiosInstance.post(`/maintenance-window`, config.maintenanceWindow, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Edits a maintenance window - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {Object} config.maintenanceWindowId - The maintenance window id. - * @param {Object} config.maintenanceWindow - The maintenance window object to be sent in the request body. - * @returns {Promise} The response from the axios POST request. - * - */ - - async editMaintenanceWindow(config) { - return this.axiosInstance.patch( - `/maintenance-window/${config.maintenanceWindowId}`, - config.maintenanceWindow, - { - headers: { - "Content-Type": "application/json", - }, - } - ); - } - - /** - * ************************************ - * Get maintenance window by id - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} [config.maintenanceWindowId] - The id of the maintenance window to delete. - * @returns {Promise} The response from the axios POST request. - * - */ - - async getMaintenanceWindowById(config) { - const { maintenanceWindowId } = config; - return this.axiosInstance.get(`/maintenance-window/${maintenanceWindowId}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Get maintenance windows by teamId - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} [config.active] - The status of the maintenance windows to retrieve. - * @param {number} [config.page] - The page number for pagination. - * @param {number} [config.rowsPerPage] - The number of rows per page for pagination. - * @param {string} [config.field] - The field to sort by. - * @param {string} [config.order] - The order in which to sort the field. - * @returns {Promise} The response from the axios POST request. - * - */ - - async getMaintenanceWindowsByTeamId(config) { - const { active, page, rowsPerPage, field, order } = config; - const params = new URLSearchParams(); - - if (active) params.append("status", active); - if (page) params.append("page", page); - if (rowsPerPage) params.append("rowsPerPage", rowsPerPage); - if (field) params.append("field", field); - if (order) params.append("order", order); - - return this.axiosInstance.get(`/maintenance-window/team?${params.toString()}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - /** - * ************************************ - * Delete maintenance window by id - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {string} [config.maintenanceWindowId] - The id of the maintenance window to delete. - * @returns {Promise} The response from the axios POST request. - * - */ - - async deleteMaintenanceWindow(config) { - const { maintenanceWindowId } = config; - return this.axiosInstance.delete(`/maintenance-window/${maintenanceWindowId}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - /** - * ************************************ - * Fetcher github latest release version - * ************************************ - * - * @async - * @returns {Promise} The response from the axios GET request. - * - */ - async fetchGithubLatestRelease() { - return this.axiosInstance.get( - "https://api.github.com/repos/bluewave-labs/checkmate/releases/latest", - { - headers: { - Authorization: null, // No authorization header for this request - }, - } - ); - } - - async getStatusPage() { - return this.axiosInstance.get(`/status-page`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async getStatusPageByUrl(config) { - const { url, type } = config; - const params = new URLSearchParams(); - params.append("type", type); - - return this.axiosInstance.get(`/status-page/${url}?${params.toString()}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async getStatusPagesByTeamId(config) { - return this.axiosInstance.get(`/status-page/team`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async createStatusPage(config) { - const { form, isCreate, id } = config; - - const fd = new FormData(); - fd.append("type", form.type); - form.isPublished !== undefined && fd.append("isPublished", form.isPublished); - form.companyName && fd.append("companyName", form.companyName); - form.url && fd.append("url", form.url); - form.timezone && fd.append("timezone", form.timezone); - form.color && fd.append("color", form.color); - if (form.showCharts !== undefined) { - fd.append("showCharts", String(form.showCharts)); - } - if (form.showUptimePercentage !== undefined) { - fd.append("showUptimePercentage", String(form.showUptimePercentage)); - } - if (form.showAdminLoginLink !== undefined) { - fd.append("showAdminLoginLink", String(form.showAdminLoginLink)); - } - form.monitors && - form.monitors.forEach((monitorId) => { - fd.append("monitors[]", monitorId); - }); - // Handle subMonitors, even if it's an empty array - if (form.subMonitors && form.subMonitors.length > 0) { - form.subMonitors.forEach((monitorId) => { - fd.append("subMonitors[]", monitorId); - }); - } else { - fd.append("deleteSubmonitors", true); - } - - if (form?.logo?.src && form?.logo?.src !== "") { - const imageResult = await axios.get(form.logo.src, { - responseType: "blob", - }); - fd.append("logo", imageResult.data); - // Cleanup blob - if (form.logo.src.startsWith("blob:")) { - URL.revokeObjectURL(form.logo.src); - } - } - - if (isCreate) { - return this.axiosInstance.post(`/status-page`, fd, {}); - } - - return this.axiosInstance.patch(`/status-page/${id}`, fd, {}); - } - - async deleteStatusPage(config) { - const { url } = config; - const encodedUrl = encodeURIComponent(url); - - return this.axiosInstance.delete(`/status-page/${encodedUrl}`, {}); - } - - // ************************************ - // Create bulk monitors - // ************************************ - - async createBulkMonitors(formData) { - const response = await this.axiosInstance.post(`/monitors/bulk`, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - return response.data; - } - - // ************************************ - // Fetch monitors with summary by TeamID - // ************************************ - async getMonitorsWithSummaryByTeamId(config) { - const { types } = config; - const params = new URLSearchParams(); - - if (types) { - types.forEach((type) => { - params.append("type", type); - }); - } - - return this.axiosInstance.get(`/monitors/team/summary?${params.toString()}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - // ************************************ - // Fetch monitors with checks by TeamID - // ************************************ - async getMonitorsWithChecksByTeamId(config) { - const { limit, types, page, rowsPerPage, filter, field, order } = config; - const params = new URLSearchParams(); - - if (limit) params.append("limit", limit); - if (types) { - types.forEach((type) => { - params.append("type", type); - }); - } - if (page) params.append("page", page); - if (rowsPerPage) params.append("rowsPerPage", rowsPerPage); - if (filter) params.append("filter", filter); - if (field) params.append("field", field); - if (order) params.append("order", order); - - return this.axiosInstance.get(`/monitors/team/with-checks?${params.toString()}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - // ************************************ - // Send test email - // ************************************ - async sendTestEmail(config) { - // Extract recipient and email configuration - const { to, emailConfig } = config; - - // If emailConfig is provided, use the new endpoint with direct parameters - if (emailConfig) { - return this.axiosInstance.post(`/settings/test-email`, { - to, - systemEmailHost: emailConfig.systemEmailHost, - systemEmailPort: emailConfig.systemEmailPort, - systemEmailAddress: emailConfig.systemEmailAddress, - systemEmailPassword: emailConfig.systemEmailPassword, - systemEmailSecure: emailConfig.systemEmailSecure, - systemEmailPool: emailConfig.systemEmailPool, - systemEmailIgnoreTLS: emailConfig.systemEmailIgnoreTLS, - systemEmailRequireTLS: emailConfig.systemEmailRequireTLS, - systemEmailRejectUnauthorized: emailConfig.systemEmailRejectUnauthorized, - systemEmailTLSServername: emailConfig.systemEmailTLSServername, - ...(emailConfig.systemEmailUser && { - systemEmailUser: emailConfig.systemEmailUser, - }), - }); - } - - // Fallback to original behavior for backward compatibility - return this.axiosInstance.post(`/settings/test-email`, { to }); - } - - async createNotification(config) { - const { notification } = config; - return this.axiosInstance.post(`/notifications`, notification); - } - - async getNotificationsByTeamId(config) { - return this.axiosInstance.get(`/notifications/team`); - } - - async deleteNotificationById(config) { - const { id } = config; - return this.axiosInstance.delete(`/notifications/${id}`); - } - - async getNotificationById(config) { - const { id } = config; - return this.axiosInstance.get(`/notifications/${id}`); - } - - async editNotification(config) { - const { id, notification } = config; - return this.axiosInstance.patch(`/notifications/${id}`, notification); - } - - async getQueueData() { - return this.axiosInstance.get(`/queue/all-metrics`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async flushQueue() { - return this.axiosInstance.post(`/queue/flush`); - } - - async exportMonitors() { - const response = await this.axiosInstance.get("/monitors/export", { - responseType: "blob", - }); - return response; - } - - async getLogs() { - return this.axiosInstance.get(`/logs`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async getDiagnostics() { - return this.axiosInstance.get(`/diagnostic/system`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async getUserById(config) { - const userId = config?.userId; - return this.axiosInstance.get(`auth/users/${userId}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async editUser(config) { - const { userId, user } = config; - return this.axiosInstance.patch(`auth/users/${userId}`, user, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async changePasswordByAdmin(config) { - const { userId, passwordForm } = config; - return this.axiosInstance.patch(`auth/users/${userId}/password`, passwordForm, { - headers: { - "Content-Type": "application/json", - }, - }); - } - - async fetchJson() { - return this.axiosInstance.get("/monitors/export/json"); - } - - getIncidentsByTeam = async (config) => { - const params = new URLSearchParams(); - if (config.monitorId) params.append("monitorId", config.monitorId); - if (config.status !== undefined) params.append("status", config.status); - if (config.resolutionType) params.append("resolutionType", config.resolutionType); - if (config.sortOrder) params.append("sortOrder", config.sortOrder); - if (config.dateRange) params.append("dateRange", config.dateRange); - if (config.page) params.append("page", config.page); - if (config.rowsPerPage) params.append("rowsPerPage", config.rowsPerPage); - return this.axiosInstance.get(`/incidents/team?${params.toString()}`); - }; - - getIncidentById = async (incidentId) => { - return this.axiosInstance.get(`/incidents/${incidentId}`); - }; - - resolveIncidentManually = async (incidentId, options = {}) => { - const body = {}; - if (options.comment) body.comment = options.comment; - return this.axiosInstance.patch(`/incidents/${incidentId}/resolve`, body); - }; - - getIncidentSummary = async (config = {}) => { - const params = new URLSearchParams(); - if (config.limit) params.append("limit", config.limit); - const queryString = params.toString(); - return this.axiosInstance.get( - `/incidents/team/summary${queryString ? `?${queryString}` : ""}` - ); - }; -} - -export default NetworkService; - -let networkService; - -export const setNetworkService = (service) => { - networkService = service; -}; -export { networkService }; diff --git a/client/src/Utils/NetworkServiceProvider.jsx b/client/src/Utils/NetworkServiceProvider.jsx deleted file mode 100644 index 73c3a2f94..000000000 --- a/client/src/Utils/NetworkServiceProvider.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useDispatch } from "react-redux"; -import { useNavigate } from "react-router-dom"; -import { setNetworkService } from "./NetworkService"; -import NetworkService, { networkService } from "./NetworkService"; -import { store } from "../store"; - -const NetworkServiceProvider = ({ children }) => { - const dispatch = useDispatch(); - const navigate = useNavigate(); - if (!networkService) { - setNetworkService(new NetworkService(store, dispatch, navigate)); - } - return children; -}; - -export default NetworkServiceProvider; diff --git a/client/src/main.jsx b/client/src/main.jsx index 5cfbe6f33..ad35d0303 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -5,9 +5,6 @@ import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; import { persistor, store } from "./store"; import { PersistGate } from "redux-persist/integration/react"; -import NetworkServiceProvider from "./Utils/NetworkServiceProvider.jsx"; -import { networkService } from "./Utils/NetworkService"; -export { networkService }; import I18nLoader from "./Components/v1/I18nLoader"; import { initApiClient } from "./Utils/ApiClient"; @@ -21,9 +18,7 @@ ReactDOM.createRoot(document.getElementById("root")).render( > - - - + From 1236b9191e6e4465682bef79200cc93071d19075 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 23:41:21 +0000 Subject: [PATCH 250/392] fix email config --- client/src/Pages/Settings/SettingsEmail.jsx | 39 +++++++++------------ 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/client/src/Pages/Settings/SettingsEmail.jsx b/client/src/Pages/Settings/SettingsEmail.jsx index 859ff604c..f31373ed9 100644 --- a/client/src/Pages/Settings/SettingsEmail.jsx +++ b/client/src/Pages/Settings/SettingsEmail.jsx @@ -65,28 +65,12 @@ const SettingsEmail = ({ * Handle sending test email with current form values */ const handleSendTestEmail = () => { - // Collect current form values - const emailConfig = { - systemEmailHost, - systemEmailPort, - systemEmailSecure, - systemEmailPool, - systemEmailUser, - systemEmailAddress, - systemEmailPassword: password || systemEmailPassword, - systemEmailTLSServername, - systemEmailConnectionHost, - systemEmailIgnoreTLS, - systemEmailRequireTLS, - systemEmailRejectUnauthorized, - }; - // Basic validation if ( - !emailConfig.systemEmailHost || - !emailConfig.systemEmailPort || - !emailConfig.systemEmailAddress || - !emailConfig.systemEmailPassword + !systemEmailHost || + !systemEmailPort || + !systemEmailAddress || + !(password || systemEmailPassword) ) { createToast({ body: t("settingsPage.emailSettings.toastEmailRequiredFieldsError"), @@ -95,10 +79,21 @@ const SettingsEmail = ({ return; } - // Send test email with current form values + // Send test email - only include optional fields if they have values sendTestEmailFn("/settings/test-email", { to: user.email, - ...emailConfig, + systemEmailHost, + systemEmailPort, + systemEmailAddress, + systemEmailPassword: password || systemEmailPassword, + systemEmailSecure, + systemEmailPool, + systemEmailIgnoreTLS, + systemEmailRequireTLS, + systemEmailRejectUnauthorized, + ...(systemEmailUser && { systemEmailUser }), + ...(systemEmailTLSServername && { systemEmailTLSServername }), + ...(systemEmailConnectionHost && { systemEmailConnectionHost }), }); }; From a20fdb423a8158d4ce4c13d7631e1bbe1bcc8bff Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 9 Feb 2026 23:51:18 +0000 Subject: [PATCH 251/392] remove unused --- client/index.html | 2 +- client/src/{App.jsx => App.tsx} | 16 ++- client/src/Components/v1/StatBox/index.jsx | 149 --------------------- client/src/Hooks/useIsAdmin.js | 17 --- client/src/Hooks/useIsAdmin.ts | 18 +++ client/src/Hooks/useMonitorUtils.js | 72 ---------- client/src/{main.jsx => main.tsx} | 8 +- 7 files changed, 33 insertions(+), 249 deletions(-) rename client/src/{App.jsx => App.tsx} (74%) delete mode 100644 client/src/Components/v1/StatBox/index.jsx delete mode 100644 client/src/Hooks/useIsAdmin.js create mode 100644 client/src/Hooks/useIsAdmin.ts delete mode 100644 client/src/Hooks/useMonitorUtils.js rename client/src/{main.jsx => main.tsx} (66%) diff --git a/client/index.html b/client/index.html index 0b0f675b4..4f949edbb 100644 --- a/client/index.html +++ b/client/index.html @@ -18,7 +18,7 @@
diff --git a/client/src/App.jsx b/client/src/App.tsx similarity index 74% rename from client/src/App.jsx rename to client/src/App.tsx index 194a5bf5b..ef3625cad 100644 --- a/client/src/App.jsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, type CSSProperties } from "react"; import { useSelector } from "react-redux"; import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; @@ -9,9 +9,10 @@ import { CssBaseline, GlobalStyles } from "@mui/material"; import { logger } from "./Utils/Logger"; // Import the logger import { Routes } from "./Routes"; import AppLayout from "@/Components/v1/Layouts/AppLayout"; +import type { RootState } from "@/Types/state"; function App() { - const mode = useSelector((state) => state.ui.mode); + const mode = useSelector((state: RootState) => state.ui.mode); // Cleanup useEffect(() => { @@ -28,6 +29,7 @@ function App() { ); diff --git a/client/src/Components/v1/StatBox/index.jsx b/client/src/Components/v1/StatBox/index.jsx deleted file mode 100644 index e72f947b3..000000000 --- a/client/src/Components/v1/StatBox/index.jsx +++ /dev/null @@ -1,149 +0,0 @@ -import { Stack, Typography } from "@mui/material"; -import Image from "../Image/index.jsx"; -import { useTheme } from "@mui/material/styles"; -import PropTypes from "prop-types"; -import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js"; - -/** - * StatBox Component - * - * A reusable component that displays a statistic with a heading and subheading - * in a styled box with a gradient background. - * - * @component - * @param {Object} props - The component props - * @param {string} props.heading - The primary heading/title of the statistic - * @param {string|React.ReactNode} props.subHeading - The value or description of the statistic - * @param {boolean} [props.gradient=false] - Determines if the box should have a gradient background - * @param {string} [props.status] - The status of the statistic - * @param {Object} [props.sx] - Additional custom styling to be applied to the box - * - * @example - * return ( - * - * ) - * - * @returns {React.ReactElement} A styled box containing the statistic - */ - -const StatBox = ({ - img, - icon: Icon, - alt, - heading, - subHeading, - gradient = false, - status = "", - sx, -}) => { - const theme = useTheme(); - const { statusToTheme } = useMonitorUtils(); - const themeColor = statusToTheme[status]; - - const statusBoxStyles = gradient - ? { - background: `linear-gradient(to bottom right, ${theme.palette[themeColor].main} 30%, ${theme.palette[themeColor].lowContrast} 70%)`, - borderColor: theme.palette[themeColor].lowContrast, - } - : { - background: `linear-gradient(340deg, ${theme.palette.tertiary.main} 10%, ${theme.palette.primary.main} 45%)`, - borderColor: theme.palette.primary.lowContrast, - }; - - const headingStyles = gradient - ? { - color: theme.palette[themeColor].contrastText, - } - : { - color: theme.palette.primary.contrastTextSecondary, - }; - - const spanFixedStyles = { marginLeft: theme.spacing(2), fontSize: 15, fontWeight: 600 }; - const detailTextStyles = gradient - ? { - color: theme.palette[themeColor].contrastText, - "& span": { - color: theme.palette[themeColor].contrastText, - ...spanFixedStyles, - }, - } - : { - color: theme.palette.primary.contrastText, - "& span": { - color: theme.palette.primary.contrastTextTertiary, - ...spanFixedStyles, - }, - }; - - const responsiveWidths = { - default: `calc(25% - (3 * ${theme.spacing(8)} / 4))`, - md: `calc(50% - (1 * ${theme.spacing(8)} / 2))`, - }; - - return ( - - {img && ( - - )} - {Icon && ( - - )} - - {heading} - {subHeading} - - - ); -}; - -StatBox.propTypes = { - heading: PropTypes.string, - subHeading: PropTypes.node, - gradient: PropTypes.bool, - status: PropTypes.string, - sx: PropTypes.object, - icon: PropTypes.elementType, - img: PropTypes.node, - alt: PropTypes.string, -}; - -export default StatBox; diff --git a/client/src/Hooks/useIsAdmin.js b/client/src/Hooks/useIsAdmin.js deleted file mode 100644 index 8abf78ac9..000000000 --- a/client/src/Hooks/useIsAdmin.js +++ /dev/null @@ -1,17 +0,0 @@ -import { useSelector } from "react-redux"; - -const useIsAdmin = () => { - const { user } = useSelector((state) => state.auth); - const isAdmin = - (user?.role?.includes("admin") ?? false) || - (user?.role?.includes("superadmin") ?? false); - return isAdmin; -}; - -const useIsSuperAdmin = () => { - const { user } = useSelector((state) => state.auth); - const isSuperAdmin = user?.role?.includes("superadmin"); - return isSuperAdmin; -}; - -export { useIsAdmin, useIsSuperAdmin }; diff --git a/client/src/Hooks/useIsAdmin.ts b/client/src/Hooks/useIsAdmin.ts new file mode 100644 index 000000000..3c94cca46 --- /dev/null +++ b/client/src/Hooks/useIsAdmin.ts @@ -0,0 +1,18 @@ +import type { RootState } from "@/Types/state"; +import { useSelector } from "react-redux"; + +const useIsAdmin = (): boolean => { + const { user } = useSelector((state: RootState) => state.auth); + const isAdmin = + (user?.role?.includes("admin") ?? false) || + (user?.role?.includes("superadmin") ?? false); + return isAdmin; +}; + +const useIsSuperAdmin = (): boolean => { + const { user } = useSelector((state: RootState) => state.auth); + const isSuperAdmin = user?.role?.includes("superadmin") ?? false; + return isSuperAdmin; +}; + +export { useIsAdmin, useIsSuperAdmin }; diff --git a/client/src/Hooks/useMonitorUtils.js b/client/src/Hooks/useMonitorUtils.js deleted file mode 100644 index b57c11a12..000000000 --- a/client/src/Hooks/useMonitorUtils.js +++ /dev/null @@ -1,72 +0,0 @@ -import { useCallback } from "react"; -import { useTheme } from "@mui/material"; - -const useMonitorUtils = () => { - const getMonitorWithPercentage = useCallback((monitor, theme) => { - let uptimePercentage = ""; - let percentageColor = ""; - - if (monitor?.uptimePercentage !== undefined) { - uptimePercentage = - monitor?.uptimePercentage === 0 - ? "0" - : (monitor?.uptimePercentage * 100).toFixed(2); - - percentageColor = - monitor?.uptimePercentage < 0.25 - ? theme.palette.error.main - : monitor?.uptimePercentage < 0.5 - ? theme.palette.warning.main - : monitor?.uptimePercentage < 0.75 - ? theme.palette.success.main - : theme.palette.success.main; - } - - return { - ...monitor, - percentage: uptimePercentage, - percentageColor, - monitor: monitor, - }; - }, []); - - const determineState = useCallback((monitor) => { - if (typeof monitor === "undefined") return "pending"; - if (monitor?.isActive === false) return "paused"; - if (monitor?.status === undefined) return "pending"; - return monitor?.status == true ? "up" : "down"; - }, []); - - const theme = useTheme(); - - const statusColor = { - up: theme.palette.success.lowContrast, - down: theme.palette.error.lowContrast, - paused: theme.palette.warning.lowContrast, - pending: theme.palette.warning.lowContrast, - }; - - const statusToTheme = { - up: "success", - down: "error", - paused: "warning", - pending: "secondary", - "cannot resolve": "tertiary", - }; - - const pagespeedStatusMsg = { - up: "Live (collecting data)", - down: "Inactive", - paused: "Paused", - }; - - return { - getMonitorWithPercentage, - determineState, - statusColor, - statusToTheme, - pagespeedStatusMsg, - }; -}; - -export { useMonitorUtils }; diff --git a/client/src/main.jsx b/client/src/main.tsx similarity index 66% rename from client/src/main.jsx rename to client/src/main.tsx index ad35d0303..813a839bd 100644 --- a/client/src/main.jsx +++ b/client/src/main.tsx @@ -3,14 +3,14 @@ import App from "./App.jsx"; import "./index.css"; import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; -import { persistor, store } from "./store"; +import { persistor, store } from "./store.js"; import { PersistGate } from "redux-persist/integration/react"; -import I18nLoader from "./Components/v1/I18nLoader"; -import { initApiClient } from "./Utils/ApiClient"; +import I18nLoader from "./Components/v1/I18nLoader/index.jsx"; +import { initApiClient } from "./Utils/ApiClient.js"; initApiClient(store); -ReactDOM.createRoot(document.getElementById("root")).render( +ReactDOM.createRoot(document.getElementById("root")!).render( Date: Tue, 10 Feb 2026 00:01:27 +0000 Subject: [PATCH 252/392] v1 cleanup --- .../src/Components/v1/Animated/PulseDot.jsx | 68 ---- .../v1/Buttons/RoundGradientButton.jsx | 30 -- .../Components/v1/Charts/AreaChart/index.jsx | 214 ----------- .../Components/v1/Charts/BarChart/index.css | 0 .../Components/v1/Charts/BarChart/index.jsx | 181 ---------- .../v1/Charts/ChartBox/EmptyView.jsx | 106 ------ .../Components/v1/Charts/ChartBox/index.jsx | 104 ------ .../v1/Charts/CustomGauge/index.css | 14 - .../v1/Charts/CustomGauge/index.jsx | 148 -------- .../Charts/DePINStatusPageBarChart/index.jsx | 181 ---------- .../Components/v1/Charts/LegendBox/index.jsx | 42 --- .../Charts/MonitorDetailsAreaChart/index.css | 0 .../Charts/MonitorDetailsAreaChart/index.jsx | 222 ------------ .../v1/Charts/StatusPageBarChart/index.jsx | 217 ----------- .../v1/Charts/Utils/chartUtilFunctions.js | 27 -- .../Components/v1/Charts/Utils/chartUtils.jsx | 337 ------------------ .../v1/Charts/Utils/gradientUtils.jsx | 47 --- client/src/Components/v1/Check/Check.jsx | 80 ----- client/src/Components/v1/Check/check.css | 0 .../src/Components/v1/CircularCount/index.jsx | 29 -- client/src/Components/v1/Common/AppBar.jsx | 220 ------------ client/src/Components/v1/Common/Footer.jsx | 255 ------------- client/src/Components/v1/ConfigRow/index.jsx | 58 --- client/src/Components/v1/Dot/index.jsx | 24 -- .../v1/Fallback/FallBackActionButtons.jsx | 44 --- .../v1/Fallback/FallbackBackground.jsx | 39 -- .../v1/Fallback/FallbackCheckList.jsx | 35 -- .../v1/Fallback/FallbackContainer.jsx | 44 --- .../v1/Fallback/FallbackPageSpeedWarning.jsx | 69 ---- .../Components/v1/Fallback/FallbackTitle.jsx | 21 -- client/src/Components/v1/Fallback/index.css | 40 --- client/src/Components/v1/Fallback/index.jsx | 75 ---- .../v1/GenericFallback/NetworkError.jsx | 23 -- .../Components/v1/GenericFallback/index.jsx | 85 ----- client/src/Components/v1/Heading/index.jsx | 32 -- client/src/Components/v1/Host/index.jsx | 73 ---- .../Components/v1/HttpStatusLabel/index.jsx | 67 ---- client/src/Components/v1/IconBox/index.jsx | 77 ---- client/src/Components/v1/Image/index.jsx | 72 ---- client/src/Components/v1/InfoBox/index.jsx | 42 --- .../v1/MonitorCreateHeader/index.jsx | 44 --- .../v1/MonitorCreateHeader/skeleton.jsx | 19 - .../v1/MonitorTimeFrameHeader/index.jsx | 85 ----- .../v1/MonitorTimeFrameHeader/skeleton.jsx | 23 -- .../Components/v1/PageStateWrapper/index.jsx | 66 ---- .../src/Components/v1/ProgressBars/index.css | 10 - .../src/Components/v1/ProgressBars/index.jsx | 180 ---------- .../Components/v1/ProgressStepper/index.css | 0 .../Components/v1/ProgressStepper/index.jsx | 74 ---- .../Components/v1/ShareComponent/index.jsx | 73 ---- .../v1/Skeletons/FullPage/index.jsx | 14 - .../Components/v1/StandardContainer/index.jsx | 51 --- .../src/Components/v1/StatusBoxes/index.jsx | 37 -- .../Components/v1/StatusBoxes/skeleton.jsx | 33 -- client/src/Components/v1/Subheader/index.jsx | 83 ----- client/src/Components/v1/Tab/index.jsx | 41 --- .../Table/TablePagination/Actions/index.jsx | 81 ----- .../v1/Table/TablePagination/index.jsx | 144 -------- client/src/Components/v1/Table/TableUtils.js | 11 - client/src/Components/v1/Table/index.jsx | 180 ---------- client/src/Components/v1/Table/skeleton.jsx | 14 - 61 files changed, 4705 deletions(-) delete mode 100644 client/src/Components/v1/Animated/PulseDot.jsx delete mode 100644 client/src/Components/v1/Buttons/RoundGradientButton.jsx delete mode 100644 client/src/Components/v1/Charts/AreaChart/index.jsx delete mode 100644 client/src/Components/v1/Charts/BarChart/index.css delete mode 100644 client/src/Components/v1/Charts/BarChart/index.jsx delete mode 100644 client/src/Components/v1/Charts/ChartBox/EmptyView.jsx delete mode 100644 client/src/Components/v1/Charts/ChartBox/index.jsx delete mode 100644 client/src/Components/v1/Charts/CustomGauge/index.css delete mode 100644 client/src/Components/v1/Charts/CustomGauge/index.jsx delete mode 100644 client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx delete mode 100644 client/src/Components/v1/Charts/LegendBox/index.jsx delete mode 100644 client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.css delete mode 100644 client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx delete mode 100644 client/src/Components/v1/Charts/StatusPageBarChart/index.jsx delete mode 100644 client/src/Components/v1/Charts/Utils/chartUtilFunctions.js delete mode 100644 client/src/Components/v1/Charts/Utils/chartUtils.jsx delete mode 100644 client/src/Components/v1/Charts/Utils/gradientUtils.jsx delete mode 100644 client/src/Components/v1/Check/Check.jsx delete mode 100644 client/src/Components/v1/Check/check.css delete mode 100644 client/src/Components/v1/CircularCount/index.jsx delete mode 100644 client/src/Components/v1/Common/AppBar.jsx delete mode 100644 client/src/Components/v1/Common/Footer.jsx delete mode 100644 client/src/Components/v1/ConfigRow/index.jsx delete mode 100644 client/src/Components/v1/Dot/index.jsx delete mode 100644 client/src/Components/v1/Fallback/FallBackActionButtons.jsx delete mode 100644 client/src/Components/v1/Fallback/FallbackBackground.jsx delete mode 100644 client/src/Components/v1/Fallback/FallbackCheckList.jsx delete mode 100644 client/src/Components/v1/Fallback/FallbackContainer.jsx delete mode 100644 client/src/Components/v1/Fallback/FallbackPageSpeedWarning.jsx delete mode 100644 client/src/Components/v1/Fallback/FallbackTitle.jsx delete mode 100644 client/src/Components/v1/Fallback/index.css delete mode 100644 client/src/Components/v1/Fallback/index.jsx delete mode 100644 client/src/Components/v1/GenericFallback/NetworkError.jsx delete mode 100644 client/src/Components/v1/GenericFallback/index.jsx delete mode 100644 client/src/Components/v1/Heading/index.jsx delete mode 100644 client/src/Components/v1/Host/index.jsx delete mode 100644 client/src/Components/v1/HttpStatusLabel/index.jsx delete mode 100644 client/src/Components/v1/IconBox/index.jsx delete mode 100644 client/src/Components/v1/Image/index.jsx delete mode 100644 client/src/Components/v1/InfoBox/index.jsx delete mode 100644 client/src/Components/v1/MonitorCreateHeader/index.jsx delete mode 100644 client/src/Components/v1/MonitorCreateHeader/skeleton.jsx delete mode 100644 client/src/Components/v1/MonitorTimeFrameHeader/index.jsx delete mode 100644 client/src/Components/v1/MonitorTimeFrameHeader/skeleton.jsx delete mode 100644 client/src/Components/v1/PageStateWrapper/index.jsx delete mode 100644 client/src/Components/v1/ProgressBars/index.css delete mode 100644 client/src/Components/v1/ProgressBars/index.jsx delete mode 100644 client/src/Components/v1/ProgressStepper/index.css delete mode 100644 client/src/Components/v1/ProgressStepper/index.jsx delete mode 100644 client/src/Components/v1/ShareComponent/index.jsx delete mode 100644 client/src/Components/v1/Skeletons/FullPage/index.jsx delete mode 100644 client/src/Components/v1/StandardContainer/index.jsx delete mode 100644 client/src/Components/v1/StatusBoxes/index.jsx delete mode 100644 client/src/Components/v1/StatusBoxes/skeleton.jsx delete mode 100644 client/src/Components/v1/Subheader/index.jsx delete mode 100644 client/src/Components/v1/Tab/index.jsx delete mode 100644 client/src/Components/v1/Table/TablePagination/Actions/index.jsx delete mode 100644 client/src/Components/v1/Table/TablePagination/index.jsx delete mode 100644 client/src/Components/v1/Table/TableUtils.js delete mode 100644 client/src/Components/v1/Table/index.jsx delete mode 100644 client/src/Components/v1/Table/skeleton.jsx diff --git a/client/src/Components/v1/Animated/PulseDot.jsx b/client/src/Components/v1/Animated/PulseDot.jsx deleted file mode 100644 index 83513d56e..000000000 --- a/client/src/Components/v1/Animated/PulseDot.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import PropTypes from "prop-types"; -import { Box, Stack, useTheme } from "@mui/material"; -// import useUtils from "../../Pages/Uptime/utils"; - -/** - * A component that renders a pulsating dot with a specified color. - * - * @component - * @example - * // Example usage: - * - * - * @param {Object} props - * @param {string} props.color - The color of the dot. - * @returns {JSX.Element} The PulseDot component. - */ - -const PulseDot = ({ color }) => { - const theme = useTheme(); - // const { statusToTheme } = useUtils(); - /* TODO refactor so it gets status and gets theme color. Then uses theme.palette.[themeColor].lowContrast */ - /* const themeColor = statusToTheme[status]; */ - - return ( - - - - ); -}; - -PulseDot.propTypes = { - color: PropTypes.string.isRequired, -}; - -export default PulseDot; diff --git a/client/src/Components/v1/Buttons/RoundGradientButton.jsx b/client/src/Components/v1/Buttons/RoundGradientButton.jsx deleted file mode 100644 index ff22be955..000000000 --- a/client/src/Components/v1/Buttons/RoundGradientButton.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import Button from "@mui/material/Button"; -import { styled } from "@mui/material/styles"; - -const RoundGradientButton = styled(Button)(({ theme }) => ({ - position: "relative", - border: "5px solid transparent", - backgroundClip: "padding-box", - borderRadius: 30, - fontSize: "1.2rem", - color: theme.palette.primary.contrastText, - backgroundColor: theme.palette.background.main, - - "&:after": { - position: "absolute", - top: -3, - left: -3, - right: -3, - bottom: -3, - background: - theme.palette.mode === "dark" - ? "linear-gradient(90deg, #842bd2, #ff5451, #8c52ff)" - : "linear-gradient(90deg, #842bd2, #ff5451, #8c52ff)", - content: '""', - zIndex: -1, - borderRadius: 30, - }, -})); - -export default RoundGradientButton; diff --git a/client/src/Components/v1/Charts/AreaChart/index.jsx b/client/src/Components/v1/Charts/AreaChart/index.jsx deleted file mode 100644 index dd5a900d6..000000000 --- a/client/src/Components/v1/Charts/AreaChart/index.jsx +++ /dev/null @@ -1,214 +0,0 @@ -/** - * CustomAreaChart component for rendering an area chart with optional gradient and custom ticks. - * - * @param {Object} props - The properties object. - * @param {Array} props.data - The data array for the chart. - * @param {Array} props.dataKeys - An array of data keys to be plotted as separate areas. - * @param {string} props.xKey - The key for the x-axis data. - * @param {string} [props.yKey] - The key for the y-axis data (optional). - * @param {Object} [props.xTick] - Custom tick component for the x-axis. - * @param {Object} [props.yTick] - Custom tick component for the y-axis. - * @param {string} [props.strokeColor] - The base stroke color for the areas. - * If not provided, uses a predefined color palette. - * @param {string} [props.fillColor] - The base fill color for the areas. - * @param {boolean} [props.gradient=false] - Whether to apply a gradient fill to the areas. - * @param {string} [props.gradientDirection="vertical"] - The direction of the gradient. - * @param {string} [props.gradientStartColor] - The start color of the gradient. - * Defaults to the area's stroke color if not provided. - * @param {string} [props.gradientEndColor] - The end color of the gradient. - * @param {Object} [props.customTooltip] - Custom tooltip component for the chart. - * @param {string|number} [props.height="100%"] - Height of the chart container. - * - * @returns {JSX.Element} The rendered area chart component. - * - * @example - * // Single series chart - * - * - * @example - * // Multi-series chart with custom tooltip - * } - * yTick={} - * gradient={true} - * customTooltip={({ active, payload, label }) => ( - * - * )} - * /> - */ - -import { - AreaChart, - Area, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from "recharts"; -import { createGradient } from "../Utils/gradientUtils.jsx"; -import PropTypes from "prop-types"; -import { useTheme } from "@mui/material"; -import { useId } from "react"; -import { Fragment } from "react"; - -const CustomAreaChart = ({ - data, - dataKeys, - xKey, - xDomain, - yKey, - yDomain, - xTick, - yTick, - strokeColor, - fillColor, - gradient = false, - gradientDirection = "vertical", - gradientStartColor, - gradientEndColor, - customTooltip, - height = "100%", -}) => { - const theme = useTheme(); - const uniqueId = useId(); - - const AREA_COLORS = [ - // Blues - "#3182bd", // Deep blue - "#6baed6", // Medium blue - "#9ecae1", // Light blue - - // Greens - "#74c476", // Soft green - "#a1d99b", // Light green - "#c7e9c0", // Pale green - - // Oranges - "#fdae6b", // Warm orange - "#fdd0a2", // Light orange - "#feedde", // Pale orange - - // Purples - "#9467bd", // Lavender - "#a55194", // Deep magenta - "#c994c7", // Soft magenta - - // Reds - "#ff9896", // Soft red - "#de2d26", // Deep red - "#fc9272", // Medium red - - // Cyans/Teals - "#17becf", // Cyan - "#7fcdbb", // Teal - "#a1dab4", // Light teal - - // Yellows - "#fec44f", // Mustard - "#fee391", // Light yellow - "#ffffd4", // Pale yellow - - // Additional colors - "#e377c2", // Soft pink - "#bcbd22", // Olive - "#2ca02c", // Vibrant green - ]; - - return ( - - - - - - - {dataKeys.map((dataKey, index) => { - const gradientId = `gradient-${uniqueId}-${index}`; - - return ( - - {gradient === true && - createGradient({ - id: gradientId, - startColor: gradientStartColor || AREA_COLORS[index], - endColor: gradientEndColor, - direction: gradientDirection, - })} - - - ); - })} - {customTooltip ? ( - - ) : ( - - )} - - - ); -}; - -CustomAreaChart.propTypes = { - data: PropTypes.array.isRequired, - dataKeys: PropTypes.array.isRequired, - xTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself - yTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself - xKey: PropTypes.string.isRequired, - xDomain: PropTypes.array, - yKey: PropTypes.string, - yDomain: PropTypes.array, - fillColor: PropTypes.string, - strokeColor: PropTypes.string, - gradient: PropTypes.bool, - gradientDirection: PropTypes.string, - gradientStartColor: PropTypes.string, - gradientEndColor: PropTypes.string, - customTooltip: PropTypes.object, - height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), -}; - -export default CustomAreaChart; diff --git a/client/src/Components/v1/Charts/BarChart/index.css b/client/src/Components/v1/Charts/BarChart/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Components/v1/Charts/BarChart/index.jsx b/client/src/Components/v1/Charts/BarChart/index.jsx deleted file mode 100644 index 77b836cae..000000000 --- a/client/src/Components/v1/Charts/BarChart/index.jsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useTheme } from "@emotion/react"; -import { Box, Stack, Tooltip, Typography } from "@mui/material"; -import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; -import { useEffect, useState } from "react"; -import "./index.css"; -import { useSelector } from "react-redux"; - -/* TODO add prop validation and jsdocs */ -const BarChart = ({ checks = [] }) => { - const theme = useTheme(); - const [animate, setAnimate] = useState(false); - const uiTimezone = useSelector((state) => state.ui.timezone); - - useEffect(() => { - setAnimate(true); - }); - - // set responseTime to average if there's only one check - if (checks.length === 1) { - checks[0] = { ...checks[0], responseTime: 50 }; - } - - if (checks.length !== 25) { - const placeholders = Array(25 - checks.length).fill("placeholder"); - checks = [...checks, ...placeholders]; - } - - return ( - event.stopPropagation()} - sx={{ - cursor: "default", - }} - > - {checks.map((check, index) => - check === "placeholder" ? ( - /* TODO what is the purpose of this box? */ - // CAIO_REVIEW the purpose of this box is to make sure there are always at least 25 bars - // even if there are less than 25 checks - - ) : ( - - - {formatDateWithTz( - check.updatedAt, - "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone - )} - - - - - - Response Time - - - {check.originalResponseTime} - - {" "} - ms - - - - - - } - placement="top" - key={`check-${check?.id}`} - slotProps={{ - popper: { - className: "bar-tooltip", - modifiers: [ - { - name: "offset", - options: { - offset: [0, -10], - }, - }, - ], - sx: { - "& .MuiTooltip-tooltip": { - backgroundColor: theme.palette.secondary.main, - border: 1, - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shape.boxShadow, - px: theme.spacing(4), - py: theme.spacing(3), - }, - "& .MuiTooltip-tooltip p": { - /* TODO Font size should point to theme */ - fontSize: 12, - color: theme.palette.secondary.contrastText, - fontWeight: 500, - }, - "& .MuiTooltip-tooltip span": { - /* TODO Font size should point to theme */ - fontSize: 11, - color: theme.palette.secondary.contrastText, - fontWeight: 600, - }, - }, - }, - }} - > - .MuiBox-root": { - filter: "brightness(0.8)", - }, */ - }} - > - - - - ) - )} - - ); -}; - -export default BarChart; diff --git a/client/src/Components/v1/Charts/ChartBox/EmptyView.jsx b/client/src/Components/v1/Charts/ChartBox/EmptyView.jsx deleted file mode 100644 index c1b60632d..000000000 --- a/client/src/Components/v1/Charts/ChartBox/EmptyView.jsx +++ /dev/null @@ -1,106 +0,0 @@ -// Components -import { Typography, Stack } from "@mui/material"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import IconBox from "../../IconBox/index.jsx"; - -/** - * `EmptyView` is a functional React component that displays an empty state view with an optional icon, header, and message. - * - * @component - * @param {Object} props - The properties that define the `EmptyView` component. - * @param {React.ReactNode} [props.icon] - An optional icon to display at the top of the empty view. - * @param {string} [props.header] - An optional header text displayed next to the icon. - * @param {string} [props.message="No Data"] - The message to be displayed in the empty view. - * @param {'h1' | 'h2' | 'h3'} [props.headingLevel="h2"] - The heading level for the message text. - * @param {string} [props.justifyContent="flex-start"] - The CSS `justify-content` value to align elements vertically. - * @param {string} [props.height="100%"] - The height of the empty view container. - * - * @example - * // Example usage of EmptyView component: - * } - * header="Average Response Time" - * message="No Response Time Available" - * headingLevel="h2" - * justifyContent="center" - * height="50%" - * /> - * - * @returns {React.Element} The `EmptyView` component with customizable icon, header, and message. - */ - -const EmptyView = ({ - icon, - header, - message = "No Data", - headingLevel = "h2", - justifyContent = "flex-start", - height = "100%", -}) => { - const theme = useTheme(); - return ( - - - - {icon && {icon}} - {header && {header}} - - - {message} - - - - ); -}; - -EmptyView.propTypes = { - message: PropTypes.string, - icon: PropTypes.node, - header: PropTypes.string, - headingLevel: PropTypes.oneOf(["h1", "h2", "h3"]), - justifyContent: PropTypes.string, - height: PropTypes.string, -}; - -export default EmptyView; diff --git a/client/src/Components/v1/Charts/ChartBox/index.jsx b/client/src/Components/v1/Charts/ChartBox/index.jsx deleted file mode 100644 index be2faf8ed..000000000 --- a/client/src/Components/v1/Charts/ChartBox/index.jsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Stack, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import IconBox from "../../IconBox/index.jsx"; -import EmptyView from "./EmptyView.jsx"; -import PropTypes from "prop-types"; - -const ChartBox = ({ - children, - icon, - header, - height = "300px", - justifyContent = "space-between", - Legend, - borderRadiusRight = 4, - sx, - noDataMessage, - isEmpty = false, -}) => { - const theme = useTheme(); - if (isEmpty) { - return ( - - ); - } - return ( - - span": { - color: theme.palette.primary.contrastText, - fontSize: 20, - "& span": { - opacity: 0.8, - marginLeft: 2, - fontSize: 15, - }, - }, - - "& tspan, & text": { - fill: theme.palette.primary.contrastTextTertiary, - }, - "& path": { - transition: "fill 300ms ease, stroke-width 400ms ease", - }, - }} - > - - {icon && {icon}} - {header && {header}} - - {children} - - {Legend && Legend} - - ); -}; - -export default ChartBox; - -ChartBox.propTypes = { - children: PropTypes.node, - icon: PropTypes.node, - header: PropTypes.string, - height: PropTypes.string, - noDataMessage: PropTypes.string, - isEmpty: PropTypes.bool, -}; diff --git a/client/src/Components/v1/Charts/CustomGauge/index.css b/client/src/Components/v1/Charts/CustomGauge/index.css deleted file mode 100644 index 1b64a6b9a..000000000 --- a/client/src/Components/v1/Charts/CustomGauge/index.css +++ /dev/null @@ -1,14 +0,0 @@ -.radial-chart { - position: relative; - display: inline-block; -} - -.radial-chart-base { - opacity: 0.3; -} - -.radial-chart-progress { - transform: rotate(-90deg); - transform-origin: center; - transition: stroke-dashoffset 1.5s ease-in-out; -} diff --git a/client/src/Components/v1/Charts/CustomGauge/index.jsx b/client/src/Components/v1/Charts/CustomGauge/index.jsx deleted file mode 100644 index 614b3774c..000000000 --- a/client/src/Components/v1/Charts/CustomGauge/index.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useTheme } from "@emotion/react"; -import { useEffect, useState, useMemo } from "react"; -import Stack from "@mui/material/Stack"; -import { Box, Typography } from "@mui/material"; -import PropTypes from "prop-types"; -import "./index.css"; -import CircularProgress from "@mui/material/CircularProgress"; - -const MINIMUM_VALUE = 0; -const MAXIMUM_VALUE = 100; - -/** - * A Performant SVG based circular gauge - * - * @component - * @param {Object} props - Component properties - * @param {number} [props.progress=0] - Progress percentage (0-100) - * @param {number} [props.radius=60] - Radius of the gauge circle - * @param {number} [props.strokeWidth=15] - Width of the gauge stroke - * @param {number} [props.precision=1] - Precision of the progress percentage - * @param {string} [props.unit="%"] - Unit of progress - * - * @example - * - * - * @returns {React.ReactElement} Rendered CustomGauge component - */ -const CustomGauge = ({ - isLoading = false, - progress = 0, - radius = 70, - strokeWidth = 15, - precision = 1, - unit = "%", -}) => { - const theme = useTheme(); - // Calculate the length of the stroke for the circle - const { circumference, totalSize, strokeLength } = useMemo( - () => ({ - circumference: 2 * Math.PI * radius, - totalSize: radius * 2 + strokeWidth * 2, - strokeLength: (progress / 100) * (2 * Math.PI * radius), - }), - [radius, strokeWidth, progress] - ); - - // Handle initial animation - const [offset, setOffset] = useState(circumference); - useEffect(() => { - setOffset(circumference); - const timer = setTimeout(() => { - setOffset(circumference - strokeLength); - }, 100); - - return () => clearTimeout(timer); - }, [progress, circumference, strokeLength]); - - const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE)); - - let fillColor; - if (progressWithinRange < 50) { - fillColor = theme.palette.success.main; - } else if (progressWithinRange < 80) { - fillColor = theme.palette.warning.lowContrast; - } else { - fillColor = theme.palette.error.lowContrast; - } - - if (isLoading) { - return ( - - - - ); - } - - return ( - - - - - - - - {`${progressWithinRange.toFixed(precision)}${unit}`} - - - ); -}; - -export default CustomGauge; - -CustomGauge.propTypes = { - isLoading: PropTypes.bool, - progress: PropTypes.number, - radius: PropTypes.number, - strokeWidth: PropTypes.number, - precision: PropTypes.number, - unit: PropTypes.string, -}; diff --git a/client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx b/client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx deleted file mode 100644 index 8b6c2b165..000000000 --- a/client/src/Components/v1/Charts/DePINStatusPageBarChart/index.jsx +++ /dev/null @@ -1,181 +0,0 @@ -// Components -import { Stack, Box, Tooltip, Typography } from "@mui/material"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useState, useEffect } from "react"; -import PropTypes from "prop-types"; -import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; -import { useSelector } from "react-redux"; - -const PlaceholderCheck = ({ daysToShow }) => { - const theme = useTheme(); - - return ( - - ); -}; - -PlaceholderCheck.propTypes = { - daysToShow: PropTypes.number, -}; - -const Check = ({ check, daysToShow }) => { - const [animate, setAnimate] = useState(false); - - const theme = useTheme(); - useEffect(() => { - setAnimate(true); - }, []); - const uiTimezone = useSelector((state) => state.ui.timezone); - - return ( - - - {formatDateWithTz(check._id, "ddd, MMMM D, YYYY", uiTimezone)} - - - - - Uptime percentage - - - {check.upPercentage.toFixed(2)} - - {" "} - % - - - - - - } - placement="top" - key={`check-${check?._id}`} - slotProps={{ - popper: { - className: "bar-tooltip", - modifiers: [ - { - name: "offset", - options: { - offset: [0, -10], - }, - }, - ], - sx: { - "& .MuiTooltip-tooltip": { - backgroundColor: theme.palette.secondary.main, - border: 1, - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shape.boxShadow, - px: theme.spacing(4), - py: theme.spacing(3), - }, - "& .MuiTooltip-tooltip p": { - /* TODO Font size should point to theme */ - fontSize: 12, - color: theme.palette.secondary.contrastText, - fontWeight: 500, - }, - "& .MuiTooltip-tooltip span": { - /* TODO Font size should point to theme */ - fontSize: 11, - color: theme.palette.secondary.contrastText, - fontWeight: 600, - }, - }, - }, - }} - > - - - - - ); -}; - -Check.propTypes = { - check: PropTypes.object, - daysToShow: PropTypes.number, -}; - -const DePINStatusPageBarChart = ({ checks = [], daysToShow = 30 }) => { - if (checks.length !== daysToShow) { - const placeholders = Array(daysToShow - checks.length).fill("placeholder"); - checks = [...checks, ...placeholders]; - } - return ( - - {checks.map((check) => { - if (check === "placeholder") { - return ( - - ); - } - return ( - - ); - })} - - ); -}; - -DePINStatusPageBarChart.propTypes = { - checks: PropTypes.array, - daysToShow: PropTypes.number, -}; - -export default DePINStatusPageBarChart; diff --git a/client/src/Components/v1/Charts/LegendBox/index.jsx b/client/src/Components/v1/Charts/LegendBox/index.jsx deleted file mode 100644 index f19e09105..000000000 --- a/client/src/Components/v1/Charts/LegendBox/index.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Stack, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import IconBox from "../../IconBox/index.jsx"; -import PropTypes from "prop-types"; - -const LegendBox = ({ children, icon, header, sx }) => { - const theme = useTheme(); - return ( - - - {icon} - {header} - - {children} - - ); -}; - -LegendBox.propTypes = { - children: PropTypes.node, - icon: PropTypes.node, - header: PropTypes.string, -}; - -export default LegendBox; diff --git a/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.css b/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx b/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx deleted file mode 100644 index dafca7b40..000000000 --- a/client/src/Components/v1/Charts/MonitorDetailsAreaChart/index.jsx +++ /dev/null @@ -1,222 +0,0 @@ -import PropTypes from "prop-types"; -import { - AreaChart, - Area, - XAxis, - Tooltip, - CartesianGrid, - ResponsiveContainer, - Text, -} from "recharts"; -import { Box, Stack, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useMemo, useState } from "react"; -import { useSelector } from "react-redux"; -import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; -import { - tooltipDateFormatLookup, - tickDateFormatLookup, -} from "../Utils/chartUtilFunctions.js"; - -import "./index.css"; -const CustomToolTip = ({ active, payload, label, dateRange }) => { - const format = tooltipDateFormatLookup(dateRange); - const uiTimezone = useSelector((state) => state.ui.timezone); - const theme = useTheme(); - if (active && payload && payload.length) { - const responseTime = payload[0]?.payload?.originalAvgResponseTime - ? payload[0]?.payload?.originalAvgResponseTime - : (payload[0]?.payload?.avgResponseTime ?? 0); - return ( - - - {formatDateWithTz(label, format, uiTimezone)} - - - - - - Response time: - - - {Math.floor(responseTime)} - - {" "} - ms - - - - - {/* Display original value */} - - ); - } - return null; -}; - -CustomToolTip.propTypes = { - active: PropTypes.bool, - payload: PropTypes.arrayOf( - PropTypes.shape({ - value: PropTypes.number, - payload: PropTypes.shape({ - _id: PropTypes.string, - avgResponseTime: PropTypes.number, - originalAvgResponseTime: PropTypes.number, - }), - }) - ), - label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - dateRange: PropTypes.string, -}; -const CustomTick = ({ x, y, payload, dateRange }) => { - const format = tickDateFormatLookup(dateRange); - const theme = useTheme(); - const uiTimezone = useSelector((state) => state.ui.timezone); - return ( - - {formatDateWithTz(payload?.value, format, uiTimezone)} - - ); -}; - -CustomTick.propTypes = { - x: PropTypes.number, - y: PropTypes.number, - payload: PropTypes.object, - index: PropTypes.number, - dateRange: PropTypes.string, -}; - -const MonitorDetailsAreaChart = ({ checks, dateRange }) => { - const theme = useTheme(); - const memoizedChecks = useMemo(() => checks, [checks[0]]); - const [isHovered, setIsHovered] = useState(false); - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - - - - - - - } - axisLine={false} - tickLine={false} - height={20} - /> - } - wrapperStyle={{ pointerEvents: "none" }} - /> - - - - ); -}; - -MonitorDetailsAreaChart.propTypes = { - checks: PropTypes.array, - dateRange: PropTypes.string, -}; - -export default MonitorDetailsAreaChart; diff --git a/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx b/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx deleted file mode 100644 index 2dc5e61f0..000000000 --- a/client/src/Components/v1/Charts/StatusPageBarChart/index.jsx +++ /dev/null @@ -1,217 +0,0 @@ -import { useTheme } from "@emotion/react"; -import { Box, Stack, Tooltip, Typography } from "@mui/material"; -import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; -import { useEffect, useState, forwardRef } from "react"; -import { useSelector } from "react-redux"; -import PropTypes from "prop-types"; - -/** - * A customizable Bar component that renders a colored bar with optional children. - * @component - * - * @param {string} width The width of the bar (e.g., "100px"). - * @param {string} height The height of the bar (e.g., "50px"). - * @param {string} backgroundColor The background color of the bar (e.g., "#FF5733"). - * @param {string} [borderRadius] Optional border radius for the bar (e.g., "8px"). - * @param {node} children The content to be rendered inside the bar. - * @returns {JSX.Element} The Bar component. - */ - -const Bar = forwardRef( - ({ width, height, backgroundColor, borderRadius, children, ...otherProps }, ref) => { - const theme = useTheme(); - - return ( - - {children} - - ); - } -); - -Bar.displayName = "Bar"; - -Bar.propTypes = { - width: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, - height: PropTypes.string.isRequired, - backgroundColor: PropTypes.string.isRequired, - borderRadius: PropTypes.string, - children: PropTypes.node, -}; - -/* TODO add prop validation and jsdocs */ -const StatusPageBarChart = ({ checks = [] }) => { - const theme = useTheme(); - const [animate, setAnimate] = useState(false); - const uiTimezone = useSelector((state) => state.ui.timezone); - - const barWidth = { - xs: "calc(60% / 25)", - xl: "calc(40% / 25)", - }; - - useEffect(() => { - setAnimate(true); - }, []); - - // set responseTime to average if there's only one check - if (checks.length === 1) { - checks[0] = { ...checks[0], responseTime: 50 }; - } - - if (checks.length !== 25) { - const placeholders = Array(25 - checks.length).fill("placeholder"); - checks = [...checks, ...placeholders]; - } - - return ( - event.stopPropagation()} - sx={{ - cursor: "default", - }} - > - {checks.map((check, index) => - check === "placeholder" ? ( - /* TODO what is the purpose of this box? */ - // CAIO_REVIEW the purpose of this box is to make sure there are always at least 25 bars - // even if there are less than 25 checks - - ) : ( - - - {formatDateWithTz( - check.createdAt, - "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone - )} - - - - - - Response Time - - - {check.originalResponseTime} - - {" "} - ms - - - - - - } - placement="top" - key={`check-${check?.id}`} - slotProps={{ - popper: { - className: "bar-tooltip", - modifiers: [ - { - name: "offset", - options: { - offset: [0, -10], - }, - }, - ], - sx: { - "& .MuiTooltip-tooltip": { - backgroundColor: theme.palette.secondary.main, - border: 1, - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shape.boxShadow, - px: theme.spacing(4), - py: theme.spacing(3), - }, - "& .MuiTooltip-tooltip p": { - /* TODO Font size should point to theme */ - fontSize: 12, - color: theme.palette.secondary.contrastText, - fontWeight: 500, - }, - "& .MuiTooltip-tooltip span": { - /* TODO Font size should point to theme */ - fontSize: 11, - color: theme.palette.secondary.contrastText, - fontWeight: 600, - }, - }, - }, - }} - > - - - - - ) - )} - - ); -}; - -export default StatusPageBarChart; diff --git a/client/src/Components/v1/Charts/Utils/chartUtilFunctions.js b/client/src/Components/v1/Charts/Utils/chartUtilFunctions.js deleted file mode 100644 index 2851af93e..000000000 --- a/client/src/Components/v1/Charts/Utils/chartUtilFunctions.js +++ /dev/null @@ -1,27 +0,0 @@ -export const tooltipDateFormatLookup = (dateRange) => { - const dateFormatLookup = { - recent: "ddd. MMMM D, YYYY, hh:mm A", - day: "ddd. MMMM D, YYYY, hh:mm A", - week: "ddd. MMMM D, YYYY, hh:mm A", - month: "ddd. MMMM D, YYYY", - }; - const format = dateFormatLookup[dateRange]; - if (format === undefined) { - return ""; - } - return format; -}; - -export const tickDateFormatLookup = (dateRange) => { - const tickFormatLookup = { - recent: "h:mm A", - day: "h:mm A", - week: "MM/D, h:mm A", - month: "ddd. M/D", - }; - const format = tickFormatLookup[dateRange]; - if (format === undefined) { - return ""; - } - return format; -}; diff --git a/client/src/Components/v1/Charts/Utils/chartUtils.jsx b/client/src/Components/v1/Charts/Utils/chartUtils.jsx deleted file mode 100644 index 7797ba462..000000000 --- a/client/src/Components/v1/Charts/Utils/chartUtils.jsx +++ /dev/null @@ -1,337 +0,0 @@ -import PropTypes from "prop-types"; -import { useSelector } from "react-redux"; -import { useTheme } from "@mui/material"; -import { Text } from "recharts"; -import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js"; -import { Box, Stack, Typography } from "@mui/material"; -import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions.js"; -/** - * Custom tick component for rendering time with timezone. - * - * @param {Object} props - The properties object. - * @param {number} props.x - The x-coordinate for the tick. - * @param {number} props.y - The y-coordinate for the tick. - * @param {Object} props.payload - The payload object containing tick data. - * @param {number} props.index - The index of the tick. - * @returns {JSX.Element} The rendered tick component. - */ -export const TzTick = ({ x, y, payload, index, dateRange }) => { - const theme = useTheme(); - const uiTimezone = useSelector((state) => state.ui.timezone); - const format = tickDateFormatLookup(dateRange); - return ( - - {formatDateWithTz(payload?.value, format, uiTimezone)} - - ); -}; - -TzTick.propTypes = { - x: PropTypes.number, - y: PropTypes.number, - payload: PropTypes.object, - index: PropTypes.number, - dateRange: PropTypes.string, -}; - -/** - * Custom tick component for rendering percentage values. - * - * @param {Object} props - The properties object. - * @param {number} props.x - The x-coordinate for the tick. - * @param {number} props.y - The y-coordinate for the tick. - * @param {Object} props.payload - The payload object containing tick data. - * @param {number} props.index - The index of the tick. - * @returns {JSX.Element|null} The rendered tick component or null for the first tick. - */ -export const PercentTick = ({ x, y, payload, index }) => { - const theme = useTheme(); - if (index === 0) return null; - return ( - - {`${(payload?.value * 100).toFixed()}%`} - - ); -}; - -PercentTick.propTypes = { - x: PropTypes.number, - y: PropTypes.number, - payload: PropTypes.object, - index: PropTypes.number, -}; - -/** - * Converts a decimal value to a formatted percentage string. - * - * @param {number} value - The decimal value to convert (e.g., 0.75) - * @returns {string} Formatted percentage string (e.g., "75.00%") or original input if not a number - * - * @example - * getFormattedPercentage(0.7543) // Returns "75.43%" - * getFormattedPercentage(1) // Returns "100.00%" - * getFormattedPercentage("test") // Returns "test" - */ -const getFormattedPercentage = (value) => { - if (typeof value !== "number") return value; - return `${(value * 100).toFixed(2)}%`; -}; - -/** - * Custom tick component for rendering network bytes per second. - * - * @param {Object} props - The properties object. - * @param {number} props.x - The x-coordinate for the tick. - * @param {number} props.y - The y-coordinate for the tick. - * @param {Object} props.payload - The payload object containing tick data. - * @param {number} props.index - The index of the tick. - * @returns {JSX.Element|null} The rendered tick component or null for the first tick. - */ -export const NetworkTick = ({ x, y, payload, index, formatter }) => { - const theme = useTheme(); - if (index === 0) return null; - - if (formatter === undefined) { - formatter = (value, space = false) => { - if (typeof value !== "number") return value; - // need to add space between value and unit - return `${(value / 1024).toFixed(1)}${space ? " " : ""}Kbps`; - }; - } - - return ( - - {formatter(payload?.value, true)} - - ); -}; - -NetworkTick.propTypes = { - x: PropTypes.number, - y: PropTypes.number, - payload: PropTypes.object, - index: PropTypes.number, - formatter: PropTypes.func, -}; - -/** - * Custom tooltip component for displaying infrastructure data. - * - * @param {Object} props - The properties object. - * @param {boolean} props.active - Indicates if the tooltip is active. - * @param {Array} props.payload - The payload array containing tooltip data. - * @param {string} props.label - The label for the tooltip. - * @param {string} props.yKey - The key for the y-axis data. - * @param {string} props.yLabel - The label for the y-axis data. - * @param {string} props.dotColor - The color of the dot in the tooltip. - * @returns {JSX.Element|null} The rendered tooltip component or null if inactive. - */ -export const InfrastructureTooltip = ({ - active, - payload, - label, - yKey, - yIdx = -1, - yLabel, - dotColor, - dateRange, - formatter = getFormattedPercentage, -}) => { - const uiTimezone = useSelector((state) => state.ui.timezone); - const theme = useTheme(); - - const format = tooltipDateFormatLookup(dateRange); - if (active && payload && payload.length) { - const [hardwareType, metric] = yKey.split("."); - return ( - - - {formatDateWithTz(label, format, uiTimezone)} - - - - - - {yIdx >= 0 - ? `${yLabel} ${formatter(payload[0].payload[hardwareType][yIdx][metric])}` - : `${yLabel} ${formatter(payload[0].payload[yKey])}`} - - - - {/* Display original value */} - - ); - } - return null; -}; - -InfrastructureTooltip.propTypes = { - active: PropTypes.bool, - payload: PropTypes.array, - label: PropTypes.oneOfType([ - PropTypes.instanceOf(Date), - PropTypes.string, - PropTypes.number, - ]), - yKey: PropTypes.string, - yIdx: PropTypes.number, - yLabel: PropTypes.string, - dotColor: PropTypes.string, - dateRange: PropTypes.string, -}; - -export const TemperatureTooltip = ({ - active, - payload, - label, - keys, - dotColor, - dateRange, -}) => { - const uiTimezone = useSelector((state) => state.ui.timezone); - const theme = useTheme(); - const format = tooltipDateFormatLookup(dateRange); - const formatCoreKey = (key) => { - return key.replace(/^core(\d+)$/, "Core $1"); - }; - if (active && payload && payload.length) { - return ( - - - {formatDateWithTz(label, format, uiTimezone)} - - - - {keys.map((key) => { - return ( - - - - - - {`${formatCoreKey(key)}: ${payload[0].payload[key]} °C`} - - - - - ); - })} - - - ); - } - return null; -}; - -TemperatureTooltip.propTypes = { - active: PropTypes.bool, - keys: PropTypes.array, - payload: PropTypes.array, - label: PropTypes.oneOfType([ - PropTypes.instanceOf(Date), - PropTypes.string, - PropTypes.number, - ]), - dotColor: PropTypes.string, - dateRange: PropTypes.string, -}; diff --git a/client/src/Components/v1/Charts/Utils/gradientUtils.jsx b/client/src/Components/v1/Charts/Utils/gradientUtils.jsx deleted file mode 100644 index b5920374d..000000000 --- a/client/src/Components/v1/Charts/Utils/gradientUtils.jsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Creates an SVG gradient definition for use in charts - * @param {Object} params - The gradient parameters - * @param {string} [params.id="colorUv"] - Unique identifier for the gradient - * @param {string} params.startColor - Starting color of the gradient (hex, rgb, or color name) - * @param {string} params.endColor - Ending color of the gradient (hex, rgb, or color name) - * @param {number} [params.startOpacity=0.8] - Starting opacity (0-1) - * @param {number} [params.endOpacity=0] - Ending opacity (0-1) - * @param {('vertical'|'horizontal')} [params.direction="vertical"] - Direction of the gradient - * @returns {JSX.Element} SVG gradient definition element - * @example - * createCustomGradient({ - * startColor: "#1976D2", - * endColor: "#42A5F5", - * direction: "horizontal" - * }) - */ - -export const createGradient = ({ - id, - startColor, - endColor, - startOpacity = 0.8, - endOpacity = 0, - direction = "vertical", // or "horizontal" -}) => ( - - - - - - -); diff --git a/client/src/Components/v1/Check/Check.jsx b/client/src/Components/v1/Check/Check.jsx deleted file mode 100644 index c40f3de3e..000000000 --- a/client/src/Components/v1/Check/Check.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import "./check.css"; -import PropTypes from "prop-types"; -import Icon from "../Icon"; -import { Box, Stack, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -/** - * `Check` is a functional React component that displays a check icon and a label. - * - * @component - * @param {Object} props - The properties that define the `Check` component. - * @param {string} props.text - The text to be displayed as the label next to the check icon. - * @param {'info' | 'error' | 'success'} [props.variant='info'] - The variant of the check component, affecting its styling. - * @param {boolean} [props.outlined] - Whether the check icon should be outlined or not. - * - * @example - * // To use this component, import it and use it in your JSX like this: - * - * - * @returns {React.Element} The `Check` component with a check icon and a label, defined by the `text` prop. - */ -const Check = ({ text, noHighlightText, variant = "info", outlined = false }) => { - const theme = useTheme(); - const colors = { - success: theme.palette.success.main, - error: theme.palette.error.main, - info: theme.palette.info.border, - }; - return ( - - {outlined ? ( - - ) : ( - - )} - - {noHighlightText && ( - - {noHighlightText} - - )}{" "} - {text} - - - ); -}; - -Check.propTypes = { - text: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), - noHighlightText: PropTypes.string, - variant: PropTypes.oneOf(["info", "error", "success"]), - outlined: PropTypes.bool, -}; - -export default Check; diff --git a/client/src/Components/v1/Check/check.css b/client/src/Components/v1/Check/check.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Components/v1/CircularCount/index.jsx b/client/src/Components/v1/CircularCount/index.jsx deleted file mode 100644 index 8350a8c44..000000000 --- a/client/src/Components/v1/CircularCount/index.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Box } from "@mui/material"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -const CircularCount = ({ count }) => { - const theme = useTheme(); - return ( - - {count} - - ); -}; - -CircularCount.propTypes = { - count: PropTypes.number, -}; - -export default CircularCount; diff --git a/client/src/Components/v1/Common/AppBar.jsx b/client/src/Components/v1/Common/AppBar.jsx deleted file mode 100644 index 5cd3db5c1..000000000 --- a/client/src/Components/v1/Common/AppBar.jsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useState } from "react"; -import { styled, alpha } from "@mui/material/styles"; -import Box from "@mui/material/Box"; -import AppBar from "@mui/material/AppBar"; -import Toolbar from "@mui/material/Toolbar"; -import Button from "@mui/material/Button"; -import IconButton from "@mui/material/IconButton"; -import Container from "@mui/material/Container"; -import MenuItem from "@mui/material/MenuItem"; -import Drawer from "@mui/material/Drawer"; -import Icon from "../Icon"; -import ThemeSwitch from "../ThemeSwitch/index.jsx"; -import { useTheme } from "@mui/material/styles"; -import { useSelector } from "react-redux"; -import { useLocation, useNavigate } from "react-router-dom"; - -const StyledToolbar = styled(Toolbar)(({ theme, mode }) => ({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - flexShrink: 0, - borderRadius: `calc(${theme.shape.borderRadius}px + 4px)`, - backdropFilter: "blur(24px)", - border: "1px solid", - borderColor: - mode === "light" - ? alpha(theme.palette.common.black, 0.1) - : alpha(theme.palette.common.white, 0.1), - backgroundColor: - mode === "light" - ? alpha(theme.palette.common.white, 0.4) - : alpha(theme.palette.common.black, 0.4), - boxShadow: theme.shadows[3], - padding: "8px 12px", -})); - -const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ - fontSize: "1.1rem", - margin: theme.spacing(4, 2), -})); - -const AppAppBar = () => { - const [open, setOpen] = useState(false); - const theme = useTheme(); - const mode = useSelector((state) => state.ui.mode); - const location = useLocation(); - const navigate = useNavigate(); - - const logoSrc = - mode === "light" ? "/images/prism-black.png" : "/images/prism-white.png"; - - const toggleDrawer = (newOpen) => () => { - setOpen(newOpen); - }; - - const handleScroll = (id) => { - if (location.pathname === "/") { - const element = document.getElementById(id); - if (element) { - element.scrollIntoView({ behavior: "smooth" }); - } - } else { - navigate(`/#${id}`); - } - }; - - return ( - - - - - Prism Logo - - - - - - - - - {/* - */} - - - - - - - - - - - - - - Features - Testimonials - Highlights - FAQ - - Blog - - {/* - - - - - */} - - - - - - - - ); -}; - -export default AppAppBar; diff --git a/client/src/Components/v1/Common/Footer.jsx b/client/src/Components/v1/Common/Footer.jsx deleted file mode 100644 index 92f01a9ae..000000000 --- a/client/src/Components/v1/Common/Footer.jsx +++ /dev/null @@ -1,255 +0,0 @@ -import * as React from "react"; -import Box from "@mui/material/Box"; -import Container from "@mui/material/Container"; -import IconButton from "@mui/material/IconButton"; -import Link from "@mui/material/Link"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { FaFacebook, FaLinkedin, FaGithub, FaTwitter, FaEnvelope } from "react-icons/fa"; - -function Copyright() { - return ( - - {"Copyright © "} - - UpRock - -   - {new Date().getFullYear()} - - ); -} - -export default function Footer() { - return ( - - -
- - Privacy Policy - - -  •  - - - Terms of Service - - -
- - - - - - - - - - - - - - - - - -
- - - - Made with ❤️ by  - - UpRock  - - &  - - Bluewave Labs - - - - - Built on  - - - - - - - - - - - - - - - - - - - - -  Solana - - - -
- ); -} diff --git a/client/src/Components/v1/ConfigRow/index.jsx b/client/src/Components/v1/ConfigRow/index.jsx deleted file mode 100644 index 12fea923e..000000000 --- a/client/src/Components/v1/ConfigRow/index.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Box, Stack, Typography } from "@mui/material"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import ConfigBox from "../ConfigBox/index.jsx"; - -/** - * ConfigRow is a styled container used to layout content in a row format with specific padding, border, and spacing. - * It serves as the wrapper for ConfigBox, with the left section displaying the title and description, - * and the right section displaying the children. - * - * @component - * @example - * return ( - * - *
Left content (Title + Description)
- *
Right content (Children)
- *
- * ); - */ - -const ConfigRow = ({ title, description, children }) => { - const theme = useTheme(); - - return ( - - - - {title} - - {description && ( - - {description} - - )} - - - {children} - - - ); -}; - -ConfigRow.propTypes = { - title: PropTypes.string.isRequired, - description: PropTypes.string, - children: PropTypes.node, -}; - -export default ConfigRow; diff --git a/client/src/Components/v1/Dot/index.jsx b/client/src/Components/v1/Dot/index.jsx deleted file mode 100644 index 0676d0a6f..000000000 --- a/client/src/Components/v1/Dot/index.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import PropTypes from "prop-types"; - -const Dot = ({ color = "gray", size = "4px", style }) => { - return ( - - ); -}; - -Dot.propTypes = { - color: PropTypes.string, - size: PropTypes.string, -}; - -export default Dot; diff --git a/client/src/Components/v1/Fallback/FallBackActionButtons.jsx b/client/src/Components/v1/Fallback/FallBackActionButtons.jsx deleted file mode 100644 index 144b16501..000000000 --- a/client/src/Components/v1/Fallback/FallBackActionButtons.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Button, Stack } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; - -const FallbackActionButtons = ({ link, type }) => { - const theme = useTheme(); - const navigate = useNavigate(); - const { t } = useTranslation(); - - return ( - - - {type === "uptimeMonitor" && ( - - )} - - ); -}; -FallbackActionButtons.propTypes = { - title: PropTypes.string.isRequired, - link: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, -}; - -export default FallbackActionButtons; diff --git a/client/src/Components/v1/Fallback/FallbackBackground.jsx b/client/src/Components/v1/Fallback/FallbackBackground.jsx deleted file mode 100644 index 2182082fd..000000000 --- a/client/src/Components/v1/Fallback/FallbackBackground.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useTheme } from "@emotion/react"; -import Box from "@mui/material/Box"; -import Background from "../../../assets/Images/background-grid.svg?react"; -import OutputAnimation from "../../../assets/Animations/output.gif"; -import DarkmodeOutput from "../../../assets/Animations/darkmodeOutput.gif"; -import { useSelector } from "react-redux"; -const FallbackBackground = () => { - const theme = useTheme(); - const mode = useSelector((state) => state.ui.mode); - return ( - <> - - - - - - ); -}; - -export default FallbackBackground; diff --git a/client/src/Components/v1/Fallback/FallbackCheckList.jsx b/client/src/Components/v1/Fallback/FallbackCheckList.jsx deleted file mode 100644 index befb95185..000000000 --- a/client/src/Components/v1/Fallback/FallbackCheckList.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@mui/material/styles"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Check from "../Check/Check.jsx"; -const FallbackCheckList = ({ checks, type }) => { - const theme = useTheme(); - return ( - - {checks?.map((check, index) => ( - - ))} - - ); -}; - -FallbackCheckList.propTypes = { - checks: PropTypes.arrayOf(PropTypes.string).isRequired, - title: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, -}; - -export default FallbackCheckList; diff --git a/client/src/Components/v1/Fallback/FallbackContainer.jsx b/client/src/Components/v1/Fallback/FallbackContainer.jsx deleted file mode 100644 index 7a2ec5fe0..000000000 --- a/client/src/Components/v1/Fallback/FallbackContainer.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useTheme } from "@emotion/react"; -import { Box, Stack } from "@mui/material"; -import PropTypes from "prop-types"; - -const FallbackContainer = ({ children, type }) => { - const theme = useTheme(); - return ( - - - {children} - - - ); -}; - -FallbackContainer.propTypes = { - children: PropTypes.node, - type: PropTypes.string, -}; - -export default FallbackContainer; diff --git a/client/src/Components/v1/Fallback/FallbackPageSpeedWarning.jsx b/client/src/Components/v1/Fallback/FallbackPageSpeedWarning.jsx deleted file mode 100644 index 2cf52bd88..000000000 --- a/client/src/Components/v1/Fallback/FallbackPageSpeedWarning.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import Box from "@mui/material/Box"; -import { useTheme } from "@mui/material/styles"; -import Link from "@mui/material/Link"; -import { Link as RouterLink } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import Alert from "../Alert/index.jsx"; -import PropTypes from "prop-types"; - -const renderWarningMessage = (t) => { - return ( - <> - {t("pageSpeedWarning")}{" "} - - {t("pageSpeedLearnMoreLink")} - {" "} - {t("pageSpeedAddApiKey")} - - ); -}; - -const FallbackPageSpeedWarning = ({ settingsData }) => { - const theme = useTheme(); - const { t } = useTranslation(); - return ( - - svg": { - color: theme.palette.warningSecondary.contrastText, - }, - }, - }} - > - {settingsData?.pagespeedKeySet === false && ( - - )} - - - ); -}; - -FallbackPageSpeedWarning.propTypes = { - settingsData: PropTypes.shape({ - pagespeedKeySet: PropTypes.bool, - }), -}; - -export default FallbackPageSpeedWarning; diff --git a/client/src/Components/v1/Fallback/FallbackTitle.jsx b/client/src/Components/v1/Fallback/FallbackTitle.jsx deleted file mode 100644 index 69fee05c6..000000000 --- a/client/src/Components/v1/Fallback/FallbackTitle.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useTheme } from "@mui/material/styles"; -import { Typography } from "@mui/material"; -import PropTypes from "prop-types"; - -const FallbackTitle = ({ title }) => { - const theme = useTheme(); - return ( - - {title} - - ); -}; -FallbackTitle.propTypes = { - title: PropTypes.string.isRequired, -}; -export default FallbackTitle; diff --git a/client/src/Components/v1/Fallback/index.css b/client/src/Components/v1/Fallback/index.css deleted file mode 100644 index 6988fa5bf..000000000 --- a/client/src/Components/v1/Fallback/index.css +++ /dev/null @@ -1,40 +0,0 @@ -[class*="fallback__"] { - width: fit-content; - margin: auto; -} - -[class*="fallback__"] h1.MuiTypography-root { - font-size: var(--env-var-font-size-large); - font-weight: 600; -} - -[class*="fallback__"] button.MuiButtonBase-root, -[class*="fallback__"] .check { - width: max-content; -} - -[class*="fallback__"] button.MuiButtonBase-root { - min-height: 34px; -} - -[class*="fallback__"] .check span.MuiTypography-root, -[class*="fallback__"] button.MuiButtonBase-root { - font-size: var(--env-var-font-size-medium); -} - -[class*="fallback__"] .background-pattern-svg { - position: absolute; - top: 0; - left: 50%; - transform: translate(-50%, -50%); - z-index: 0; - - width: 100%; - max-width: 800px; - height: 100%; - max-height: 800px; - - background-position: center; - background-size: cover; - background-repeat: no-repeat; -} diff --git a/client/src/Components/v1/Fallback/index.jsx b/client/src/Components/v1/Fallback/index.jsx deleted file mode 100644 index 40113f882..000000000 --- a/client/src/Components/v1/Fallback/index.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Stack } from "@mui/material"; -import "./index.css"; -import FallbackTitle from "./FallbackTitle.jsx"; -import FallbackCheckList from "./FallbackCheckList.jsx"; -import FallbackActionButtons from "./FallBackActionButtons.jsx"; -import FallbackBackground from "./FallbackBackground.jsx"; -import FallbackContainer from "./FallbackContainer.jsx"; -/** - * Fallback component to display a fallback UI with a title, a list of checks, and a navigation button. - * - * @param {Object} props - The component props. - * @param {string} props.title - The title to be displayed in the fallback UI. - * @param {string} props.type - The type of the fallback (e.g., "pageSpeed", "notifications"). - * @param {Array} props.checks - An array of strings representing the checks to display. - * @param {string} [props.link="/"] - The link to navigate to. - * @param {boolean} [props.showPageSpeedWarning=false] - Whether to show the PageSpeed API warning. - * @returns {JSX.Element} The rendered fallback UI. - */ - -const Fallback = ({ title, checks, link = "/", isAdmin, type, children }) => { - const theme = useTheme(); - return ( - - - - - - - - {isAdmin && ( - - - {children} - - )} - - - ); -}; -Fallback.propTypes = { - title: PropTypes.string.isRequired, - checks: PropTypes.arrayOf(PropTypes.string).isRequired, - link: PropTypes.string, - isAdmin: PropTypes.bool, - showPageSpeedWarning: PropTypes.bool, - type: PropTypes.string.isRequired, - children: PropTypes.node, -}; -export default Fallback; diff --git a/client/src/Components/v1/GenericFallback/NetworkError.jsx b/client/src/Components/v1/GenericFallback/NetworkError.jsx deleted file mode 100644 index ddffa1c31..000000000 --- a/client/src/Components/v1/GenericFallback/NetworkError.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; - -const NetworkError = () => { - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - <> - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); -}; - -export default NetworkError; diff --git a/client/src/Components/v1/GenericFallback/index.jsx b/client/src/Components/v1/GenericFallback/index.jsx deleted file mode 100644 index 188c09f37..000000000 --- a/client/src/Components/v1/GenericFallback/index.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useTheme } from "@emotion/react"; -import { Box, Stack } from "@mui/material"; -import OutputAnimation from "../../../assets/Animations/output.gif"; -import DarkmodeOutput from "../../../assets/Animations/darkmodeOutput.gif"; -import Background from "../../../assets/Images/background-grid.svg?react"; -import { useSelector } from "react-redux"; - -/** - * Fallback component to display a fallback UI for network errors - * - * @returns {JSX.Element} The rendered fallback UI. - */ - -const GenericFallback = ({ children }) => { - const theme = useTheme(); - const mode = useSelector((state) => state.ui.mode); - - return ( - - - - - - - - {children} - - - - ); -}; - -export default GenericFallback; diff --git a/client/src/Components/v1/Heading/index.jsx b/client/src/Components/v1/Heading/index.jsx deleted file mode 100644 index 1a5f1a53b..000000000 --- a/client/src/Components/v1/Heading/index.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Typography } from "@mui/material"; -import PropTypes from "prop-types"; - -/** - * A heading component that renders text with a specific heading level. - * - * @param {Object} props - The properties passed to the Heading component. - * @param {('h1'|'h2'|'h3')} props.component - The heading level for the component. - * @param {Object} props.style - Custom styles to apply to the heading. - * @param {string} props.children - The content to display inside the heading. - * @returns {JSX.Element} The Typography component with specified heading properties. - */ - -function Heading({ component, style, children }) { - return ( - - {children} - - ); -} - -Heading.propTypes = { - component: PropTypes.oneOf(["h1", "h2", "h3"]).isRequired, - style: PropTypes.object, - children: PropTypes.string.isRequired, -}; -export { Heading }; diff --git a/client/src/Components/v1/Host/index.jsx b/client/src/Components/v1/Host/index.jsx deleted file mode 100644 index 6670722c1..000000000 --- a/client/src/Components/v1/Host/index.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Stack, Typography } from "@mui/material"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import Dot from "../Dot/index.jsx"; -import { StatusLabel } from "@/Components/v1/Label/index.jsx"; -/** - * Host component. - * This subcomponent receives a params object and displays the host details. - * - * @component - * @param {Object} params - An object containing the following properties: - * @param {string} params.url - The URL of the host. - * @param {string} params.title - The name of the host. - * @param {string} params.percentageColor - The color of the percentage text. - * @param {number} params.percentage - The percentage to display. - * @returns {React.ElementType} Returns a div element with the host details. - */ -const Host = ({ url, title, percentageColor, percentage, showURL, status }) => { - const theme = useTheme(); - return ( - - - - {title} - - - {percentageColor && percentage && ( - <> - - - {percentage}% - - {status && ( - - )} - - )} - - {showURL && {url}} - - ); -}; - -Host.propTypes = { - title: PropTypes.string, - percentageColor: PropTypes.string, - percentage: PropTypes.string, - url: PropTypes.string, - showURL: PropTypes.bool, - status: PropTypes.string, -}; - -export default Host; diff --git a/client/src/Components/v1/HttpStatusLabel/index.jsx b/client/src/Components/v1/HttpStatusLabel/index.jsx deleted file mode 100644 index 2253e323e..000000000 --- a/client/src/Components/v1/HttpStatusLabel/index.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@mui/material"; -import { BaseLabel } from "../Label/index.jsx"; - -/** - * @component - * @param {Object} props - * @param {number} props.status - The http status for the label - * @param {Styles} props.customStyles - CSS Styles passed from parent component - * @returns {JSX.Element} - * @example - * // Render a http status label - * - */ - -const DEFAULT_CODE = 9999; // Default code for unknown status - -const handleStatusCode = (status) => { - if (status) { - return status; - } - return DEFAULT_CODE; -}; - -const getRoundedStatusCode = (status) => { - return Math.floor(status / 100) * 100; -}; - -const HttpStatusLabel = ({ status, customStyles }) => { - const theme = useTheme(); - const colors = { - 400: { - color: theme.palette.warning.main, - borderColor: theme.palette.warning.lowContrast, - }, - 500: { - color: theme.palette.error.main, - borderColor: theme.palette.error.lowContrast, - }, - default: { - color: theme.palette.primary.contrastText, - borderColor: theme.palette.primary.contrastText, - }, - }; - - const statusCode = handleStatusCode(status); - - const { borderColor, color } = - colors[getRoundedStatusCode(statusCode)] || colors.default; - return ( - - ); -}; - -HttpStatusLabel.propTypes = { - status: PropTypes.number, - customStyles: PropTypes.object, -}; - -export { HttpStatusLabel }; diff --git a/client/src/Components/v1/IconBox/index.jsx b/client/src/Components/v1/IconBox/index.jsx deleted file mode 100644 index bb9770e6a..000000000 --- a/client/src/Components/v1/IconBox/index.jsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Box, styled } from "@mui/material"; -import PropTypes from "prop-types"; - -/** - * IconBox - A styled box component for rendering icons with consistent sizing and styling - * - * @component - * @param {Object} [props] - Configuration options for the IconBox - * @param {number} [props.height=34] - Height of the icon box - * @param {number} [props.width=34] - Width of the icon box - * @param {number} [props.minWidth=34] - Minimum width of the icon box - * @param {number} [props.borderRadius=4] - Border radius of the icon box - * @param {number} [props.svgWidth=20] - Width of the SVG icon - * @param {number} [props.svgHeight=20] - Height of the SVG icon - * - * @example - * // Basic usage - * - * - * - * - * @example - * // Customized usage - * - * - * - * - * @returns {React.ReactElement} A styled box containing an icon - */ -const IconBox = styled(Box)( - ({ - theme, - height = 34, - width = 34, - minWidth = 34, - borderRadius = 4, - svgWidth = 20, - svgHeight = 20, - }) => ({ - height: height, - minWidth: minWidth, - width: width, - position: "relative", - border: 1, - borderStyle: "solid", - borderColor: theme.palette.primary.lowContrast, - borderRadius: borderRadius, - backgroundColor: theme.palette.tertiary.main, - "& svg": { - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - width: svgWidth, - height: svgHeight, - "& path": { - stroke: theme.palette.primary.contrastTextTertiary, - }, - }, - }) -); - -IconBox.propTypes = { - height: PropTypes.number, - width: PropTypes.number, - minWidth: PropTypes.number, - borderRadius: PropTypes.number, - svgWidth: PropTypes.number, - svgHeight: PropTypes.number, -}; - -export default IconBox; diff --git a/client/src/Components/v1/Image/index.jsx b/client/src/Components/v1/Image/index.jsx deleted file mode 100644 index 8f30f8a9e..000000000 --- a/client/src/Components/v1/Image/index.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Box } from "@mui/material"; -import PropTypes from "prop-types"; - -const isValidBase64Image = (data) => { - return /^[A-Za-z0-9+/=]+$/.test(data); -}; - -const Image = ({ - shouldRender = true, - src, - alt, - width = "auto", - height = "auto", - minWidth = "auto", - minHeight = "auto", - maxWidth = "auto", - maxHeight = "auto", - base64, - placeholder, - sx, -}) => { - if (shouldRender === false) { - return null; - } - - if (typeof src !== "undefined" && typeof base64 !== "undefined") { - console.warn("base64 takes precedence over src and overwrites it"); - } - - if (typeof base64 !== "undefined" && isValidBase64Image(base64)) { - src = `data:image/png;base64,${base64}`; - } - - if ( - typeof src === "undefined" && - typeof base64 === "undefined" && - typeof placeholder !== "undefined" - ) { - src = placeholder; - } - - return ( - - ); -}; - -Image.propTypes = { - shouldRender: PropTypes.bool, - src: PropTypes.string, - alt: PropTypes.string.isRequired, - width: PropTypes.string, - height: PropTypes.string, - minWidth: PropTypes.string, - minHeight: PropTypes.string, - maxWidth: PropTypes.string, - maxHeight: PropTypes.string, - base64: PropTypes.string, - sx: PropTypes.object, -}; - -export default Image; diff --git a/client/src/Components/v1/InfoBox/index.jsx b/client/src/Components/v1/InfoBox/index.jsx deleted file mode 100644 index 96e67975d..000000000 --- a/client/src/Components/v1/InfoBox/index.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { RowContainer } from "../StandardContainer/index.jsx"; -import { Stack, Typography } from "@mui/material"; -import Image from "../Image/index.jsx"; - -import { useTheme } from "@mui/material/styles"; - -const InfoBox = ({ - img, - icon: Icon, - alt, - heading, - headingLevel = 2, - subHeading, - subHeadingLevel = "", - sx, -}) => { - const theme = useTheme(); - return ( - - {img && ( - - )} - {Icon && ( - - )} - - {heading} - - {subHeading} - - - - ); -}; - -export default InfoBox; diff --git a/client/src/Components/v1/MonitorCreateHeader/index.jsx b/client/src/Components/v1/MonitorCreateHeader/index.jsx deleted file mode 100644 index c3a979eb9..000000000 --- a/client/src/Components/v1/MonitorCreateHeader/index.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Stack, Button } from "@mui/material"; -import { useNavigate } from "react-router-dom"; -import PropTypes from "prop-types"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "@emotion/react"; - -const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const theme = useTheme(); - - // Use the provided label or fall back to the translated default - - if (!isAdmin) return null; - - return ( - - - {/* {bulkPath && } */} - - ); -}; - -export default CreateMonitorHeader; - -CreateMonitorHeader.propTypes = { - isAdmin: PropTypes.bool.isRequired, - isLoading: PropTypes.bool, - path: PropTypes.string.isRequired, - label: PropTypes.string, - bulkPath: PropTypes.string, -}; diff --git a/client/src/Components/v1/MonitorCreateHeader/skeleton.jsx b/client/src/Components/v1/MonitorCreateHeader/skeleton.jsx deleted file mode 100644 index 7583f7849..000000000 --- a/client/src/Components/v1/MonitorCreateHeader/skeleton.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Components/v1/MonitorTimeFrameHeader/index.jsx b/client/src/Components/v1/MonitorTimeFrameHeader/index.jsx deleted file mode 100644 index 9d57edc41..000000000 --- a/client/src/Components/v1/MonitorTimeFrameHeader/index.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Stack, Typography, Button, ButtonGroup } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import SkeletonLayout from "./skeleton.jsx"; -import PropTypes from "prop-types"; - -const MonitorTimeFrameHeader = ({ - isLoading = false, - hasDateRange = true, - dateRange, - setDateRange, -}) => { - const theme = useTheme(); - - if (isLoading) { - return ; - } - - let timeFramePicker = null; - - if (hasDateRange) { - timeFramePicker = ( - - - - - - - ); - } - - return ( - - - Showing statistics for past{" "} - {dateRange === "recent" - ? "2 hours" - : dateRange === "day" - ? "24 hours" - : dateRange === "week" - ? "7 days" - : "30 days"} - . - - {timeFramePicker} - - ); -}; - -MonitorTimeFrameHeader.propTypes = { - isLoading: PropTypes.bool, - hasDateRange: PropTypes.bool, - dateRange: PropTypes.string, - setDateRange: PropTypes.func, -}; - -export default MonitorTimeFrameHeader; diff --git a/client/src/Components/v1/MonitorTimeFrameHeader/skeleton.jsx b/client/src/Components/v1/MonitorTimeFrameHeader/skeleton.jsx deleted file mode 100644 index d7c395d54..000000000 --- a/client/src/Components/v1/MonitorTimeFrameHeader/skeleton.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -const SkeletonLayout = () => { - return ( - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Components/v1/PageStateWrapper/index.jsx b/client/src/Components/v1/PageStateWrapper/index.jsx deleted file mode 100644 index 08adbe514..000000000 --- a/client/src/Components/v1/PageStateWrapper/index.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import GenericFallback from "../GenericFallback/index.jsx"; -import Fallback from "../Fallback/index.jsx"; -import { Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import PropTypes from "prop-types"; -const PageStateWrapper = ({ - networkError, - isLoading, - items, - type, - fallbackLink, - fallbackChildren, - children, -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - const isAdmin = useIsAdmin(); - const isEmpty = items === null || (Array.isArray(items) && items.length === 0); - const hasInitialized = items !== undefined && !(isLoading && isEmpty); - const showEmpty = !isLoading && isEmpty; - if (networkError) { - return ( - - - {t("common.toasts.networkError")} - - {t("common.toasts.checkConnection")} - - ); - } - if (!hasInitialized) { - return null; - } - if (showEmpty) { - return ( - - {fallbackChildren} - - ); - } - return <>{children}; -}; - -PageStateWrapper.propTypes = { - networkError: PropTypes.any, - isLoading: PropTypes.bool, - items: PropTypes.oneOfType([PropTypes.array, PropTypes.oneOf([null])]), - type: PropTypes.string, - fallbackLink: PropTypes.string, - fallbackChildren: PropTypes.node, - children: PropTypes.node, -}; - -export default PageStateWrapper; diff --git a/client/src/Components/v1/ProgressBars/index.css b/client/src/Components/v1/ProgressBars/index.css deleted file mode 100644 index 9b4ab2c7d..000000000 --- a/client/src/Components/v1/ProgressBars/index.css +++ /dev/null @@ -1,10 +0,0 @@ -.progress-bar-container h2.MuiTypography-root, -.progress-bar-container p.MuiTypography-root { - font-size: var(--env-var-font-size-medium); -} -.progress-bar-container p.MuiTypography-root:has(span) { - font-size: 12px; -} -.progress-bar-container p.MuiTypography-root span { - padding-left: 2px; -} diff --git a/client/src/Components/v1/ProgressBars/index.jsx b/client/src/Components/v1/ProgressBars/index.jsx deleted file mode 100644 index 86cedcbaa..000000000 --- a/client/src/Components/v1/ProgressBars/index.jsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -import { Box, IconButton, LinearProgress, Stack, Typography } from "@mui/material"; -import Icon from "../Icon"; -import "./index.css"; - -/** - * @param {Object} props - The component props. - * @param {JSX.Element} props.icon - The icon element to display (optional). - * @param {string} props.label - The label text for the progress item. - * @param {string} props.size - The size information for the progress item. - * @param {number} props.progress - The current progress value (0-100). - * @param {function} props.onClick - The function to handle click events on the remove button. - * @param {string} props.error - Error message to display if there's an error (optional). - * @returns {JSX.Element} The rendered component. - */ - -const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) => { - const theme = useTheme(); - - return ( - .MuiStack-root > svg": { - fill: theme.palette.error.contrastText, - width: "20px", - height: "20px", - }, - }, - }} - > - - {error ? ( - - ) : icon ? ( - - {icon} - - ) : ( - "" - )} - {error ? ( - - {error} - - ) : ( - - - {error ? error : label} - - - {!error && size} - - - )} - - - - - {!error ? ( - - - - - - {progress} - % - - - ) : ( - "" - )} - - ); -}; - -ProgressUpload.propTypes = { - icon: PropTypes.element, // JSX element for the icon (optional) - label: PropTypes.string, // Label text for the progress item - size: PropTypes.string.isRequired, // Size information for the progress item - progress: PropTypes.number.isRequired, // Current progress value (0-100) - onClick: PropTypes.func.isRequired, // Function to handle click events on the remove button - error: PropTypes.string, // Error message to display if there's an error (optional) -}; - -export default ProgressUpload; diff --git a/client/src/Components/v1/ProgressStepper/index.css b/client/src/Components/v1/ProgressStepper/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Components/v1/ProgressStepper/index.jsx b/client/src/Components/v1/ProgressStepper/index.jsx deleted file mode 100644 index 9fb0d15dc..000000000 --- a/client/src/Components/v1/ProgressStepper/index.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import { Step, Stepper, StepLabel, Typography } from "@mui/material"; -import Icon from "../Icon"; -import PropTypes from "prop-types"; - -const CustomStepIcon = (props) => { - const { completed, active } = props; - return completed ? ( - - ) : ( - - ); -}; - -CustomStepIcon.propTypes = { - completed: PropTypes.bool.isRequired, - active: PropTypes.bool.isRequired, -}; - -/** - * @component - * @param {Object} props - * @param {Array} props.steps - */ - -const ProgressStepper = ({ steps }) => { - const [activeStep, setActiveStep] = React.useState(1); - return ( - - {steps.map((step, index) => { - const color = activeStep === index ? "primary" : "inherit"; - return ( - setActiveStep(index)} - > - - - {step.label} - - - - {step.content} - - - ); - })} - - ); -}; - -ProgressStepper.propTypes = { - steps: PropTypes.array.isRequired, -}; - -export default ProgressStepper; diff --git a/client/src/Components/v1/ShareComponent/index.jsx b/client/src/Components/v1/ShareComponent/index.jsx deleted file mode 100644 index 476b3672f..000000000 --- a/client/src/Components/v1/ShareComponent/index.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import html2canvas from "html2canvas"; -import Icon from "../Icon"; -import { Button } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -const ShareComponent = ({ elementToCapture, fileName = "screenshot" }) => { - const theme = useTheme(); - const captureAndShare = async () => { - try { - // Temporarily apply styles directly to the element - const originalBackground = elementToCapture.current.style.background; - const originalPadding = elementToCapture.current.style.padding; - - elementToCapture.current.style.background = `radial-gradient(circle, ${theme.palette.gradient.color1}, ${theme.palette.gradient.color2}, ${theme.palette.gradient.color3}, ${theme.palette.gradient.color4}, ${theme.palette.gradient.color5})`; - elementToCapture.current.style.padding = `${theme.spacing(20)}`; - - // Capture the element directly - const canvas = await html2canvas(elementToCapture.current, { - useCORS: true, - scale: 2, - allowTaint: true, - backgroundColor: null, - }); - - // Restore original styles - elementToCapture.current.style.background = originalBackground; - elementToCapture.current.style.padding = originalPadding; - - const imageBlob = await new Promise((resolve) => - canvas.toBlob(resolve, "image/png") - ); - - const file = new File([imageBlob], `${fileName}.png`, { - type: "image/png", - }); - - if (navigator.share) { - await navigator.share({ - files: [file], - title: "Screenshot", - text: "Check out this screenshot!", - }); - } else { - const url = URL.createObjectURL(imageBlob); - const a = document.createElement("a"); - a.href = url; - a.download = `${fileName}.png`; - a.click(); - URL.revokeObjectURL(url); - } - } catch (error) { - console.error(error); - } - }; - return ( - - ); -}; - -export default ShareComponent; diff --git a/client/src/Components/v1/Skeletons/FullPage/index.jsx b/client/src/Components/v1/Skeletons/FullPage/index.jsx deleted file mode 100644 index 233fc3c06..000000000 --- a/client/src/Components/v1/Skeletons/FullPage/index.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; - -export const SkeletonLayout = () => { - return ( - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Components/v1/StandardContainer/index.jsx b/client/src/Components/v1/StandardContainer/index.jsx deleted file mode 100644 index c736b3ecc..000000000 --- a/client/src/Components/v1/StandardContainer/index.jsx +++ /dev/null @@ -1,51 +0,0 @@ -// Components -import { Stack } from "@mui/material"; - -// Utils -import { useTheme } from "@emotion/react"; - -const Container = ({ children, direction, backgroundColor, sx }) => { - const theme = useTheme(); - let bgColor = - typeof backgroundColor !== "undefined" - ? backgroundColor - : theme.palette.background.main; - return ( - - {children} - - ); -}; - -export const ColContainer = ({ children, backgroundColor, sx }) => { - return ( - - {children} - - ); -}; - -export const RowContainer = ({ children, backgroundColor, sx }) => { - return ( - - {children} - - ); -}; diff --git a/client/src/Components/v1/StatusBoxes/index.jsx b/client/src/Components/v1/StatusBoxes/index.jsx deleted file mode 100644 index a78337ba7..000000000 --- a/client/src/Components/v1/StatusBoxes/index.jsx +++ /dev/null @@ -1,37 +0,0 @@ -// Components -import { Stack } from "@mui/material"; -import SkeletonLayout from "./skeleton.jsx"; -// Utils -import { useTheme } from "@mui/material/styles"; -import PropTypes from "prop-types"; -const StatusBoxes = ({ shouldRender = true, flexWrap = "nowrap", children }) => { - const theme = useTheme(); - if (!shouldRender) { - return ( - - ); - } - - return ( - - {children} - - ); -}; - -StatusBoxes.propTypes = { - shouldRender: PropTypes.bool, - flexWrap: PropTypes.string, - children: PropTypes.node, -}; - -export default StatusBoxes; diff --git a/client/src/Components/v1/StatusBoxes/skeleton.jsx b/client/src/Components/v1/StatusBoxes/skeleton.jsx deleted file mode 100644 index bc8b18251..000000000 --- a/client/src/Components/v1/StatusBoxes/skeleton.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Stack, Skeleton } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; -const SkeletonLayout = ({ numBoxes, flexWrap }) => { - const theme = useTheme(); - return ( - - {Array.from({ length: numBoxes }).map((_, index) => { - const width = `${200 / numBoxes}%`; - return ( - - ); - })} - - ); -}; - -SkeletonLayout.propTypes = { - flexWrap: PropTypes.string, - numBoxes: PropTypes.number, -}; - -export default SkeletonLayout; diff --git a/client/src/Components/v1/Subheader/index.jsx b/client/src/Components/v1/Subheader/index.jsx deleted file mode 100644 index a76696567..000000000 --- a/client/src/Components/v1/Subheader/index.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Stack, Typography } from "@mui/material"; - -/** - - * - * @component - * @example - * - * @param {boolean} props.shouldRender - Whether to render the subheader - * @param {string} props.direction - Direction of the subheader - * @param {string} props.headerText - Header text - * @param {number} props.headerLevel - Font characteristic of the header - * @param {string} props.subHeaderText - Subheader text - * @param {number} props.subHeaderLevel - Font characteristic of the subheader - * @param {string} props.alignItems - Align items - * @param {node} props.children - Children - * @returns {JSX.Element} The rendered component - */ - -const SubHeader = ({ - shouldRender = true, - direction = "row", - headerText, - headerLevel = 1, - subHeaderText, - subHeaderLevel = 1, - alignItems = "center", - children, - ...props -}) => { - const theme = useTheme(); - - if (!shouldRender) { - return null; - } - - return ( - - - - - {headerText} - - - - {subHeaderText} - - - {children} - - ); -}; - -SubHeader.propTypes = { - shouldRender: PropTypes.bool, - direction: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - headerText: PropTypes.string, - headerLevel: PropTypes.number, - subHeaderText: PropTypes.string, - subHeaderLevel: PropTypes.number, - alignItems: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - children: PropTypes.node, -}; - -export default SubHeader; diff --git a/client/src/Components/v1/Tab/index.jsx b/client/src/Components/v1/Tab/index.jsx deleted file mode 100644 index 7609904d9..000000000 --- a/client/src/Components/v1/Tab/index.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Box, useTheme } from "@mui/material"; -import { TabList } from "@mui/lab"; -import PropTypes from "prop-types"; - -/** - * CustomTabList component - * @param {string} value - The currently selected tab's value. - * @param {function} onChange - Callback when a different tab is selected. - * @param {React.ReactNode} children - Tab components to render inside the TabList. - * @param {object} props - Additional props passed to the TabList component. - */ - -const CustomTabList = ({ value, onChange, children, ...props }) => { - const theme = useTheme(); - - return ( - - - {children} - - - ); -}; - -CustomTabList.propTypes = { - value: PropTypes.string, - onChange: PropTypes.func, - children: PropTypes.node, -}; - -export default CustomTabList; diff --git a/client/src/Components/v1/Table/TablePagination/Actions/index.jsx b/client/src/Components/v1/Table/TablePagination/Actions/index.jsx deleted file mode 100644 index 0df62878e..000000000 --- a/client/src/Components/v1/Table/TablePagination/Actions/index.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import PropTypes from "prop-types"; -import { Box, Button } from "@mui/material"; -import LeftArrow from "../../../ArrowLeft/index.jsx"; -import RightArrow from "../../../ArrowRight/index.jsx"; -import { useTheme } from "@emotion/react"; - -TablePaginationActions.propTypes = { - count: PropTypes.number.isRequired, - page: PropTypes.number.isRequired, - rowsPerPage: PropTypes.number.isRequired, - onPageChange: PropTypes.func.isRequired, -}; - -/** - * 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. - */ - -function TablePaginationActions({ count, page, rowsPerPage, onPageChange }) { - const handleFirstPageButtonClick = (event) => { - onPageChange(event, 0); - }; - const handleBackButtonClick = (event) => { - onPageChange(event, page - 1); - }; - const handleNextButtonClick = (event) => { - onPageChange(event, page + 1); - }; - const handleLastPageButtonClick = (event) => { - onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); - }; - - const theme = useTheme(); - - return ( - - - - - - - ); -} - -export { TablePaginationActions }; diff --git a/client/src/Components/v1/Table/TablePagination/index.jsx b/client/src/Components/v1/Table/TablePagination/index.jsx deleted file mode 100644 index 77ee5ded1..000000000 --- a/client/src/Components/v1/Table/TablePagination/index.jsx +++ /dev/null @@ -1,144 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Stack, TablePagination, Typography } from "@mui/material"; -import { TablePaginationActions } from "./Actions/index.jsx"; -import Icon from "../../Icon"; - -Pagination.propTypes = { - paginationLabel: PropTypes.string, // Label for the pagination. - itemCount: PropTypes.number, // Total number of items for pagination. - page: PropTypes.number, // Current page index. - rowsPerPage: PropTypes.number, // Number of rows displayed per page. - handleChangePage: PropTypes.func.isRequired, // Function to handle page changes. - handleChangeRowsPerPage: PropTypes.func, // Function to handle changes in rows per page. -}; - -const ROWS_PER_PAGE_OPTIONS = [5, 10, 15, 25]; - -// Determine whether pagination buttons should be shown -const shouldShowPaginationButtons = (itemCount, rowsPerPage) => { - return Math.ceil(itemCount / rowsPerPage) > 1; -}; - -// Empty action component for pagination (no-op component) -const EmptyPaginationActions = () =>
; - -/** - * Pagination component for table navigation with customized styling and behavior. - * - * @param {object} props - Component properties. - * @param {string} props.paginationLabel - Label for the pagination. - * @param {number} props.monitorCount - Total number of monitors to paginate. - * @param {number} props.page - Current page index (0-based). - * @param {number} props.rowsPerPage - Number of rows to display per page. - * @param {function} props.handleChangePage - Callback for handling page changes. - * @param {function} props.handleChangeRowsPerPage - Callback for handling changes to rows per page. - * @returns {JSX.Element} The Pagination component. - */ -function Pagination({ - paginationLabel, - itemCount = 0, - page = 0, - rowsPerPage = 5, - handleChangePage, - handleChangeRowsPerPage, -}) { - const theme = useTheme(); - - const start = page * rowsPerPage + 1; - const end = Math.min(page * rowsPerPage + rowsPerPage, itemCount); - const range = `${start} - ${end}`; - - const showPaginationButtons = shouldShowPaginationButtons(itemCount, rowsPerPage); - - return ( - - - Showing {range} of {itemCount} {paginationLabel} - - - `Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}` - } - slotProps={{ - select: { - MenuProps: { - keepMounted: true, - disableScrollLock: true, - PaperProps: { - className: "pagination-dropdown", - sx: { - mt: 0, - mb: theme.spacing(2), - }, - }, - transformOrigin: { vertical: "bottom", horizontal: "left" }, - anchorOrigin: { vertical: "top", horizontal: "left" }, - sx: { - mt: theme.spacing(-2), - }, - }, - inputProps: { id: "pagination-dropdown" }, - IconComponent: () => ( - - ), - sx: { - ml: theme.spacing(4), - mr: theme.spacing(12), - minWidth: theme.spacing(20), - textAlign: "left", - "&.Mui-focused > div": { - backgroundColor: theme.palette.primary.main, - }, - "& .MuiSelect-icon": { - // Add this style override - position: "absolute", - right: 0, - top: "50%", - transform: "translateY(-50%)", - }, - }, - }, - }} - sx={{ - color: theme.palette.primary.contrastTextSecondary, - "& svg path": { - stroke: theme.palette.primary.contrastTextTertiary, - strokeWidth: 1.3, - }, - "& .MuiSelect-select": { - border: 1, - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - }, - }} - /> - - ); -} - -export default Pagination; diff --git a/client/src/Components/v1/Table/TableUtils.js b/client/src/Components/v1/Table/TableUtils.js deleted file mode 100644 index a9f95a5b7..000000000 --- a/client/src/Components/v1/Table/TableUtils.js +++ /dev/null @@ -1,11 +0,0 @@ -export const createHeaderFactory = (getCellSx = () => {}) => { - return ({ id, content, onClick = () => {}, render = () => {} }) => { - return { - id, - content, - onClick, - getCellSx, - render, - }; - }; -}; diff --git a/client/src/Components/v1/Table/index.jsx b/client/src/Components/v1/Table/index.jsx deleted file mode 100644 index 2113a2215..000000000 --- a/client/src/Components/v1/Table/index.jsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, -} from "@mui/material"; -import Tooltip from "@mui/material/Tooltip"; -import SkeletonLayout from "./skeleton.jsx"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; - -/** - * @typedef {Object} Header - * @property {number|string} id - The unique identifier for the header. - * @property {React.ReactNode} content - The content to display in the header cell. - * @property {Function} onClick - A function to be called when this cell is clicked, receiving the event and row data as an argument. - * @property {Object} getCellSx - Function that takes a row and returns a style object for the table cell. - * @property {Function} render - A function to render the cell content for a given row. - */ - -/** - * @typedef {Object} Config - * @property {Function} onRowClick - A function to be called when a row is clicked, receiving the row data as an argument. - * @property {Object} rowSX - Style object for the table row. - */ - -/** - * DataTable component renders a table with headers and data. - * - * @param {Object} props - The component props. - * @param {Header[]} props.headers - An array of header objects, each containing an `id`, `content`, and `render` function. - * @param {Array} props.data - An array of data objects, each representing a row. - * @returns {JSX.Element} The rendered table component. - */ - -const DataTable = ({ - shouldRender = true, - headers = [], - data = [], - config = { - emptyView: "No data", - tooltipContent: null, - onRowClick: () => {}, - }, -}) => { - const theme = useTheme(); - if (!shouldRender) { - return ; - } - - if ((headers?.length ?? 0) === 0) { - return "No data"; - } - - return ( - -
- - - {headers.map((header, index) => ( - - {header.content} - - ))} - - - - {(data?.length ?? 0) === 0 ? ( - - - {config.emptyView} - - - ) : ( - data.map((row) => { - const key = row.id || row._id || Math.random(); - return ( - { - return [popper.width / 2 + 20, -popper.height / 8]; - }, - }, - }, - ], - }, - }} - > - config.onRowClick(row) : null} - > - {headers.map((header, index) => { - return ( - header.onClick(e, row) : null} - sx={header.getCellSx ? header.getCellSx(row) : {}} - > - {header.render(row)} - - ); - })} - - - ); - }) - )} - -
- - ); -}; - -DataTable.propTypes = { - shouldRender: PropTypes.bool, - headers: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - content: PropTypes.node.isRequired, - render: PropTypes.func.isRequired, - }) - ).isRequired, - data: PropTypes.array, - config: PropTypes.shape({ - onRowClick: PropTypes.func, - rowSX: PropTypes.object, - emptyView: PropTypes.node, - }), -}; - -export default DataTable; diff --git a/client/src/Components/v1/Table/skeleton.jsx b/client/src/Components/v1/Table/skeleton.jsx deleted file mode 100644 index 062d39156..000000000 --- a/client/src/Components/v1/Table/skeleton.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Skeleton } from "@mui/material"; - -const TableSkeleton = () => { - return ( - - ); -}; - -export default TableSkeleton; From 98943ad4611cf3837e208b41f1ae2cd7a150b9cb Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 10 Feb 2026 08:10:44 -0500 Subject: [PATCH 253/392] fix: removing JSdoc for usedebounce and comments --- client/src/Hooks/useDebounce.ts | 10 ---------- client/src/Pages/Infrastructure/Monitors/index.tsx | 3 +-- client/src/Pages/Uptime/Monitors/index.tsx | 3 +-- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/client/src/Hooks/useDebounce.ts b/client/src/Hooks/useDebounce.ts index 175d079fa..57a7d4079 100644 --- a/client/src/Hooks/useDebounce.ts +++ b/client/src/Hooks/useDebounce.ts @@ -1,15 +1,5 @@ import { useState, useEffect } from "react"; -/** - * Custom hook for debouncing values - * @template T - The type of the value being debounced - * @param value - The value to debounce - * @param delay - The debounce delay in milliseconds - * @returns The debounced value - * - * @example - * const debouncedSearch = useDebounce(search, 300); // Debounces the search input with a 300ms delay - */ const useDebounce = (value: T, delay: number): T => { const [debouncedValue, setDebouncedValue] = useState(value); diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 303c7d0f7..1d38dbdd3 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -44,8 +44,7 @@ const InfrastructureMonitors = () => { const [selectedMonitor, setSelectedMonitor] = useState(null); const isDialogOpen = Boolean(selectedMonitor); - // Debounce search input - 300ms delay - const debouncedSearch = useDebounce(search, 300); + const debouncedSearch = useDebounce(search, 300); const handleClearFilters = useCallback(() => { setSelectedStatus(""); diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index ec4935221..702bbd928 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -44,8 +44,7 @@ const UptimeMonitorsPage = () => { const [selectedMonitor, setSelectedMonitor] = useState(null); const isDialogOpen = Boolean(selectedMonitor); - // Debounce search input - 300ms delay - const debouncedSearch = useDebounce(search, 300); + const debouncedSearch = useDebounce(search, 300); // Convert filter selections to API filter values // Status: "up" -> true, "down" -> false From 7ef10b4fcd8ac6af1f29b98c3e8b2de3b3d7269f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 17:53:00 +0000 Subject: [PATCH 254/392] initial commit --- client/src/App.tsx | 2 +- .../Components/v1/Layouts/AppLayout/index.jsx | 31 -------- .../v2/design-elements/OfflineBanner.tsx | 76 +++++++++++++++++++ .../Components/v2/design-elements/index.tsx | 1 + client/src/Components/v2/layout/AppLayout.tsx | 39 ++++++++++ .../src/Components/v2/layout/RootLayout.tsx | 0 client/src/Components/v2/sidebar/index.tsx | 0 client/src/Utils/ApiClient.ts | 15 ++++ client/src/locales/en.json | 6 ++ 9 files changed, 138 insertions(+), 32 deletions(-) delete mode 100644 client/src/Components/v1/Layouts/AppLayout/index.jsx create mode 100644 client/src/Components/v2/design-elements/OfflineBanner.tsx create mode 100644 client/src/Components/v2/layout/AppLayout.tsx create mode 100644 client/src/Components/v2/layout/RootLayout.tsx create mode 100644 client/src/Components/v2/sidebar/index.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index ef3625cad..0a6b364f8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,7 +8,7 @@ import darkTheme from "./Utils/Theme/darkTheme"; import { CssBaseline, GlobalStyles } from "@mui/material"; import { logger } from "./Utils/Logger"; // Import the logger import { Routes } from "./Routes"; -import AppLayout from "@/Components/v1/Layouts/AppLayout"; +import AppLayout from "@/Components/v2/layout/AppLayout"; import type { RootState } from "@/Types/state"; function App() { diff --git a/client/src/Components/v1/Layouts/AppLayout/index.jsx b/client/src/Components/v1/Layouts/AppLayout/index.jsx deleted file mode 100644 index 0dbfb1e7a..000000000 --- a/client/src/Components/v1/Layouts/AppLayout/index.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import Box from "@mui/material/Box"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import BackgroundSVG from "../../../../assets/Images/background.svg"; -import { useSelector } from "react-redux"; - -const AppLayout = ({ children }) => { - const theme = useTheme(); - const ui = useSelector((state) => state.ui); - return ( - - {children} - - ); -}; - -AppLayout.propTypes = { - children: PropTypes.node, -}; - -export default AppLayout; diff --git a/client/src/Components/v2/design-elements/OfflineBanner.tsx b/client/src/Components/v2/design-elements/OfflineBanner.tsx new file mode 100644 index 000000000..fa5690c12 --- /dev/null +++ b/client/src/Components/v2/design-elements/OfflineBanner.tsx @@ -0,0 +1,76 @@ +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { WifiOff } from "lucide-react"; + +interface OfflineBannerProps { + visible: boolean; + onRetry?: () => void; + isRetrying?: boolean; +} + +export const OfflineBanner = ({ visible, onRetry, isRetrying }: OfflineBannerProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + if (!visible) { + return null; + } + + return ( + + + + + {t("components.offlineBanner.serverUnreachable")} + + {onRetry && ( + + )} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index b7b1d484e..9a4b97a44 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -19,3 +19,4 @@ export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; +export * from "./OfflineBanner"; diff --git a/client/src/Components/v2/layout/AppLayout.tsx b/client/src/Components/v2/layout/AppLayout.tsx new file mode 100644 index 000000000..1310863f9 --- /dev/null +++ b/client/src/Components/v2/layout/AppLayout.tsx @@ -0,0 +1,39 @@ +import Box from "@mui/material/Box"; +import { ThemeProvider, useTheme } from "@mui/material/styles"; +import { useSelector } from "react-redux"; +import BackgroundSVG from "@/assets/Images/background.svg"; +import type { RootState } from "@/Types/state"; +import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; +import { OfflineBanner } from "@/Components/v2/design-elements"; + +interface AppLayoutProps { + children: React.ReactNode; +} + +const AppLayout = ({ children }: AppLayoutProps) => { + const theme = useTheme(); + const mode = useSelector((state: RootState) => state.ui.mode); + const v2theme = mode === "dark" ? darkTheme : lightTheme; + + return ( + + + + {children} + + + ); +}; + +export default AppLayout; diff --git a/client/src/Components/v2/layout/RootLayout.tsx b/client/src/Components/v2/layout/RootLayout.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/Components/v2/sidebar/index.tsx b/client/src/Components/v2/sidebar/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts index c39839558..4b2f4971f 100644 --- a/client/src/Utils/ApiClient.ts +++ b/client/src/Utils/ApiClient.ts @@ -15,6 +15,15 @@ type StoreType = { let storeInstance: StoreType | null = null; let interceptorsInitialized = false; +type ServerUnreachableCallback = (unreachable: boolean) => void; +let serverUnreachableCallback: ServerUnreachableCallback | null = null; + +export const setServerUnreachableCallback = ( + callback: ServerUnreachableCallback +): void => { + serverUnreachableCallback = callback; +}; + export const initApiClient = (store: StoreType): void => { storeInstance = store; @@ -41,6 +50,12 @@ export const initApiClient = (store: StoreType): void => { const onSuccess = (response: AxiosResponse) => response; const onError = (error: AxiosError) => { + // Handle network errors (server unreachable) + if (error.code === "ERR_NETWORK") { + serverUnreachableCallback?.(true); + return Promise.reject(error); + } + if (error.response?.status === 401) { if (window.location.pathname !== "/login") { window.location.href = "/login"; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbd9ea7b7..096fabd9d 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -325,6 +325,12 @@ "week": "Showing statistics for past 7 days.", "month": "Showing statistics for past 30 days." } + }, + "offlineBanner": { + "serverUnreachable": "Unable to reach server", + "retry": "Retry", + "retrying": "Retrying...", + "reconnected": "Connection restored" } }, "configure": "Configure", From d1d96bd2baddfb0eb0cde646e2ec77b765091b43 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 19:21:57 +0000 Subject: [PATCH 255/392] sidebar --- .../v1/Layouts/HomeLayout/index.jsx | 1 - client/src/Components/v2/layout/AppLayout.tsx | 32 ++--- .../src/Components/v2/layout/RootLayout.tsx | 46 ++++++++ client/src/Components/v2/sidebar/Logo.tsx | 60 ++++++++++ client/src/Components/v2/sidebar/Menu.tsx | 41 +++++++ client/src/Components/v2/sidebar/NavItem.tsx | 110 ++++++++++++++++++ client/src/Components/v2/sidebar/index.tsx | 69 +++++++++++ client/src/Hooks/useSidebar.js | 29 ----- client/src/Hooks/useSidebar.ts | 25 ++++ client/src/Routes/index.jsx | 4 +- 10 files changed, 369 insertions(+), 48 deletions(-) create mode 100644 client/src/Components/v2/sidebar/Logo.tsx create mode 100644 client/src/Components/v2/sidebar/Menu.tsx create mode 100644 client/src/Components/v2/sidebar/NavItem.tsx delete mode 100644 client/src/Hooks/useSidebar.js create mode 100644 client/src/Hooks/useSidebar.ts diff --git a/client/src/Components/v1/Layouts/HomeLayout/index.jsx b/client/src/Components/v1/Layouts/HomeLayout/index.jsx index b078fe048..adadf638f 100644 --- a/client/src/Components/v1/Layouts/HomeLayout/index.jsx +++ b/client/src/Components/v1/Layouts/HomeLayout/index.jsx @@ -14,7 +14,6 @@ const HomeLayout = () => { flexDirection="row" > - {/* Spacer for fixed sidebar */} { const v2theme = mode === "dark" ? darkTheme : lightTheme; return ( - - + + - {children} - - + + {children} + ); }; diff --git a/client/src/Components/v2/layout/RootLayout.tsx b/client/src/Components/v2/layout/RootLayout.tsx index e69de29bb..b0c0effe1 100644 --- a/client/src/Components/v2/layout/RootLayout.tsx +++ b/client/src/Components/v2/layout/RootLayout.tsx @@ -0,0 +1,46 @@ +import { Sidebar } from "@/Components/v2/sidebar"; +import { Outlet } from "react-router"; +import Stack from "@mui/material/Stack"; + +// TEMP +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; +import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; +import { ThemeProvider, useTheme } from "@mui/material"; + +const RootLayout = () => { + const mode = useSelector((state: RootState) => state.ui.mode); + const v2theme = mode === "dark" ? darkTheme : lightTheme; + const theme = useTheme(); + return ( + + + + + + + + + + + ); +}; + +export default RootLayout; diff --git a/client/src/Components/v2/sidebar/Logo.tsx b/client/src/Components/v2/sidebar/Logo.tsx new file mode 100644 index 000000000..131ac03bc --- /dev/null +++ b/client/src/Components/v2/sidebar/Logo.tsx @@ -0,0 +1,60 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { toggleSidebar } from "@/Features/UI/uiSlice.js"; +import { useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import useSidebar from "@/Hooks/useSidebar"; + +export const Logo = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useDispatch(); + const { collapsed } = useSidebar(); + return ( + { + dispatch(toggleSidebar()); + }} + sx={{ cursor: "pointer" }} + > + + C + + + {" "} + + {t("common.appName")} + + + + ); +}; diff --git a/client/src/Components/v2/sidebar/Menu.tsx b/client/src/Components/v2/sidebar/Menu.tsx new file mode 100644 index 000000000..ad58f4455 --- /dev/null +++ b/client/src/Components/v2/sidebar/Menu.tsx @@ -0,0 +1,41 @@ +import { Icon } from "@/Components/v2/design-elements"; + +import { + Globe, + Gauge, + Link, + Bell, + FileText, + AlertTriangle, + Wifi, + Wrench, + Database, + Settings, +} from "lucide-react"; + +export const getMenu = (t: Function) => { + return [ + { name: t("menu.uptime"), path: "uptime", icon: }, + { name: t("menu.pagespeed"), path: "pagespeed", icon: }, + { + name: t("menu.infrastructure"), + path: "infrastructure", + icon: , + }, + { + name: t("menu.notifications"), + path: "notifications", + icon: , + }, + { name: t("menu.checks"), path: "checks", icon: }, + { name: t("menu.incidents"), path: "incidents", icon: }, + { name: t("menu.statusPages"), path: "status", icon: }, + { name: t("menu.maintenance"), path: "maintenance", icon: }, + { name: t("menu.logs"), path: "logs", icon: }, + { + name: t("menu.settings"), + icon: , + path: "settings", + }, + ]; +}; diff --git a/client/src/Components/v2/sidebar/NavItem.tsx b/client/src/Components/v2/sidebar/NavItem.tsx new file mode 100644 index 000000000..756b10d60 --- /dev/null +++ b/client/src/Components/v2/sidebar/NavItem.tsx @@ -0,0 +1,110 @@ +import Tooltip from "@mui/material/Tooltip"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import useSidebar from "@/Hooks/useSidebar"; + +export interface NavData { + name: string; + icon: React.ReactElement; +} + +export const NavItem = ({ + item, + selected, + onClick, +}: { + item: NavData; + selected: boolean; + onClick: (event: React.MouseEvent) => void; +}) => { + const { collapsed } = useSidebar(); + const theme = useTheme(); + const iconStroke = selected ? theme.palette.primary.main : theme.palette.text.secondary; + + const buttonBgColor = selected ? theme.palette.action.selected : "transparent"; + const buttonBgHoverColor = selected + ? theme.palette.action.selected + : theme.palette.action.hover; + const fontWeight = selected ? 600 : 400; + return ( + + + + {item.icon} + + + + {item.name} + + + + + ); +}; diff --git a/client/src/Components/v2/sidebar/index.tsx b/client/src/Components/v2/sidebar/index.tsx index e69de29bb..10725c6a0 100644 --- a/client/src/Components/v2/sidebar/index.tsx +++ b/client/src/Components/v2/sidebar/index.tsx @@ -0,0 +1,69 @@ +import Stack from "@mui/material/Stack"; +import List from "@mui/material/List"; +import { useSidebar } from "@/Hooks/useSidebar.js"; +import { Logo } from "@/Components/v2/sidebar/Logo"; +import { getMenu } from "@/Components/v2/sidebar/Menu"; +import { NavItem } from "@/Components/v2/sidebar/NavItem"; + +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme, useMediaQuery } from "@mui/material"; +import { useDispatch } from "react-redux"; +import { toggleSidebar } from "@/Features/UI/uiSlice"; + +export const Sidebar = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { width, transition } = useSidebar(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const menu = getMenu(t); + + return ( + t.zIndex.drawer : "auto", + }} + > + + + {menu.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + { + navigate(`/${item.path}`); + if (isSmall) { + dispatch(toggleSidebar()); + } + }} + /> + ); + })} + + + ); +}; diff --git a/client/src/Hooks/useSidebar.js b/client/src/Hooks/useSidebar.js deleted file mode 100644 index 97724b417..000000000 --- a/client/src/Hooks/useSidebar.js +++ /dev/null @@ -1,29 +0,0 @@ -import { useSelector } from "react-redux"; - -// CSS variable names for sidebar widths -const SIDEBAR_WIDTH_VAR = "var(--env-var-side-bar-width)"; -const SIDEBAR_COLLAPSED_WIDTH_VAR = "var(--env-var-side-bar-collapsed-width)"; - -// Transition timing for sidebar width changes -const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; - -/** - * Hook to get sidebar state and computed width - * Centralizes sidebar width logic to avoid duplication between Sidebar and HomeLayout - * - * @returns {Object} Sidebar state and styles - * @returns {boolean} collapsed - Whether the sidebar is collapsed - * @returns {string} width - CSS width value based on collapsed state - * @returns {string} transition - CSS transition for width changes - */ -export const useSidebar = () => { - const collapsed = useSelector((state) => state.ui.sidebar?.collapsed ?? false); - - return { - collapsed, - width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR, - transition: SIDEBAR_TRANSITION, - }; -}; - -export default useSidebar; diff --git a/client/src/Hooks/useSidebar.ts b/client/src/Hooks/useSidebar.ts new file mode 100644 index 000000000..1cf705dd1 --- /dev/null +++ b/client/src/Hooks/useSidebar.ts @@ -0,0 +1,25 @@ +import type { RootState } from "@/Types/state"; +import { useSelector } from "react-redux"; + +// CSS variable names for sidebar widths +const SIDEBAR_WIDTH_VAR = 250; +const SIDEBAR_COLLAPSED_WIDTH_VAR = 64; + +// Transition timing for sidebar width changes +const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; + +export const useSidebar = () => { + const collapsed = useSelector( + (state: RootState) => state.ui.sidebar?.collapsed ?? false + ); + + return { + collapsed, + collapsedWidth: SIDEBAR_COLLAPSED_WIDTH_VAR, + expandedWidth: SIDEBAR_WIDTH_VAR, + width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR, + transition: SIDEBAR_TRANSITION, + }; +}; + +export default useSidebar; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 07dc1793c..0a66b0cf3 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -4,7 +4,7 @@ import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; import { useSelector } from "react-redux"; import { Navigate, Route, Routes as LibRoutes } from "react-router"; -import HomeLayout from "@/Components/v1/Layouts/HomeLayout"; +import RootLayout from "@/Components/v2/layout/RootLayout"; import NotFound from "@/Pages/NotFound"; // Auth @@ -67,7 +67,7 @@ const Routes = () => { path="/" element={ - + } > From 1db5f4b70f946e0f69ad0a631667cb5d0be46aa1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 21:54:31 +0000 Subject: [PATCH 256/392] sidebar --- .../src/Components/v2/layout/RootLayout.tsx | 7 ++++++- client/src/Components/v2/sidebar/Logo.tsx | 8 ++++---- client/src/Components/v2/sidebar/index.tsx | 19 ++++++++++++++----- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/client/src/Components/v2/layout/RootLayout.tsx b/client/src/Components/v2/layout/RootLayout.tsx index b0c0effe1..0b6defb91 100644 --- a/client/src/Components/v2/layout/RootLayout.tsx +++ b/client/src/Components/v2/layout/RootLayout.tsx @@ -1,8 +1,9 @@ import { Sidebar } from "@/Components/v2/sidebar"; import { Outlet } from "react-router"; import Stack from "@mui/material/Stack"; +import { useMediaQuery } from "@mui/material"; +import { useSidebar } from "@/Hooks/useSidebar"; -// TEMP import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; @@ -12,6 +13,9 @@ const RootLayout = () => { const mode = useSelector((state: RootState) => state.ui.mode); const v2theme = mode === "dark" ? darkTheme : lightTheme; const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const { collapsedWidth } = useSidebar(); + return ( @@ -28,6 +32,7 @@ const RootLayout = () => { : "rgba(0, 0, 0, 0.01)", display: "flex", alignItems: "center", + paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12, }} > { +export const Logo = (props: StackProps) => { const { t } = useTranslation(); const theme = useTheme(); const dispatch = useDispatch(); const { collapsed } = useSidebar(); return ( { dispatch(toggleSidebar()); }} sx={{ cursor: "pointer" }} + {...props} > { const dispatch = useDispatch(); const navigate = useNavigate(); + const location = useLocation(); const { t } = useTranslation(); const { width, transition } = useSidebar(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const menu = getMenu(t); + useEffect(() => { + dispatch(setCollapsed({ collapsed: isSmall })); + }, [isSmall, dispatch]); + return ( { height: "100%", }} > - + {menu.map((item) => { const selected = location.pathname.startsWith(`/${item.path}`); return ( @@ -57,7 +66,7 @@ export const Sidebar = () => { onClick={() => { navigate(`/${item.path}`); if (isSmall) { - dispatch(toggleSidebar()); + dispatch(setCollapsed({ collapsed: true })); } }} /> From 0f6c273dad4cae9d594edbf8d777bbdbdcde02d0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 22:17:13 +0000 Subject: [PATCH 257/392] bottom menu --- client/src/Components/v2/sidebar/Menu.tsx | 19 ++++++++ client/src/Components/v2/sidebar/index.tsx | 50 ++++++++++++++++++---- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/client/src/Components/v2/sidebar/Menu.tsx b/client/src/Components/v2/sidebar/Menu.tsx index ad58f4455..8292ea490 100644 --- a/client/src/Components/v2/sidebar/Menu.tsx +++ b/client/src/Components/v2/sidebar/Menu.tsx @@ -11,6 +11,12 @@ import { Wrench, Database, Settings, + HelpCircle, + MessageCircle, + Code, + User, + Lock, + Users, } from "lucide-react"; export const getMenu = (t: Function) => { @@ -39,3 +45,16 @@ export const getMenu = (t: Function) => { }, ]; }; + +export const getBottomMenu = (t: Function) => { + return [ + { name: t("menu.support"), path: "support", icon: }, + { + name: t("menu.discussions"), + path: "discussions", + icon: , + }, + { name: t("menu.docs"), path: "docs", icon: }, + { name: t("menu.changelog"), path: "changelog", icon: }, + ]; +}; diff --git a/client/src/Components/v2/sidebar/index.tsx b/client/src/Components/v2/sidebar/index.tsx index 568703791..e278092eb 100644 --- a/client/src/Components/v2/sidebar/index.tsx +++ b/client/src/Components/v2/sidebar/index.tsx @@ -1,9 +1,10 @@ import { useEffect } from "react"; import Stack from "@mui/material/Stack"; import List from "@mui/material/List"; +import Divider from "@mui/material/Divider"; import { useSidebar } from "@/Hooks/useSidebar.js"; import { Logo } from "@/Components/v2/sidebar/Logo"; -import { getMenu } from "@/Components/v2/sidebar/Menu"; +import { getMenu, getBottomMenu } from "@/Components/v2/sidebar/Menu"; import { NavItem } from "@/Components/v2/sidebar/NavItem"; import { useNavigate, useLocation } from "react-router"; @@ -12,6 +13,13 @@ import { useTheme, useMediaQuery } from "@mui/material"; import { useDispatch } from "react-redux"; import { setCollapsed } from "@/Features/UI/uiSlice"; +const URL_MAP: Record = { + support: "https://discord.com/invite/NAb6H3UTjK", + discussions: "https://github.com/bluewave-labs/checkmate/discussions", + docs: "https://bluewavelabs.gitbook.io/checkmate", + changelog: "https://github.com/bluewave-labs/checkmate/releases", +}; + export const Sidebar = () => { const dispatch = useDispatch(); const navigate = useNavigate(); @@ -21,11 +29,24 @@ export const Sidebar = () => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const menu = getMenu(t); + const bottomMenu = getBottomMenu(t); useEffect(() => { dispatch(setCollapsed({ collapsed: isSmall })); }, [isSmall, dispatch]); + const handleNavClick = (path: string) => { + const url = URL_MAP[path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${path}`); + } + if (isSmall) { + dispatch(setCollapsed({ collapsed: true })); + } + }; + return ( { disablePadding sx={{ px: theme.spacing(6), - height: "100%", + flex: 1, }} > { key={item.path} item={item} selected={selected} - onClick={() => { - navigate(`/${item.path}`); - if (isSmall) { - dispatch(setCollapsed({ collapsed: true })); - } - }} + onClick={() => handleNavClick(item.path)} /> ); })} + + {bottomMenu.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + + ); }; From a289a197a11b8a760ec43da8dd6eb3db70f9fbd6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 22:24:35 +0000 Subject: [PATCH 258/392] starprompt --- .../src/Components/v2/sidebar/StarPrompt.tsx | 115 ++++++++++++++++++ client/src/Components/v2/sidebar/index.tsx | 2 + 2 files changed, 117 insertions(+) create mode 100644 client/src/Components/v2/sidebar/StarPrompt.tsx diff --git a/client/src/Components/v2/sidebar/StarPrompt.tsx b/client/src/Components/v2/sidebar/StarPrompt.tsx new file mode 100644 index 000000000..cacc442cc --- /dev/null +++ b/client/src/Components/v2/sidebar/StarPrompt.tsx @@ -0,0 +1,115 @@ +import { Typography, IconButton, Stack, Box } from "@mui/material"; +import { Icon } from "@/Components/v2/design-elements"; +import { X } from "lucide-react"; + +import { useTheme } from "@mui/material"; +import { useSelector, useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { setStarPromptOpen } from "@/Features/UI/uiSlice.js"; +import type { RootState } from "@/Types/state.js"; +import useSidebar from "@/Hooks/useSidebar.js"; + +export const StarPrompt = ({ + repoUrl = "https://github.com/bluewave-labs/checkmate", +}: { + repoUrl?: string; +}) => { + const theme = useTheme(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + const isOpen = useSelector((state: RootState) => state.ui?.starPromptOpen ?? true); + const mode = useSelector((state: RootState) => state.ui.mode); + const { collapsed } = useSidebar(); + const handleClose = () => { + dispatch(setStarPromptOpen(false)); + }; + + const handleStarClick = () => { + window.open(repoUrl, "_blank"); + }; + + if (collapsed) return null; + if (!isOpen) return null; + + return ( + + + + {t("starPromptTitle")} + + + + + + + + {t("starPromptDescription")} + + + + + ); +}; diff --git a/client/src/Components/v2/sidebar/index.tsx b/client/src/Components/v2/sidebar/index.tsx index e278092eb..e3056c9f4 100644 --- a/client/src/Components/v2/sidebar/index.tsx +++ b/client/src/Components/v2/sidebar/index.tsx @@ -6,6 +6,7 @@ import { useSidebar } from "@/Hooks/useSidebar.js"; import { Logo } from "@/Components/v2/sidebar/Logo"; import { getMenu, getBottomMenu } from "@/Components/v2/sidebar/Menu"; import { NavItem } from "@/Components/v2/sidebar/NavItem"; +import { StarPrompt } from "@/Components/v2/sidebar/StarPrompt"; import { useNavigate, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; @@ -89,6 +90,7 @@ export const Sidebar = () => { ); })} + Date: Tue, 10 Feb 2026 22:28:47 +0000 Subject: [PATCH 259/392] styling --- client/src/Components/v2/sidebar/StarPrompt.tsx | 12 +----------- client/src/locales/en.json | 7 ++++++- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/client/src/Components/v2/sidebar/StarPrompt.tsx b/client/src/Components/v2/sidebar/StarPrompt.tsx index cacc442cc..665f0a440 100644 --- a/client/src/Components/v2/sidebar/StarPrompt.tsx +++ b/client/src/Components/v2/sidebar/StarPrompt.tsx @@ -52,13 +52,7 @@ export const StarPrompt = ({ > {t("starPromptTitle")} @@ -68,7 +62,6 @@ export const StarPrompt = ({ sx={{ color: theme.palette.text.primary, padding: 0, - marginTop: theme.spacing(-5), "&:hover": { backgroundColor: "transparent", opacity: 0.8, @@ -83,10 +76,7 @@ export const StarPrompt = ({ Date: Tue, 10 Feb 2026 22:47:39 +0000 Subject: [PATCH 260/392] auth footer --- .../Components/v2/design-elements/Avatar.tsx | 48 +++ .../Components/v2/design-elements/index.tsx | 1 + .../src/Components/v2/sidebar/Authfooter.tsx | 279 ++++++++++++++++++ client/src/Components/v2/sidebar/Menu.tsx | 88 +++++- .../src/Components/v2/sidebar/StarPrompt.tsx | 4 +- client/src/Components/v2/sidebar/index.tsx | 20 +- client/src/locales/en.json | 34 ++- 7 files changed, 454 insertions(+), 20 deletions(-) create mode 100644 client/src/Components/v2/design-elements/Avatar.tsx create mode 100644 client/src/Components/v2/sidebar/Authfooter.tsx diff --git a/client/src/Components/v2/design-elements/Avatar.tsx b/client/src/Components/v2/design-elements/Avatar.tsx new file mode 100644 index 000000000..1adec4741 --- /dev/null +++ b/client/src/Components/v2/design-elements/Avatar.tsx @@ -0,0 +1,48 @@ +import { Avatar as MuiAvatar } from "@mui/material"; +import { useSelector } from "react-redux"; +import { useEffect, useState } from "react"; +import { useTheme } from "@mui/material"; +import type { RootState } from "@/Types/state"; + +interface AvatarProps { + src?: string; + small?: boolean; + sx?: object; + onClick?: Function; +} + +export const Avatar = ({ src, small, sx, onClick = () => {} }: AvatarProps) => { + const { user } = useSelector((state: RootState) => state.auth); + const theme = useTheme(); + if (!user) return null; + + const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 }; + const border = small ? 1 : 3; + + const [image, setImage] = useState(); + useEffect(() => { + if (user.avatarImage) { + setImage(`data:image/png;base64,${user.avatarImage}`); + } + }, [user?.avatarImage]); + + return ( + + {user.firstName?.charAt(0)} + {user.lastName?.charAt(0) || ""} + + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index 9a4b97a44..ed6a885f6 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -20,3 +20,4 @@ export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; export * from "./OfflineBanner"; +export * from "./Avatar"; diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx new file mode 100644 index 000000000..cc84dc0e2 --- /dev/null +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -0,0 +1,279 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Tooltip from "@mui/material/Tooltip"; +import IconButton from "@mui/material/IconButton"; +import { Avatar, Icon } from "@/Components/v2/design-elements"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import Divider from "@mui/material/Divider"; +import { MoreVertical, LogOut } from "lucide-react"; + +import { useTheme } from "@mui/material"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { clearAuthState } from "@/Features/Auth/authSlice.js"; +import { useDispatch } from "react-redux"; +import PropTypes from "prop-types"; +import type { RootState } from "@/Types/state.js"; + +const getFilteredAccountMenuItems = (user: any, items: any[]) => { + if (!user) return []; + + let filtered = [...items]; + + if (user.role?.includes("demo")) { + filtered = filtered.filter((item) => item.name !== "Password"); + } + + if (!user.role?.includes("superadmin")) { + filtered = filtered.filter((item) => item.name !== "Team"); + } + + return filtered; +}; + +const getRoleDisplayText = (user: any, t: Function) => { + if (!user?.role) return ""; + + if (user.role.includes("superadmin")) return t("roles.superAdmin"); + if (user.role.includes("admin")) return t("roles.admin"); + if (user.role.includes("user")) return t("roles.teamMember"); + if (user.role.includes("demo")) return t("roles.demoUser"); + + return user.role; +}; + +const AuthFooter = ({ + collapsed, + accountMenuItems, +}: { + collapsed: boolean; + accountMenuItems: any[]; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const authState = useSelector((state: RootState) => state.auth); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [anchorEl, setAnchorEl] = useState(null); + + const openPopup = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const closePopup = () => { + setAnchorEl(null); + }; + + const logout = async () => { + dispatch(clearAuthState()); + navigate("/login"); + }; + const renderAccountMenuItems = (user: any, items: any[]) => { + const filteredItems = getFilteredAccountMenuItems(user, items); + + return filteredItems.map((item) => ( + { + closePopup(); + navigate(item.path); + }} + sx={{ + gap: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + pl: theme.spacing(4), + }} + > + {item.icon} + {item.name} + + )); + }; + return ( + + { + openPopup(e); + }} + sx={{ + cursor: collapsed ? "pointer" : "default", + }} + /> + + + + + {authState.user?.firstName} {authState.user?.lastName} + + + {getRoleDisplayText(authState.user, t)} + + + + openPopup(event)} + > + + + + + + {collapsed && ( + + + + {authState.user?.firstName} {authState.user?.lastName} + + + {authState.user?.role} + + + + )} + {/* TODO Do we need two dividers? */} + {collapsed && } + {/* */} + {renderAccountMenuItems(authState.user, accountMenuItems)} + + + {t("menu.logOut", "Log out")} + + + + ); +}; + +AuthFooter.propTypes = { + collapsed: PropTypes.bool, + accountMenuItems: PropTypes.array, +}; + +export default AuthFooter; diff --git a/client/src/Components/v2/sidebar/Menu.tsx b/client/src/Components/v2/sidebar/Menu.tsx index 8292ea490..918a82858 100644 --- a/client/src/Components/v2/sidebar/Menu.tsx +++ b/client/src/Components/v2/sidebar/Menu.tsx @@ -21,25 +21,53 @@ import { export const getMenu = (t: Function) => { return [ - { name: t("menu.uptime"), path: "uptime", icon: }, - { name: t("menu.pagespeed"), path: "pagespeed", icon: }, { - name: t("menu.infrastructure"), + name: t("components.sidebar.menu.uptime"), + path: "uptime", + icon: , + }, + { + name: t("components.sidebar.menu.pagespeed"), + path: "pagespeed", + icon: , + }, + { + name: t("components.sidebar.menu.infrastructure"), path: "infrastructure", icon: , }, { - name: t("menu.notifications"), + name: t("components.sidebar.menu.notifications"), path: "notifications", icon: , }, - { name: t("menu.checks"), path: "checks", icon: }, - { name: t("menu.incidents"), path: "incidents", icon: }, - { name: t("menu.statusPages"), path: "status", icon: }, - { name: t("menu.maintenance"), path: "maintenance", icon: }, - { name: t("menu.logs"), path: "logs", icon: }, { - name: t("menu.settings"), + name: t("components.sidebar.menu.checks"), + path: "checks", + icon: , + }, + { + name: t("components.sidebar.menu.incidents"), + path: "incidents", + icon: , + }, + { + name: t("components.sidebar.menu.statusPages"), + path: "status", + icon: , + }, + { + name: t("components.sidebar.menu.maintenance"), + path: "maintenance", + icon: , + }, + { + name: t("components.sidebar.menu.logs"), + path: "logs", + icon: , + }, + { + name: t("components.sidebar.menu.settings"), icon: , path: "settings", }, @@ -48,13 +76,45 @@ export const getMenu = (t: Function) => { export const getBottomMenu = (t: Function) => { return [ - { name: t("menu.support"), path: "support", icon: }, { - name: t("menu.discussions"), + name: t("components.sidebar.bottomMenu.support"), + path: "support", + icon: , + }, + { + name: t("components.sidebar.bottomMenu.discussions"), path: "discussions", icon: , }, - { name: t("menu.docs"), path: "docs", icon: }, - { name: t("menu.changelog"), path: "changelog", icon: }, + { + name: t("components.sidebar.bottomMenu.docs"), + path: "docs", + icon: , + }, + { + name: t("components.sidebar.bottomMenu.changelog"), + path: "changelog", + icon: , + }, + ]; +}; + +export const getAccountMenu = (t: Function) => { + return [ + { + name: t("components.sidebar.accountMenu.profile"), + path: "account/profile", + icon: , + }, + { + name: t("components.sidebar.accountMenu.password"), + path: "account/password", + icon: , + }, + { + name: t("components.sidebar.accountMenu.team"), + path: "account/team", + icon: , + }, ]; }; diff --git a/client/src/Components/v2/sidebar/StarPrompt.tsx b/client/src/Components/v2/sidebar/StarPrompt.tsx index 665f0a440..5319f6bcc 100644 --- a/client/src/Components/v2/sidebar/StarPrompt.tsx +++ b/client/src/Components/v2/sidebar/StarPrompt.tsx @@ -54,7 +54,7 @@ export const StarPrompt = ({ variant="subtitle2" mt={theme.spacing(3)} > - {t("starPromptTitle")} + {t("components.sidebar.starPrompt.title")} - {t("starPromptDescription")} + {t("components.sidebar.starPrompt.description")} { const isSmall = useMediaQuery(theme.breakpoints.down("md")); const menu = getMenu(t); const bottomMenu = getBottomMenu(t); + const accountMenu = getAccountMenu(t); useEffect(() => { dispatch(setCollapsed({ collapsed: isSmall })); @@ -109,6 +110,23 @@ export const Sidebar = () => { })} + + {accountMenu.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a9090e5bc..dd747b5e4 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -333,10 +333,38 @@ "reconnected": "Connection restored" }, "sidebar": { - "menu": {}, - "bottomMenu": {} + "menu": { + "uptime": "Uptime", + "pagespeed": "Pagespeed", + "infrastructure": "Infrastructure", + "notifications": "Notifications", + "checks": "Checks", + "incidents": "Incidents", + "statusPages": "Status pages", + "maintenance": "Maintenance", + "logs": "Logs", + "settings": "Settings" + }, + "bottomMenu": { + "support": "Support", + "discussions": "Discussions", + "docs": "Docs", + "changelog": "Changelog" + }, + "accountMenu": { + "profile": "Profile", + "password": "Password", + "team": "Team" + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "See the latest releases and help grow the community on GitHub" + } }, - "starPrompt": {} + "starPrompt": { + "title": "Star Checkmate", + "description": "See the latest releases and help grow the community on GitHub" + } }, "configure": "Configure", "confirmPassword": "Confirm password", From a390ee5f21343d308fbfb48ebbecde5ae8a67d32 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 22:54:23 +0000 Subject: [PATCH 261/392] auth footer --- .../Components/v2/design-elements/Avatar.tsx | 3 +-- .../src/Components/v2/sidebar/Authfooter.tsx | 11 -------- client/src/Components/v2/sidebar/index.tsx | 25 ++++++------------- 3 files changed, 8 insertions(+), 31 deletions(-) diff --git a/client/src/Components/v2/design-elements/Avatar.tsx b/client/src/Components/v2/design-elements/Avatar.tsx index 1adec4741..74c06f8f3 100644 --- a/client/src/Components/v2/design-elements/Avatar.tsx +++ b/client/src/Components/v2/design-elements/Avatar.tsx @@ -8,7 +8,7 @@ interface AvatarProps { src?: string; small?: boolean; sx?: object; - onClick?: Function; + onClick?: (event: React.MouseEvent) => void; } export const Avatar = ({ src, small, sx, onClick = () => {} }: AvatarProps) => { @@ -17,7 +17,6 @@ export const Avatar = ({ src, small, sx, onClick = () => {} }: AvatarProps) => { if (!user) return null; const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 }; - const border = small ? 1 : 3; const [image, setImage] = useState(); useEffect(() => { diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx index cc84dc0e2..3194cf44a 100644 --- a/client/src/Components/v2/sidebar/Authfooter.tsx +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -6,7 +6,6 @@ import IconButton from "@mui/material/IconButton"; import { Avatar, Icon } from "@/Components/v2/design-elements"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; -import Divider from "@mui/material/Divider"; import { MoreVertical, LogOut } from "lucide-react"; import { useTheme } from "@mui/material"; @@ -97,7 +96,6 @@ const AuthFooter = ({ return ( @@ -240,7 +233,6 @@ const AuthFooter = ({ fontSize: 12, whiteSpace: "nowrap", overflow: "visible", - // wordBreak: "break-word", }} > {authState.user?.role} @@ -248,9 +240,6 @@ const AuthFooter = ({ )} - {/* TODO Do we need two dividers? */} - {collapsed && } - {/* */} {renderAccountMenuItems(authState.user, accountMenuItems)} { const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation(); - const { width, transition } = useSidebar(); + const { width, transition, collapsed } = useSidebar(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const menu = getMenu(t); @@ -110,23 +111,11 @@ export const Sidebar = () => { })} - - {accountMenu.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - return ( - handleNavClick(item.path)} - /> - ); - })} - + + ); }; From 4c5c2d2d5e74ad275270a77d4a081cf412523a09 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 23:12:33 +0000 Subject: [PATCH 262/392] auth footer --- .../Components/v2/design-elements/Icon.tsx | 1 + .../src/Components/v2/sidebar/Authfooter.tsx | 27 +++++++++++-------- client/src/Components/v2/sidebar/index.tsx | 2 +- client/src/locales/en.json | 3 ++- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/client/src/Components/v2/design-elements/Icon.tsx b/client/src/Components/v2/design-elements/Icon.tsx index b32cd84b2..22f939750 100644 --- a/client/src/Components/v2/design-elements/Icon.tsx +++ b/client/src/Components/v2/design-elements/Icon.tsx @@ -4,6 +4,7 @@ interface IconProps { icon: LucideIcon; size?: number; strokeWidth?: number; + stroke?: string; } const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => { diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx index 3194cf44a..d63663a01 100644 --- a/client/src/Components/v2/sidebar/Authfooter.tsx +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -15,7 +15,6 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { clearAuthState } from "@/Features/Auth/authSlice.js"; import { useDispatch } from "react-redux"; -import PropTypes from "prop-types"; import type { RootState } from "@/Types/state.js"; const getFilteredAccountMenuItems = (user: any, items: any[]) => { @@ -45,7 +44,7 @@ const getRoleDisplayText = (user: any, t: Function) => { return user.role; }; -const AuthFooter = ({ +export const AuthFooter = ({ collapsed, accountMenuItems, }: { @@ -86,6 +85,9 @@ const AuthFooter = ({ gap: theme.spacing(2), borderRadius: theme.shape.borderRadius, pl: theme.spacing(4), + "& svg": { + stroke: theme.palette.text.secondary, + }, }} > {item.icon} @@ -140,6 +142,7 @@ const AuthFooter = ({ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", + color: theme.palette.text.primary, }} > {authState.user?.firstName} {authState.user?.lastName} @@ -148,7 +151,10 @@ const AuthFooter = ({ textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap" - sx={{ textTransform: "capitalize", opacity: 0.8 }} + sx={{ + textTransform: "capitalize", + color: theme.palette.text.secondary, + }} > {getRoleDisplayText(authState.user, t)} @@ -162,6 +168,9 @@ const AuthFooter = ({ ml: "50px", "&:focus": { outline: "none" }, alignSelf: "center", + "& svg": { + stroke: theme.palette.text.secondary, + }, }} onClick={(event) => openPopup(event)} > @@ -244,9 +253,12 @@ const AuthFooter = ({ ); }; - -AuthFooter.propTypes = { - collapsed: PropTypes.bool, - accountMenuItems: PropTypes.array, -}; - -export default AuthFooter; diff --git a/client/src/Components/v2/sidebar/index.tsx b/client/src/Components/v2/sidebar/index.tsx index deb2730e2..9bd36a853 100644 --- a/client/src/Components/v2/sidebar/index.tsx +++ b/client/src/Components/v2/sidebar/index.tsx @@ -7,7 +7,7 @@ import { Logo } from "@/Components/v2/sidebar/Logo"; import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/v2/sidebar/Menu"; import { NavItem } from "@/Components/v2/sidebar/NavItem"; import { StarPrompt } from "@/Components/v2/sidebar/StarPrompt"; -import AuthFooter from "@/Components/v2/sidebar/Authfooter"; +import { AuthFooter } from "@/Components/v2/sidebar/Authfooter"; import { useNavigate, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index dd747b5e4..314ba7e50 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -359,7 +359,8 @@ "starPrompt": { "title": "Star Checkmate", "description": "See the latest releases and help grow the community on GitHub" - } + }, + "authFooter": {} }, "starPrompt": { "title": "Star Checkmate", From 71f3c904722da8f82c326fd32300661d81fc2c89 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 23:16:12 +0000 Subject: [PATCH 263/392] strings --- client/src/Components/v2/sidebar/Authfooter.tsx | 15 +++++++++------ client/src/locales/en.json | 11 ++++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx index d63663a01..2bebb49b2 100644 --- a/client/src/Components/v2/sidebar/Authfooter.tsx +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -36,10 +36,13 @@ const getFilteredAccountMenuItems = (user: any, items: any[]) => { const getRoleDisplayText = (user: any, t: Function) => { if (!user?.role) return ""; - if (user.role.includes("superadmin")) return t("roles.superAdmin"); - if (user.role.includes("admin")) return t("roles.admin"); - if (user.role.includes("user")) return t("roles.teamMember"); - if (user.role.includes("demo")) return t("roles.demoUser"); + if (user.role.includes("superadmin")) + return t("components.sidebar.authFooter.roles.superAdmin"); + if (user.role.includes("admin")) return t("components.sidebar.authFooter.roles.admin"); + if (user.role.includes("user")) + return t("components.sidebar.authFooter.roles.teamMember"); + if (user.role.includes("demo")) + return t("components.sidebar.authFooter.roles.demoUser"); return user.role; }; @@ -160,7 +163,7 @@ export const AuthFooter = ({ - {t("menu.logOut", "Log out")} + {t("components.sidebar.authFooter.logOut")} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 314ba7e50..b49ebaa43 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -360,7 +360,16 @@ "title": "Star Checkmate", "description": "See the latest releases and help grow the community on GitHub" }, - "authFooter": {} + "authFooter": { + "navControls": "Controls", + "logOut": "Log out", + "roles": { + "superAdmin": "Super admin", + "admin": "Admin", + "teamMember": "Team member", + "demoUser": "Demo user" + } + } }, "starPrompt": { "title": "Star Checkmate", From 442fc2ce22d37f1c77561f16bedb5ed17912ce9e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 23:24:51 +0000 Subject: [PATCH 264/392] account tab fix --- .../src/Components/v2/sidebar/Authfooter.tsx | 315 ++++++------------ client/src/Pages/Account/index.tsx | 7 +- 2 files changed, 117 insertions(+), 205 deletions(-) diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx index 2bebb49b2..4b369cb67 100644 --- a/client/src/Components/v2/sidebar/Authfooter.tsx +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -1,103 +1,75 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import Tooltip from "@mui/material/Tooltip"; import IconButton from "@mui/material/IconButton"; -import { Avatar, Icon } from "@/Components/v2/design-elements"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; -import { MoreVertical, LogOut } from "lucide-react"; - import { useTheme } from "@mui/material"; -import { useSelector } from "react-redux"; +import { useSelector, useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; import { useState } from "react"; import { useNavigate } from "react-router"; +import { MoreVertical, LogOut } from "lucide-react"; +import { Avatar, Icon } from "@/Components/v2/design-elements"; import { clearAuthState } from "@/Features/Auth/authSlice.js"; -import { useDispatch } from "react-redux"; import type { RootState } from "@/Types/state.js"; -const getFilteredAccountMenuItems = (user: any, items: any[]) => { - if (!user) return []; - - let filtered = [...items]; - - if (user.role?.includes("demo")) { - filtered = filtered.filter((item) => item.name !== "Password"); - } - - if (!user.role?.includes("superadmin")) { - filtered = filtered.filter((item) => item.name !== "Team"); - } - - return filtered; -}; - -const getRoleDisplayText = (user: any, t: Function) => { - if (!user?.role) return ""; - - if (user.role.includes("superadmin")) - return t("components.sidebar.authFooter.roles.superAdmin"); - if (user.role.includes("admin")) return t("components.sidebar.authFooter.roles.admin"); - if (user.role.includes("user")) - return t("components.sidebar.authFooter.roles.teamMember"); - if (user.role.includes("demo")) - return t("components.sidebar.authFooter.roles.demoUser"); - - return user.role; -}; - -export const AuthFooter = ({ - collapsed, - accountMenuItems, -}: { +interface AuthFooterProps { collapsed: boolean; - accountMenuItems: any[]; -}) => { + accountMenuItems: Array<{ + name: string; + path: string; + icon: React.ReactNode; + }>; +} + +export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => { const { t } = useTranslation(); const theme = useTheme(); - const authState = useSelector((state: RootState) => state.auth); + const user = useSelector((state: RootState) => state.auth.user); const navigate = useNavigate(); const dispatch = useDispatch(); - const [anchorEl, setAnchorEl] = useState(null); - const openPopup = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); + const menuOpen = Boolean(anchorEl); + const handleMenuOpen = (e: React.MouseEvent) => + setAnchorEl(e.currentTarget); + const handleMenuClose = () => setAnchorEl(null); + + const handleNavigate = (path: string) => { + handleMenuClose(); + navigate(path); }; - const closePopup = () => { - setAnchorEl(null); - }; - - const logout = async () => { + const handleLogout = () => { dispatch(clearAuthState()); navigate("/login"); }; - const renderAccountMenuItems = (user: any, items: any[]) => { - const filteredItems = getFilteredAccountMenuItems(user, items); - return filteredItems.map((item) => ( - { - closePopup(); - navigate(item.path); - }} - sx={{ - gap: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - pl: theme.spacing(4), - "& svg": { - stroke: theme.palette.text.secondary, - }, - }} - > - {item.icon} - {item.name} - - )); + const getRoleText = () => { + const role = user?.role ?? ""; + if (role.includes("superadmin")) + return t("components.sidebar.authFooter.roles.superAdmin"); + if (role.includes("admin")) return t("components.sidebar.authFooter.roles.admin"); + if (role.includes("user")) return t("components.sidebar.authFooter.roles.teamMember"); + if (role.includes("demo")) return t("components.sidebar.authFooter.roles.demoUser"); + return role; }; + + const filteredMenuItems = accountMenuItems.filter((item) => { + if (!user) return false; + if (item.name === "Password" && user.role?.includes("demo")) return false; + if (item.name === "Team" && !user.role?.includes("superadmin")) return false; + return true; + }); + + const menuItemSx = { + gap: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + pl: theme.spacing(4), + "& svg": { stroke: theme.palette.text.secondary }, + }; + return ( { - openPopup(e); - }} - sx={{ - cursor: collapsed ? "pointer" : "default", - }} + small + onClick={collapsed ? handleMenuOpen : undefined} + sx={{ cursor: collapsed ? "pointer" : "default" }} /> - + {!collapsed && ( - - {authState.user?.firstName} {authState.user?.lastName} - - - {getRoleDisplayText(authState.user, t)} - - - + + {user?.firstName} {user?.lastName} + + + {getRoleText()} + + openPopup(event)} > - - +
+ )} + {collapsed && ( - - + - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role} - - - + {user?.firstName} {user?.lastName} + + + {getRoleText()} + + )} - {renderAccountMenuItems(authState.user, accountMenuItems)} + {filteredMenuItems.map((item) => ( + handleNavigate(item.path)} + sx={menuItemSx} + > + {item.icon} + {item.name} + + ))} { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(TAB_MAP[open]); + // Sync activeTab when open prop changes (e.g., navigating from sidebar) + useEffect(() => { + setActiveTab(TAB_MAP[open]); + }, [open]); + return ( Date: Tue, 10 Feb 2026 23:32:58 +0000 Subject: [PATCH 265/392] cleanup --- client/src/Components/v1/ArrowLeft/index.jsx | 36 --- client/src/Components/v1/Avatar/index.css | 0 client/src/Components/v1/Avatar/index.jsx | 73 ----- client/src/Components/v1/Label/index.css | 6 - client/src/Components/v1/Label/index.jsx | 172 ----------- .../v1/Layouts/HomeLayout/index.css | 29 -- .../v1/Layouts/HomeLayout/index.jsx | 31 -- .../v1/Sidebar/components/authFooter.jsx | 276 ------------------ .../v1/Sidebar/components/collapseButton.jsx | 55 ---- .../Components/v1/Sidebar/components/logo.jsx | 67 ----- .../v1/Sidebar/components/navItem.jsx | 96 ------ client/src/Components/v1/Sidebar/index.jsx | 152 ---------- client/src/Components/v1/StarPrompt/index.jsx | 110 ------- .../src/Components/v2/sidebar/Authfooter.tsx | 2 +- client/src/locales/en.json | 2 +- 15 files changed, 2 insertions(+), 1105 deletions(-) delete mode 100644 client/src/Components/v1/ArrowLeft/index.jsx delete mode 100644 client/src/Components/v1/Avatar/index.css delete mode 100644 client/src/Components/v1/Avatar/index.jsx delete mode 100644 client/src/Components/v1/Label/index.css delete mode 100644 client/src/Components/v1/Label/index.jsx delete mode 100644 client/src/Components/v1/Layouts/HomeLayout/index.css delete mode 100644 client/src/Components/v1/Layouts/HomeLayout/index.jsx delete mode 100644 client/src/Components/v1/Sidebar/components/authFooter.jsx delete mode 100644 client/src/Components/v1/Sidebar/components/collapseButton.jsx delete mode 100644 client/src/Components/v1/Sidebar/components/logo.jsx delete mode 100644 client/src/Components/v1/Sidebar/components/navItem.jsx delete mode 100644 client/src/Components/v1/Sidebar/index.jsx delete mode 100644 client/src/Components/v1/StarPrompt/index.jsx diff --git a/client/src/Components/v1/ArrowLeft/index.jsx b/client/src/Components/v1/ArrowLeft/index.jsx deleted file mode 100644 index e18a7f2d5..000000000 --- a/client/src/Components/v1/ArrowLeft/index.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import Icon from "../Icon"; -import PropTypes from "prop-types"; - -const ArrowLeft = ({ type, color = "#667085", ...props }) => { - if (type === "double") { - return ( - - ); - } else if (type === "long") { - return ( - - ); - } else { - return ( - - ); - } -}; - -ArrowLeft.propTypes = { - color: PropTypes.string, - type: PropTypes.oneOf(["double", "long", "default"]), -}; -export default ArrowLeft; diff --git a/client/src/Components/v1/Avatar/index.css b/client/src/Components/v1/Avatar/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Components/v1/Avatar/index.jsx b/client/src/Components/v1/Avatar/index.jsx deleted file mode 100644 index d089080ff..000000000 --- a/client/src/Components/v1/Avatar/index.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Avatar as MuiAvatar } from "@mui/material"; -import PropTypes from "prop-types"; -import { useSelector } from "react-redux"; -import { useEffect, useState } from "react"; -import { useTheme } from "@emotion/react"; -/** - * @component - * @param {Object} props - * @param {string} props.src - Path to image for avatar - * @param {boolean} props.small - Specifies if avatar should be large - * @param {Object} [props.sx] - Additional styles to apply to the button. - * @returns {JSX.Element} - * @example - * // Render a red label - * - */ - -const Avatar = ({ src, small, sx, onClick = () => {} }) => { - const { user } = useSelector((state) => state.auth); - const theme = useTheme(); - - const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 }; - const border = small ? 1 : 3; - - const [image, setImage] = useState(); - useEffect(() => { - if (user.avatarImage) { - setImage(`data:image/png;base64,${user.avatarImage}`); - } - }, [user?.avatarImage]); - - return ( - - {user.firstName?.charAt(0)} - {user.lastName?.charAt(0) || ""} - - ); -}; - -Avatar.propTypes = { - src: PropTypes.string, - small: PropTypes.bool, - sx: PropTypes.object, - onClick: PropTypes.func, -}; - -export default Avatar; diff --git a/client/src/Components/v1/Label/index.css b/client/src/Components/v1/Label/index.css deleted file mode 100644 index d3fe53da5..000000000 --- a/client/src/Components/v1/Label/index.css +++ /dev/null @@ -1,6 +0,0 @@ -.label { - display: inline-flex; - justify-content: center; - align-items: center; - line-height: normal; -} diff --git a/client/src/Components/v1/Label/index.jsx b/client/src/Components/v1/Label/index.jsx deleted file mode 100644 index 2ed9b93bf..000000000 --- a/client/src/Components/v1/Label/index.jsx +++ /dev/null @@ -1,172 +0,0 @@ -import PropTypes from "prop-types"; -import { Box } from "@mui/material"; -import { useTheme } from "@mui/material"; -import "./index.css"; - -/** - * @typedef {Object} Styles - * @param {string} [color] - The text color - * @param {string} [backgroundColor] - The background color - * @param {string} [borderColor] - The border color - */ - -/** - * @component - * @param {Object} props - * @param {string} props.label - The label of the label - * @param {Styles} props.styles - CSS Styles passed from parent component - * @param {React.ReactNode} children - Children passed from parent component - * @returns {JSX.Element} - */ - -const BaseLabel = ({ label, styles, children }) => { - const theme = useTheme(); - // Grab the default borderRadius from the theme to match button style - const { borderRadius } = theme.shape; - // Calculate padding for the label to mimic button. Appears to scale correctly, not 100% sure though. - const padding = theme.spacing(3, 5); - - return ( - - {children} - {label} - - ); -}; - -BaseLabel.propTypes = { - label: PropTypes.string.isRequired, - styles: PropTypes.shape({ - color: PropTypes.string, - backgroundColor: PropTypes.string, - }), - children: PropTypes.node, -}; - -// Produces a lighter color based on a hex color and a percent -// lightenColor("#067647", 20) will produce a color 20% lighter than #067647 -const lightenColor = (color, percent) => { - let r = parseInt(color.substring(1, 3), 16); - let g = parseInt(color.substring(3, 5), 16); - let b = parseInt(color.substring(5, 7), 16); - - const amt = Math.round((255 * percent) / 100); - - r = r + amt <= 255 ? r + amt : 255; - g = g + amt <= 255 ? g + amt : 255; - b = b + amt <= 255 ? b + amt : 255; - - r = r.toString(16).padStart(2, "0"); - g = g.toString(16).padStart(2, "0"); - b = b.toString(16).padStart(2, "0"); - - return `#${r}${g}${b}`; -}; - -/** - * @component - * @param {Object} props - * @param {string} props.label - The label of the label - * @param {string} props.color - The color of the label, specified in #RRGGBB format - * @returns {JSX.Element} - * @example - * // Render a red label - * - */ - -const ColoredLabel = ({ label, color }) => { - const theme = useTheme(); - // If an invalid color is passed, default to the labelGray color - if (typeof color !== "string" || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) { - color = theme.palette.primary.lowContrast; - } - - // Calculate lighter shades for border and bg - const borderColor = lightenColor(color, 20); - const bgColor = lightenColor(color, 75); - - return ( - - ); -}; - -ColoredLabel.propTypes = { - label: PropTypes.string.isRequired, - color: PropTypes.string.isRequired, -}; - -/** - * @component - * @param {Object} props - * @param {'up' | 'down' | 'paused' | 'pending' | 'cannot resolve' | 'published' | 'unpublished'} props.status - The status for the label - * @param {string} props.text - The text of the label - * @returns {JSX.Element} - * @example - * // Render an active label - * - */ - -const statusToTheme = { - up: "success", - down: "error", - paused: "warning", - pending: "warning", - "cannot resolve": "error", - published: "success", - unpublished: "error", -}; - -const StatusLabel = ({ status, text, customStyles }) => { - const theme = useTheme(); - - const themeColor = statusToTheme[status]; - - return ( - - - - ); -}; - -StatusLabel.propTypes = { - status: PropTypes.oneOf([ - "up", - "down", - "paused", - "pending", - "cannot resolve", - "published", - "unpublished", - ]), - text: PropTypes.string, - customStyles: PropTypes.object, -}; - -export { BaseLabel, ColoredLabel, StatusLabel }; diff --git a/client/src/Components/v1/Layouts/HomeLayout/index.css b/client/src/Components/v1/Layouts/HomeLayout/index.css deleted file mode 100644 index 61808aa57..000000000 --- a/client/src/Components/v1/Layouts/HomeLayout/index.css +++ /dev/null @@ -1,29 +0,0 @@ -.home-layout { - position: relative; - min-height: 100vh; - margin: 0 auto; - padding: 0; - overflow-x: hidden; - width: 100%; -} - -/* TODO go for this approach for responsiveness. The aside needs to be taken care of */ -/* @media (max-width: 1000px) { - .home-layout { - flex-direction: column !important; - } -} */ - -.home-layout > .home-content-wrapper { - min-height: calc(100vh - var(--env-var-spacing-2) * 2); - flex: 1; -} - -.home-content-wrapper { - padding: var(--env-var-spacing-2); - max-width: 1400px; - margin: 0 auto; - flex: 1; - min-width: 0; - overflow-x: hidden; -} diff --git a/client/src/Components/v1/Layouts/HomeLayout/index.jsx b/client/src/Components/v1/Layouts/HomeLayout/index.jsx deleted file mode 100644 index adadf638f..000000000 --- a/client/src/Components/v1/Layouts/HomeLayout/index.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import Sidebar from "../../Sidebar/index.jsx"; -import { Outlet } from "react-router"; -import { Box, Stack } from "@mui/material"; -import { useSidebar } from "@/Hooks/useSidebar.js"; - -import "./index.css"; - -const HomeLayout = () => { - const { width, transition } = useSidebar(); - - return ( - - - - - - - - ); -}; - -export default HomeLayout; diff --git a/client/src/Components/v1/Sidebar/components/authFooter.jsx b/client/src/Components/v1/Sidebar/components/authFooter.jsx deleted file mode 100644 index 16b60d9f2..000000000 --- a/client/src/Components/v1/Sidebar/components/authFooter.jsx +++ /dev/null @@ -1,276 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Tooltip from "@mui/material/Tooltip"; -import IconButton from "@mui/material/IconButton"; -import Avatar from "../../Avatar/index.jsx"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Divider from "@mui/material/Divider"; -import Icon from "../../Icon"; - -import { useTheme } from "@emotion/react"; -import { useSelector } from "react-redux"; -import { useTranslation } from "react-i18next"; -import { useState } from "react"; -import { useNavigate } from "react-router"; -import { clearAuthState } from "../../../../Features/Auth/authSlice.js"; -import { useDispatch } from "react-redux"; -import PropTypes from "prop-types"; - -const getFilteredAccountMenuItems = (user, items) => { - if (!user) return []; - - let filtered = [...items]; - - if (user.role?.includes("demo")) { - filtered = filtered.filter((item) => item.name !== "Password"); - } - - if (!user.role?.includes("superadmin")) { - filtered = filtered.filter((item) => item.name !== "Team"); - } - - return filtered; -}; - -const getRoleDisplayText = (user, t) => { - if (!user?.role) return ""; - - if (user.role.includes("superadmin")) return t("roles.superAdmin"); - if (user.role.includes("admin")) return t("roles.admin"); - if (user.role.includes("user")) return t("roles.teamMember"); - if (user.role.includes("demo")) return t("roles.demoUser"); - - return user.role; -}; - -const AuthFooter = ({ collapsed, accountMenuItems }) => { - const { t } = useTranslation(); - const theme = useTheme(); - const authState = useSelector((state) => state.auth); - const navigate = useNavigate(); - const dispatch = useDispatch(); - - const [anchorEl, setAnchorEl] = useState(null); - - const openPopup = (event) => { - setAnchorEl(event.currentTarget); - }; - - const closePopup = () => { - setAnchorEl(null); - }; - - const logout = async () => { - dispatch(clearAuthState()); - navigate("/login"); - }; - const renderAccountMenuItems = (user, items) => { - const filteredItems = getFilteredAccountMenuItems(user, items); - - return filteredItems.map((item) => ( - { - closePopup(); - navigate(item.path); - }} - sx={{ - gap: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - pl: theme.spacing(4), - color: theme.palette.primary.contrastTextTertiary, - "& svg": { - stroke: theme.palette.primary.contrastTextTertiary, - }, - }} - > - {item.icon} - {item.name} - - )); - }; - return ( - - collapsed && openPopup(e)} - sx={{ - cursor: collapsed ? "pointer" : "default", - }} - /> - - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {getRoleDisplayText(authState.user, t)} - - - - openPopup(event)} - > - - - - - - {collapsed && ( - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role} - - - - )} - {/* TODO Do we need two dividers? */} - {collapsed && } - {/* */} - {renderAccountMenuItems(authState.user, accountMenuItems)} - - - {t("menu.logOut", "Log out")} - - - - ); -}; - -AuthFooter.propTypes = { - collapsed: PropTypes.bool, - accountMenuItems: PropTypes.array, -}; - -export default AuthFooter; diff --git a/client/src/Components/v1/Sidebar/components/collapseButton.jsx b/client/src/Components/v1/Sidebar/components/collapseButton.jsx deleted file mode 100644 index e3a9c91f5..000000000 --- a/client/src/Components/v1/Sidebar/components/collapseButton.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import IconButton from "@mui/material/IconButton"; -import ArrowRight from "../../ArrowRight/index.jsx"; -import ArrowLeft from "../../ArrowLeft/index.jsx"; -import { useTheme } from "@mui/material/styles"; -import { useDispatch } from "react-redux"; -import { toggleSidebar } from "../../../../Features/UI/uiSlice.js"; -import PropTypes from "prop-types"; - -const CollapseButton = ({ collapsed }) => { - const theme = useTheme(); - const dispatch = useDispatch(); - const arrowIcon = collapsed ? ( - - ) : ( - - ); - return ( - { - dispatch(toggleSidebar()); - }} - > - {arrowIcon} - - ); -}; - -CollapseButton.propTypes = { - collapsed: PropTypes.bool.isRequired, -}; -export default CollapseButton; diff --git a/client/src/Components/v1/Sidebar/components/logo.jsx b/client/src/Components/v1/Sidebar/components/logo.jsx deleted file mode 100644 index a0c0ff84e..000000000 --- a/client/src/Components/v1/Sidebar/components/logo.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { useTheme } from "@mui/material/styles"; -import { useNavigate } from "react-router"; -import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; - -const Logo = ({ collapsed }) => { - const { t } = useTranslation(); - const theme = useTheme(); - const navigate = useNavigate(); - - return ( - navigate("/")} - sx={{ cursor: "pointer" }} - > - - C - - - {" "} - - {t("common.appName")} - - - - ); -}; - -Logo.propTypes = { - collapsed: PropTypes.bool, -}; - -export default Logo; diff --git a/client/src/Components/v1/Sidebar/components/navItem.jsx b/client/src/Components/v1/Sidebar/components/navItem.jsx deleted file mode 100644 index 170d39bee..000000000 --- a/client/src/Components/v1/Sidebar/components/navItem.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import Tooltip from "@mui/material/Tooltip"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; - -const NavItem = ({ item, collapsed, selected, onClick }) => { - const theme = useTheme(); - const iconStroke = selected - ? theme.palette.primary.contrastText - : theme.palette.primary.contrastTextTertiary; - - const buttonBgColor = selected ? theme.palette.secondary.main : "transparent"; - const buttonBgHoverColor = selected - ? theme.palette.secondary.main - : theme.palette.tertiary.main; - const fontWeight = selected ? 600 : 400; - return ( - - - - {item.icon} - - - - {item.name} - - - - - ); -}; - -NavItem.propTypes = { - item: PropTypes.object, - collapsed: PropTypes.bool, - selected: PropTypes.bool, - onClick: PropTypes.func, -}; -export default NavItem; diff --git a/client/src/Components/v1/Sidebar/index.jsx b/client/src/Components/v1/Sidebar/index.jsx deleted file mode 100644 index 763a4123c..000000000 --- a/client/src/Components/v1/Sidebar/index.jsx +++ /dev/null @@ -1,152 +0,0 @@ -import Stack from "@mui/material/Stack"; - -import List from "@mui/material/List"; -import Logo from "./components/logo.jsx"; -import CollapseButton from "./components/collapseButton.jsx"; -import Divider from "@mui/material/Divider"; -import NavItem from "./components/navItem.jsx"; -import AuthFooter from "./components/authFooter.jsx"; - -import StarPrompt from "../StarPrompt/index.jsx"; -import Icon from "../Icon"; - -// Utils -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; -import { useSidebar } from "@/Hooks/useSidebar.js"; - -const URL_MAP = { - support: "https://discord.com/invite/NAb6H3UTjK", - discussions: "https://github.com/bluewave-labs/checkmate/discussions", - docs: "https://bluewavelabs.gitbook.io/checkmate", - changelog: "https://github.com/bluewave-labs/checkmate/releases", -}; - -const getMenu = (t) => [ - { name: t("menu.uptime"), path: "uptime", icon: }, - { name: t("menu.pagespeed"), path: "pagespeed", icon: }, - { name: t("menu.infrastructure"), path: "infrastructure", icon: }, - { - name: t("menu.notifications"), - path: "notifications", - icon: , - }, - { name: t("menu.checks"), path: "checks", icon: }, - { name: t("menu.incidents"), path: "incidents", icon: }, - { name: t("menu.statusPages"), path: "status", icon: }, - { name: t("menu.maintenance"), path: "maintenance", icon: }, - { name: t("menu.logs"), path: "logs", icon: }, - { - name: t("menu.settings"), - icon: , - path: "settings", - }, -]; - -const getOtherMenuItems = (t) => [ - { name: t("menu.support"), path: "support", icon: }, - { - name: t("menu.discussions"), - path: "discussions", - icon: , - }, - { name: t("menu.docs"), path: "docs", icon: }, - { name: t("menu.changelog"), path: "changelog", icon: }, -]; - -const getAccountMenuItems = (t) => [ - { name: t("menu.profile"), path: "account/profile", icon: }, - { name: t("menu.password"), path: "account/password", icon: }, - { name: t("menu.team"), path: "account/team", icon: }, -]; - -const Sidebar = () => { - const theme = useTheme(); - const { t } = useTranslation(); - const navigate = useNavigate(); - const { collapsed, width, transition } = useSidebar(); - - const menu = getMenu(t); - const otherMenuItems = getOtherMenuItems(t); - const accountMenuItems = getAccountMenuItems(t); - - return ( - - - - - {menu.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - return ( - navigate(`/${item.path}`)} - /> - ); - })} - - {!collapsed && } - - {otherMenuItems.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - - return ( - { - const url = URL_MAP[item.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${item.path}`); - } - }} - /> - ); - })} - - - - - ); -}; - -export default Sidebar; diff --git a/client/src/Components/v1/StarPrompt/index.jsx b/client/src/Components/v1/StarPrompt/index.jsx deleted file mode 100644 index f1dd5e136..000000000 --- a/client/src/Components/v1/StarPrompt/index.jsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from "react"; -import { Typography, IconButton, Stack, Box } from "@mui/material"; -import Icon from "../Icon"; -import { useTheme } from "@emotion/react"; -import { useSelector, useDispatch } from "react-redux"; -import { useTranslation } from "react-i18next"; -import { setStarPromptOpen } from "../../../Features/UI/uiSlice.js"; - -const StarPrompt = ({ repoUrl = "https://github.com/bluewave-labs/checkmate" }) => { - const theme = useTheme(); - const dispatch = useDispatch(); - const { t } = useTranslation(); - const isOpen = useSelector((state) => state.ui?.starPromptOpen ?? true); - const mode = useSelector((state) => state.ui.mode); - - const handleClose = () => { - dispatch(setStarPromptOpen(false)); - }; - - const handleStarClick = () => { - window.open(repoUrl, "_blank"); - }; - - if (!isOpen) return null; - - return ( - - - - {t("starPromptTitle")} - - - - - - - - {t("starPromptDescription")} - - - - - ); -}; - -export default StarPrompt; diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx index 4b369cb67..62ea5610b 100644 --- a/client/src/Components/v2/sidebar/Authfooter.tsx +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -51,7 +51,7 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => if (role.includes("superadmin")) return t("components.sidebar.authFooter.roles.superAdmin"); if (role.includes("admin")) return t("components.sidebar.authFooter.roles.admin"); - if (role.includes("user")) return t("components.sidebar.authFooter.roles.teamMember"); + if (role.includes("user")) return t("components.sidebar.authFooter.roles.user"); if (role.includes("demo")) return t("components.sidebar.authFooter.roles.demoUser"); return role; }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b49ebaa43..86b220998 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -366,7 +366,7 @@ "roles": { "superAdmin": "Super admin", "admin": "Admin", - "teamMember": "Team member", + "user": "User", "demoUser": "Demo user" } } From 10bfdc9da13de9ebd2c406638f972f001aeefe3c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 10 Feb 2026 23:53:06 +0000 Subject: [PATCH 266/392] hook up offline banner --- .../v2/design-elements/OfflineBanner.tsx | 49 ++---- client/src/Components/v2/layout/AppLayout.tsx | 32 +++- client/src/Pages/ServerUnreachable.jsx | 166 ------------------ client/src/Routes/index.jsx | 7 - client/src/Utils/ApiClient.ts | 6 +- client/src/locales/en.json | 10 +- 6 files changed, 59 insertions(+), 211 deletions(-) delete mode 100644 client/src/Pages/ServerUnreachable.jsx diff --git a/client/src/Components/v2/design-elements/OfflineBanner.tsx b/client/src/Components/v2/design-elements/OfflineBanner.tsx index fa5690c12..062b1ca0d 100644 --- a/client/src/Components/v2/design-elements/OfflineBanner.tsx +++ b/client/src/Components/v2/design-elements/OfflineBanner.tsx @@ -1,30 +1,39 @@ import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { WifiOff } from "lucide-react"; +import { useState, useEffect } from "react"; interface OfflineBannerProps { visible: boolean; - onRetry?: () => void; - isRetrying?: boolean; } -export const OfflineBanner = ({ visible, onRetry, isRetrying }: OfflineBannerProps) => { +export const OfflineBanner = ({ visible }: OfflineBannerProps) => { const theme = useTheme(); const { t } = useTranslation(); + const [shouldRender, setShouldRender] = useState(visible); + const [isAnimating, setIsAnimating] = useState(false); - if (!visible) { - return null; - } + useEffect(() => { + if (visible) { + setShouldRender(true); + requestAnimationFrame(() => setIsAnimating(true)); + } else { + setIsAnimating(false); + const timer = setTimeout(() => setShouldRender(false), 1000); + return () => clearTimeout(timer); + } + }, [visible]); + + if (!shouldRender) return null; return ( {t("components.offlineBanner.serverUnreachable")} - {onRetry && ( - - )} ); diff --git a/client/src/Components/v2/layout/AppLayout.tsx b/client/src/Components/v2/layout/AppLayout.tsx index 3ae2729e3..7c9c3c903 100644 --- a/client/src/Components/v2/layout/AppLayout.tsx +++ b/client/src/Components/v2/layout/AppLayout.tsx @@ -1,10 +1,12 @@ import Box from "@mui/material/Box"; +import { useState, useEffect, useRef } from "react"; import { ThemeProvider, useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import BackgroundSVG from "@/assets/Images/background.svg"; import type { RootState } from "@/Types/state"; import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; import { OfflineBanner } from "@/Components/v2/design-elements"; +import { setServerUnreachableCallback, get } from "@/Utils/ApiClient"; interface AppLayoutProps { children: React.ReactNode; @@ -15,6 +17,34 @@ const AppLayout = ({ children }: AppLayoutProps) => { const mode = useSelector((state: RootState) => state.ui.mode); const v2theme = mode === "dark" ? darkTheme : lightTheme; + const [serverUnreachable, setServerUnreachable] = useState(false); + const retryIntervalRef = useRef(null); + + useEffect(() => { + setServerUnreachableCallback(setServerUnreachable); + }, []); + + useEffect(() => { + if (serverUnreachable) { + retryIntervalRef.current = window.setInterval(async () => { + try { + await get("/health", { timeout: 5000 }); + } catch { + // NO_OP + } + }, 5000); + } else if (retryIntervalRef.current) { + clearInterval(retryIntervalRef.current); + retryIntervalRef.current = null; + } + + return () => { + if (retryIntervalRef.current) { + clearInterval(retryIntervalRef.current); + } + }; + }, [serverUnreachable]); + return ( { }} > - + {children} diff --git a/client/src/Pages/ServerUnreachable.jsx b/client/src/Pages/ServerUnreachable.jsx deleted file mode 100644 index 5051ee95f..000000000 --- a/client/src/Pages/ServerUnreachable.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useState } from "react"; -import { Box, Typography, Button, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; -import { get } from "@/Utils/ApiClient"; -import Alert from "@/Components/v1/Alert/index.jsx"; -import { createToast } from "@/Utils/toastUtils.jsx"; -import { useTranslation } from "react-i18next"; -import Background from "@/assets/Images/background-grid.svg?react"; -import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import ThemeSwitch from "@/Components/v1/ThemeSwitch/index.jsx"; -import LanguageSelector from "@/Components/LanguageSelector.jsx"; - -const ServerUnreachable = () => { - const theme = useTheme(); - const navigate = useNavigate(); - const { t } = useTranslation(); - - // State for tracking connection check status - const [isCheckingConnection, setIsCheckingConnection] = useState(false); - - const handleRetry = React.useCallback(async () => { - setIsCheckingConnection(true); - try { - // Try to connect to the backend with a simple API call - // We'll use any lightweight endpoint that doesn't require authentication - await get("/health", { timeout: 5000 }); - - // If successful, show toast and navigate to login page - createToast({ - body: t("errorPages.serverUnreachable.toasts.reconnected"), - }); - navigate("/login"); - } catch (error) { - // If still unreachable, stay on this page and show toast - createToast({ - body: t("errorPages.serverUnreachable.toasts.stillUnreachable"), - }); - } finally { - setIsCheckingConnection(false); - } - }, [navigate, t]); - - return ( - - - - - - {/* Header with logo */} - - - - {t("common.appName")} - - - - - - - .MuiStack-root": { - border: 1, - borderRadius: theme.spacing(5), - borderColor: theme.palette.primary.lowContrast, - backgroundColor: theme.palette.primary.main, - padding: { - xs: theme.spacing(12), - sm: theme.spacing(20), - }, - }, - }} - > - - - - - - - {t("errorPages.serverUnreachable.description")} - - - - - - - - - ); -}; - -export default ServerUnreachable; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 0a66b0cf3..7d9163c43 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -25,9 +25,6 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/"; import Infrastructure from "@/Pages/Infrastructure/Monitors"; import InfrastructureDetails from "@/Pages/Infrastructure/Details/index"; -// Server Status -import ServerUnreachable from "../Pages/ServerUnreachable.jsx"; - // Checks import Checks from "../Pages/Checks/index"; @@ -432,10 +429,6 @@ const Routes = () => { } /> - } - /> { } ); - const onSuccess = (response: AxiosResponse) => response; + const onSuccess = (response: AxiosResponse) => { + // Server is reachable, hide offline banner if shown + serverUnreachableCallback?.(false); + return response; + }; const onError = (error: AxiosError) => { // Handle network errors (server unreachable) if (error.code === "ERR_NETWORK") { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 86b220998..d2a066c11 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -582,7 +582,7 @@ "notFoundButton": "Go to the main dashboard", "notifications": { "fallback": { - "actionButton": "Let's create your first notification channel!", + "actionButton": "Create notification channel!", "checks": [ "Alert teams about downtime or performance issues", "Let engineers know when incidents happen", @@ -1019,7 +1019,7 @@ } }, "fallback": { - "actionButton": "Let's create your first infrastructure monitor!", + "actionButton": "Create a monitor!", "checks": [ "Track the performance of your servers", "Identify bottlenecks and optimize usage", @@ -1087,7 +1087,7 @@ }, "maintenanceWindow": { "fallback": { - "actionButton": "Let's create your first maintenance window!", + "actionButton": "Create a maintenance window!", "checks": [ "Mark your maintenance periods", "Eliminate any misunderstandings", @@ -1228,7 +1228,7 @@ } }, "fallback": { - "actionButton": "Let's create your first PageSpeed monitor!", + "actionButton": "Create a monitor!", "checks": [ "Report on the user experience of a page", "Help analyze webpage speed", @@ -1247,7 +1247,7 @@ "Build trust with transparent service monitoring", "Reduce support requests during incidents" ], - "actionButton": "Let's create your first status page!" + "actionButton": "Create a status page!" }, "monitorsList": { "chartTypeHeatmap": "Heatmap", From 64438020243d4671a526af0f7b1ef9d8eb14d4ef Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 17:21:32 +0000 Subject: [PATCH 267/392] cleanup imports --- client/src/Routes/index.jsx | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 7d9163c43..0022187fe 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -11,7 +11,7 @@ import NotFound from "@/Pages/NotFound"; import AuthLogin from "@/Pages/Auth/Login"; import AuthRegister from "@/Pages/Auth/Register"; import AuthForgotPassword from "@/Pages/Auth/Recovery"; -import AuthSetNewPassword from "../Pages/Auth/SetNewPassword"; +import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword"; // Uptime import Uptime from "@/Pages/Uptime/Monitors"; @@ -23,34 +23,34 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/"; // Infrastructure import Infrastructure from "@/Pages/Infrastructure/Monitors"; -import InfrastructureDetails from "@/Pages/Infrastructure/Details/index"; +import InfrastructureDetails from "@/Pages/Infrastructure/Details"; // Checks -import Checks from "../Pages/Checks/index"; +import Checks from "@/Pages/Checks"; // Incidents -import Incidents from "../Pages/Incidents/"; +import Incidents from "@/Pages/Incidents"; // Status pages -import CreateStatus from "../Pages/StatusPage/Create/"; -import StatusPages from "../Pages/StatusPage/StatusPages"; -import Status from "../Pages/StatusPage/Status"; +import CreateStatus from "@/Pages/StatusPage/Create/"; +import StatusPages from "@/Pages/StatusPage/StatusPages"; +import Status from "@/Pages/StatusPage/Status"; -import Notifications from "../Pages/Notifications"; -import CreateNotifications from "../Pages/Notifications/create"; +import Notifications from "@/Pages/Notifications"; +import CreateNotifications from "@/Pages/Notifications/create"; // Settings import Account from "@/Pages/Account"; -import EditUser from "../Pages/Account/EditUser"; -import Settings from "../Pages/Settings"; +import EditUser from "@/Pages/Account/EditUser"; +import Settings from "@/Pages/Settings"; -import Maintenance from "../Pages/Maintenance"; +import Maintenance from "@/Pages/Maintenance"; import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; -import ProtectedRoute from "../Components/v1/ProtectedRoute"; -import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute"; +import ProtectedRoute from "@/Components/v1/ProtectedRoute"; +import RoleProtectedRoute from "@/Components/v1/RoleProtectedRoute"; import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; -import Logs from "../Pages/Logs"; +import Logs from "@/Pages/Logs"; import CreateMonitor from "@/Pages/CreateMonitor"; From ee3062fcb495035bb653e68888103d7df4780272 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 17:37:50 +0000 Subject: [PATCH 268/392] remove unused components --- client/src/Components/v1/Alert/index.css | 9 -- client/src/Components/v1/Alert/index.jsx | 138 ------------------ .../v1/ThemeSwitch/SunAndMoonIcon.jsx | 98 ------------- .../src/Components/v1/ThemeSwitch/index.css | 64 -------- .../src/Components/v1/ThemeSwitch/index.jsx | 53 ------- .../Components/v2/routing/RouteProtected.tsx | 0 .../v2/routing/RouteRoleProtected.tsx | 0 client/src/Routes/index.jsx | 4 +- 8 files changed, 1 insertion(+), 365 deletions(-) delete mode 100644 client/src/Components/v1/Alert/index.css delete mode 100644 client/src/Components/v1/Alert/index.jsx delete mode 100644 client/src/Components/v1/ThemeSwitch/SunAndMoonIcon.jsx delete mode 100644 client/src/Components/v1/ThemeSwitch/index.css delete mode 100644 client/src/Components/v1/ThemeSwitch/index.jsx create mode 100644 client/src/Components/v2/routing/RouteProtected.tsx create mode 100644 client/src/Components/v2/routing/RouteRoleProtected.tsx diff --git a/client/src/Components/v1/Alert/index.css b/client/src/Components/v1/Alert/index.css deleted file mode 100644 index 233b5c466..000000000 --- a/client/src/Components/v1/Alert/index.css +++ /dev/null @@ -1,9 +0,0 @@ -.alert { - margin: 0; - width: fit-content; -} -.alert, -.alert button, -.alert .MuiTypography-root { - font-size: var(--env-var-font-size-medium); -} diff --git a/client/src/Components/v1/Alert/index.jsx b/client/src/Components/v1/Alert/index.jsx deleted file mode 100644 index dd3b2f9f2..000000000 --- a/client/src/Components/v1/Alert/index.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Button, IconButton, Stack, Typography } from "@mui/material"; -import Icon from "../Icon"; -import "./index.css"; - -/** - * Icons mapping for different alert variants. - * @type {Object} - */ - -const icons = { - info: ( - - ), - error: ( - - ), - warning: ( - - ), -}; - -/** - * @param {Object} props - * @param {'info' | 'error' | 'warning'} props.variant - The type of alert. - * @param {string} [props.title] - The title of the alert. - * @param {string} [props.body] - The body text of the alert. - * @param {boolean} [props.isToast] - Indicates if the alert is used as a toast notification. - * @param {boolean} [props.hasIcon] - Whether to display an icon in the alert. - * @param {function} props.onClick - Toast dismiss function. - * @returns {JSX.Element} - */ - -const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => { - const theme = useTheme(); - /* TODO - Do we need other variants for alert? - */ - - const text = theme.palette.secondary.contrastText; - const border = theme.palette.alert.contrastText; - const bg = theme.palette.alert.main; - const icon = icons[variant]; - - return ( - - {hasIcon && {icon}} - - {title && ( - {title} - )} - {body && ( - {body} - )} - {hasIcon && isToast && ( - - )} - - {isToast && ( - - - - )} - - ); -}; - -Alert.propTypes = { - variant: PropTypes.oneOf(["info", "error", "warning"]).isRequired, - title: PropTypes.string, - body: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - isToast: PropTypes.bool, - hasIcon: PropTypes.bool, - onClick: function (props, propName, componentName) { - if (props.isToast && !props[propName]) { - return new Error( - `Prop '${propName}' is required when 'isToast' is true in '${componentName}'.` - ); - } - return null; - }, -}; - -export default Alert; diff --git a/client/src/Components/v1/ThemeSwitch/SunAndMoonIcon.jsx b/client/src/Components/v1/ThemeSwitch/SunAndMoonIcon.jsx deleted file mode 100644 index 21256e114..000000000 --- a/client/src/Components/v1/ThemeSwitch/SunAndMoonIcon.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useTheme } from "@mui/material"; -import "./index.css"; - -const SunAndMoonIcon = () => { - const theme = useTheme(); - - return ( - - ); -}; - -export default SunAndMoonIcon; diff --git a/client/src/Components/v1/ThemeSwitch/index.css b/client/src/Components/v1/ThemeSwitch/index.css deleted file mode 100644 index d5db1c1ca..000000000 --- a/client/src/Components/v1/ThemeSwitch/index.css +++ /dev/null @@ -1,64 +0,0 @@ -.sun-and-moon > :is(.moon, .sun, .sun-beams) { - transform-origin: center; -} - -.theme-toggle .sun-and-moon > .sun-beams { - stroke-width: 2px; -} - -.theme-dark .sun-and-moon > .sun { - transform: scale(1.75); -} - -.theme-dark .sun-and-moon > .sun-beams { - opacity: 0; -} - -.theme-dark .sun-and-moon > .moon > circle { - transform: translateX(-7px); -} - -@supports (cx: 1) { - .theme-dark .sun-and-moon > .moon > circle { - cx: 17; - transform: translateX(0); - } -} - -@media (prefers-reduced-motion: no-preference) { - .sun-and-moon > .sun { - transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55); - } - - .sun-and-moon > .sun-beams { - transition: - transform 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55), - opacity 0.5s cubic-bezier(0.25, 0.1, 0.25, 1); - } - - .sun-and-moon .moon > circle { - transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); - } - - @supports (cx: 1) { - .sun-and-moon .moon > circle { - transition: cx 0.25s cubic-bezier(0.4, 0, 0.2, 1); - } - } - - .theme-dark .sun-and-moon > .sun { - transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); - transition-duration: 0.25s; - transform: scale(1.75); - } - - .theme-dark .sun-and-moon > .sun-beams { - transition-duration: 0.15s; - transform: rotateZ(-25deg); - } - - .theme-dark .sun-and-moon > .moon > circle { - transition-duration: 0.5s; - transition-delay: 0.25s; - } -} diff --git a/client/src/Components/v1/ThemeSwitch/index.jsx b/client/src/Components/v1/ThemeSwitch/index.jsx deleted file mode 100644 index 60763da90..000000000 --- a/client/src/Components/v1/ThemeSwitch/index.jsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * ThemeSwitch Component - * Dark and Light Theme Switch - * Original Code: https://web.dev/patterns/theming/theme-switch - * License: Apache License 2.0 - * Copyright © Google LLC - * - * This code has been adapted for use in this project. - * Apache License: https://www.apache.org/licenses/LICENSE-2.0 - */ - -import { IconButton } from "@mui/material"; -import SunAndMoonIcon from "./SunAndMoonIcon.jsx"; -import { useDispatch, useSelector } from "react-redux"; -import { setMode } from "../../../Features/UI/uiSlice.js"; -import "./index.css"; -import { useTranslation } from "react-i18next"; - -const ThemeSwitch = ({ width = 48, height = 48, color }) => { - const mode = useSelector((state) => state.ui.mode); - const dispatch = useDispatch(); - const { t } = useTranslation(); - - const toggleTheme = () => { - dispatch(setMode(mode === "light" ? "dark" : "light")); - }; - - return ( - :is(circle, g)": { - fill: color, - stroke: color, - }, - }} - > - - - ); -}; - -export default ThemeSwitch; diff --git a/client/src/Components/v2/routing/RouteProtected.tsx b/client/src/Components/v2/routing/RouteProtected.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/Components/v2/routing/RouteRoleProtected.tsx b/client/src/Components/v2/routing/RouteRoleProtected.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 0022187fe..178d00b2d 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -49,14 +49,12 @@ import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; import ProtectedRoute from "@/Components/v1/ProtectedRoute"; import RoleProtectedRoute from "@/Components/v1/RoleProtectedRoute"; -import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; import Logs from "@/Pages/Logs"; import CreateMonitor from "@/Pages/CreateMonitor"; const Routes = () => { const mode = useSelector((state) => state.ui.mode); - const AdminCheckedRegister = withAdminCheck(AuthRegister); const v2theme = mode === "light" ? lightTheme : darkTheme; return ( @@ -380,7 +378,7 @@ const Routes = () => { element={ <> - + } From de447d7be2a90e0a77125674c534fab34e92affd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 17:52:26 +0000 Subject: [PATCH 269/392] move admin check to regsiter page --- .../src/Components/v1/HOC/withAdminCheck.jsx | 49 ------------------- .../Components/v2/routing/RouteProtected.tsx | 41 ++++++++++++++++ .../v2/routing/RouteRoleProtected.tsx | 0 client/src/Pages/Auth/Register/index.tsx | 14 +++++- client/src/Routes/index.jsx | 9 +++- 5 files changed, 61 insertions(+), 52 deletions(-) delete mode 100644 client/src/Components/v1/HOC/withAdminCheck.jsx delete mode 100644 client/src/Components/v2/routing/RouteRoleProtected.tsx diff --git a/client/src/Components/v1/HOC/withAdminCheck.jsx b/client/src/Components/v1/HOC/withAdminCheck.jsx deleted file mode 100644 index 6343b2e4e..000000000 --- a/client/src/Components/v1/HOC/withAdminCheck.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; - -import { logger } from "../../../Utils/Logger.js"; -import { useLazyGet } from "@/Hooks/UseApi"; - -const withAdminCheck = (WrappedComponent) => { - const WithAdminCheck = (props) => { - const navigate = useNavigate(); - const [superAdminExists, setSuperAdminExists] = useState(false); - const [hasChecked, setHasChecked] = useState(false); - const { get: checkSuperAdmin, loading: isChecking } = useLazyGet(); - - useEffect(() => { - checkSuperAdmin("/auth/users/superadmin") - .then((response) => { - if (response?.data === true) { - navigate("/login"); - } else { - setSuperAdminExists(false); - } - }) - .catch((error) => { - logger.error(error); - }) - .finally(() => { - setHasChecked(true); - }); - }, [navigate, checkSuperAdmin]); - - if (!hasChecked || isChecking) { - return null; - } - - return ( - - ); - }; - const wrappedComponentName = - WrappedComponent.displayName || WrappedComponent.name || "Component"; - WithAdminCheck.displayName = `WithAdminCheck(${wrappedComponentName})`; - - return WithAdminCheck; -}; - -export default withAdminCheck; diff --git a/client/src/Components/v2/routing/RouteProtected.tsx b/client/src/Components/v2/routing/RouteProtected.tsx index e69de29bb..7064c26a4 100644 --- a/client/src/Components/v2/routing/RouteProtected.tsx +++ b/client/src/Components/v2/routing/RouteProtected.tsx @@ -0,0 +1,41 @@ +import { Navigate } from "react-router-dom"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; +import type { UserRole } from "@/Types/User"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const authState = useSelector((state: RootState) => state.auth); + + return authState.authToken ? ( + children + ) : ( + + ); +}; + +interface RoleProtectedRouteProps { + roles: UserRole[]; + children: React.ReactNode; +} + +export const RoleProtectedRoute = ({ roles, children }: RoleProtectedRouteProps) => { + const authState = useSelector((state: RootState) => state.auth); + const userRoles = authState?.user?.role || []; + const canAccess = userRoles.some((role) => roles.includes(role)); + + return canAccess ? ( + children + ) : ( + + ); +}; diff --git a/client/src/Components/v2/routing/RouteRoleProtected.tsx b/client/src/Components/v2/routing/RouteRoleProtected.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 75d27649f..3c8a4e4bc 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRegisterForm } from "@/Hooks/useRegisterForm"; import type { RegisterFormData } from "@/Validation/register"; import { useTranslation } from "react-i18next"; -import { usePost } from "@/Hooks/UseApi"; +import { usePost, useGet } from "@/Hooks/UseApi"; import { setAuthState } from "@/Features/Auth/authSlice"; import { useDispatch } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; @@ -32,11 +32,21 @@ const RegisterPage = () => { const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>(); const hasVerified = useRef(false); + const { data: superAdminExists, isLoading: isCheckingAdmin } = useGet( + token ? null : "/auth/users/superadmin" + ); + const { control, handleSubmit, setError, reset } = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + useEffect(() => { + if (superAdminExists === true) { + navigate("/login", { replace: true }); + } + }, [superAdminExists, navigate]); + useEffect(() => { if (!token || hasVerified.current) return; hasVerified.current = true; @@ -53,6 +63,8 @@ const RegisterPage = () => { }); }, [token]); + if (isCheckingAdmin) return null; + const onSubmit = async (data: RegisterFormData) => { if (loading) return; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 178d00b2d..3b0b76fc6 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -47,10 +47,15 @@ import Settings from "@/Pages/Settings"; import Maintenance from "@/Pages/Maintenance"; import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; -import ProtectedRoute from "@/Components/v1/ProtectedRoute"; -import RoleProtectedRoute from "@/Components/v1/RoleProtectedRoute"; +// Logs & Diagnostics import Logs from "@/Pages/Logs"; +// Routing +import { + ProtectedRoute, + RoleProtectedRoute, +} from "@/Components/v2/routing/RouteProtected"; + import CreateMonitor from "@/Pages/CreateMonitor"; const Routes = () => { From 6f6920f3b25425c244146a9b6ee09f610709bc36 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 18:15:26 +0000 Subject: [PATCH 270/392] migrate protected routes --- .../Components/v1/ProtectedRoute/index.jsx | 32 ----------------- .../v1/RoleProtectedRoute/index.jsx | 35 ------------------- 2 files changed, 67 deletions(-) delete mode 100644 client/src/Components/v1/ProtectedRoute/index.jsx delete mode 100644 client/src/Components/v1/RoleProtectedRoute/index.jsx diff --git a/client/src/Components/v1/ProtectedRoute/index.jsx b/client/src/Components/v1/ProtectedRoute/index.jsx deleted file mode 100644 index 313500338..000000000 --- a/client/src/Components/v1/ProtectedRoute/index.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Navigate } from "react-router-dom"; -import { useSelector } from "react-redux"; -import PropTypes from "prop-types"; - -/** - * ProtectedRoute is a wrapper component that ensures only authenticated users - * can access the wrapped content. It checks authentication status (e.g., from Redux or Context). - * If the user is authenticated, it renders the children; otherwise, it redirects to the login page. - * - * @param {Object} props - The props passed to the ProtectedRoute component. - * @param {React.ReactNode} props.children - The children to render if the user is authenticated. - * @returns {React.ReactElement} The children wrapped in a protected route or a redirect to the login page. - */ - -const ProtectedRoute = ({ children }) => { - const authState = useSelector((state) => state.auth); - - return authState.authToken ? ( - children - ) : ( - - ); -}; - -ProtectedRoute.propTypes = { - children: PropTypes.element.isRequired, -}; - -export default ProtectedRoute; diff --git a/client/src/Components/v1/RoleProtectedRoute/index.jsx b/client/src/Components/v1/RoleProtectedRoute/index.jsx deleted file mode 100644 index c7b7dfc12..000000000 --- a/client/src/Components/v1/RoleProtectedRoute/index.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Navigate } from "react-router-dom"; -import { useSelector } from "react-redux"; -import PropTypes from "prop-types"; - -/** - * ProtectedRoute is a wrapper component that ensures only authenticated users - * can access the wrapped content. It checks authentication status (e.g., from Redux or Context). - * If the user is authenticated, it renders the children; otherwise, it redirects to the login page. - * - * @param {Object} props - The props passed to the ProtectedRoute component. - * @param {React.ReactNode} props.children - The children to render if the user is authenticated. - * @returns {React.ReactElement} The children wrapped in a protected route or a redirect to the login page. - */ - -const RoleProtectedRoute = ({ roles, children }) => { - const authState = useSelector((state) => state.auth); - const userRoles = authState?.user?.role || []; - const canAccess = userRoles.some((role) => roles.includes(role)); - - return canAccess ? ( - children - ) : ( - - ); -}; - -RoleProtectedRoute.propTypes = { - children: PropTypes.element.isRequired, - roles: PropTypes.array, -}; - -export default RoleProtectedRoute; From e214d9a54ce96bb1972fd4cb8934550caf45d6a1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 18:34:55 +0000 Subject: [PATCH 271/392] sidebar tweak --- .../src/Components/v2/sidebar/Authfooter.tsx | 2 +- client/src/Components/v2/sidebar/index.tsx | 134 ++++++++++-------- 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx index 62ea5610b..4724ed97d 100644 --- a/client/src/Components/v2/sidebar/Authfooter.tsx +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -76,7 +76,7 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => alignItems="center" py={theme.spacing(4)} px={theme.spacing(8)} - gap={theme.spacing(2)} + gap={theme.spacing(4)} > { }; return ( - t.zIndex.drawer : "auto", - }} - > - + dispatch(setCollapsed({ collapsed: true }))} + sx={{ zIndex: 999 }} + /> + - - {menu.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - return ( - handleNavClick(item.path)} - /> - ); - })} - - - - {bottomMenu.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - return ( - handleNavClick(item.path)} - /> - ); - })} - - + + + {menu.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + + + + {bottomMenu.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + + - - + + + ); }; From 446c791f88b7d1899add15e623512058ed00e838 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 18:36:20 +0000 Subject: [PATCH 272/392] avatar text color --- client/src/Components/v2/design-elements/Avatar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Components/v2/design-elements/Avatar.tsx b/client/src/Components/v2/design-elements/Avatar.tsx index 74c06f8f3..2dd1160ad 100644 --- a/client/src/Components/v2/design-elements/Avatar.tsx +++ b/client/src/Components/v2/design-elements/Avatar.tsx @@ -31,6 +31,7 @@ export const Avatar = ({ src, small, sx, onClick = () => {} }: AvatarProps) => { alt={`${user?.firstName} ${user?.lastName}`} src={src ? src : user?.avatarImage ? image : undefined} sx={{ + color: theme.palette.primary.contrastText, fontSize: small ? "16px" : "22px", fontWeight: 400, backgroundColor: theme.palette.primary.main, From d296aec02ea0f6a48b35aa82b553aee5c40c8ed1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 18:44:21 +0000 Subject: [PATCH 273/392] add monitor statuses --- server/src/types/monitor.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f6c0f83a7..6d1f9a422 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -1,10 +1,12 @@ -import type { Check } from "@/types/check.js"; import type { CheckSnapshot } from "@/types/check.js"; export type { CheckSnapshot } from "@/types/check.js"; export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; +export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance"] as const; +export type MonitorStatus = (typeof MonitorStatuses)[number]; + export interface MonitorThresholds { usage_cpu?: number; usage_memory?: number; @@ -20,7 +22,7 @@ export interface Monitor { teamId: string; name: string; description?: string; - status?: boolean; + status?: MonitorStatus; statusWindow: boolean[]; statusWindowSize: number; statusWindowThreshold: number; From 7b658a9e52e50986562ce7417bbd1af77ef88f33 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 18:58:12 +0000 Subject: [PATCH 274/392] initial commit --- server/src/db/models/Monitor.ts | 10 ++-- .../monitors/MongoMonitorsRepository.ts | 56 ++++++------------- server/src/types/monitor.ts | 2 +- 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index d4cc9a872..9111ce47f 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,9 +1,6 @@ import { Schema, model, Types, type UpdateQuery } from "mongoose"; import type { Monitor, MonitorMatchMethod, MonitorThresholds, CheckSnapshot } from "@/types/monitor.js"; -import { MonitorTypes } from "@/types/monitor.js"; -import Check from "./Check.js"; -import MonitorStats from "./MonitorStats.js"; -import StatusPage from "./StatusPage.js"; +import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -68,8 +65,9 @@ const MonitorSchema = new Schema( type: String, }, status: { - type: Boolean, - default: undefined, + type: String, + enum: MonitorStatuses, + default: "initializing", }, statusWindow: { type: [Boolean], diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 9b30e5ac2..71bdca752 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -55,7 +55,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { query.isActive = filter === "true"; break; case "status": - query.status = filter === "true"; + query.status = filter; break; case "type": query.type = filter; @@ -223,17 +223,27 @@ class MongoMonitorsRepository implements IMonitorsRepository { totalMonitors: { $sum: 1 }, upMonitors: { $sum: { - $cond: [{ $eq: ["$status", true] }, 1, 0], + $cond: [{ $eq: ["$status", "up"] }, 1, 0], }, }, downMonitors: { $sum: { - $cond: [{ $eq: ["$status", false] }, 1, 0], + $cond: [{ $eq: ["$status", "down"] }, 1, 0], }, }, pausedMonitors: { $sum: { - $cond: [{ $eq: ["$isActive", false] }, 1, 0], + $cond: [{ $eq: ["$status", "paused"] }, 1, 0], + }, + }, + initializingMonitors: { + $sum: { + $cond: [{ $eq: ["$status", "initializing"] }, 1, 0], + }, + }, + maintenanceMonitors: { + $sum: { + $cond: [{ $eq: ["$status", "maintenance"] }, 1, 0], }, }, }, @@ -242,7 +252,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { ]; const [summary] = await MonitorModel.aggregate(pipeline); - return summary ?? { totalMonitors: 0, upMonitors: 0, downMonitors: 0, pausedMonitors: 0 }; + return summary ?? { totalMonitors: 0, upMonitors: 0, downMonitors: 0, pausedMonitors: 0, initializingMonitors: 0, maintenanceMonitors: 0 }; }; findGroupsByTeamId = async (teamId: string): Promise => { @@ -284,7 +294,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { teamId: toStringId(doc.teamId), name: doc.name, description: doc.description ?? undefined, - status: doc.status ?? undefined, + status: doc.status ?? "initializing", statusWindow: doc.statusWindow ?? [], statusWindowSize: doc.statusWindowSize, statusWindowThreshold: doc.statusWindowThreshold, @@ -331,45 +341,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification)); - const checks: Check[] = (doc.checks ?? []).map((check: any) => ({ - id: toStringId(check._id), - metadata: { - monitorId: toStringId(check.metadata?.monitorId), - teamId: toStringId(check.metadata?.teamId), - type: check.metadata?.type, - }, - status: check.status, - responseTime: check.responseTime, - timings: check.timings, - statusCode: check.statusCode, - message: check.message, - ack: check.ack, - ackAt: check.ackAt ?? null, - expiry: toDateString(check.expiry), - cpu: check.cpu, - memory: check.memory, - disk: check.disk, - host: check.host, - errors: check.errors, - capture: check.capture, - net: check.net, - accessibility: check.accessibility, - bestPractices: check.bestPractices, - seo: check.seo, - performance: check.performance, - audits: check.audits, - __v: check.__v, - createdAt: toDateString(check.createdAt), - updatedAt: toDateString(check.updatedAt), - })); - return { id: toStringId(doc._id), userId: toStringId(doc.userId), teamId: toStringId(doc.teamId), name: doc.name, description: doc.description ?? undefined, - status: doc.status ?? undefined, + status: doc.status ?? "initializing", statusWindow: doc.statusWindow ?? [], statusWindowSize: doc.statusWindowSize, statusWindowThreshold: doc.statusWindowThreshold, diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 6d1f9a422..f3cd40898 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -22,7 +22,7 @@ export interface Monitor { teamId: string; name: string; description?: string; - status?: MonitorStatus; + status: MonitorStatus; statusWindow: boolean[]; statusWindowSize: number; statusWindowThreshold: number; From d622bce6c141cad8986e89623355f0c4c7854ee1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 19:00:04 +0000 Subject: [PATCH 275/392] update summary type --- client/src/Types/Monitor.ts | 2 ++ server/src/types/monitor.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 2a0272fdc..dde2c4841 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -66,6 +66,8 @@ export interface MonitorsSummary { upMonitors: number; downMonitors: number; pausedMonitors: number; + initializingMonitors: number; + maintenanceMonitors: number; } export interface MonitorsWithChecksResponse { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f3cd40898..384e14fe0 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -58,6 +58,8 @@ export interface MonitorsSummary { upMonitors: number; downMonitors: number; pausedMonitors: number; + initializingMonitors: number; + maintenanceMonitors: number; } export interface MonitorsWithChecksByTeamIdResult { From 94afe19b373ae401d10b743b01a360775206a3d2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 19:22:35 +0000 Subject: [PATCH 276/392] update services/repositories to reflect new monitorstatus type --- .../monitors/MongoMonitorsRepository.ts | 8 ++++++- .../src/service/business/incidentService.ts | 2 +- .../SuperSimpleQueueHelper.ts | 3 ++- .../notificationProviders/utils.ts | 2 +- .../infrastructure/notificationsService.ts | 12 ++++------ .../service/infrastructure/statusService.ts | 24 ++++++------------- server/src/types/network.ts | 3 ++- 7 files changed, 24 insertions(+), 30 deletions(-) diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 71bdca752..65d93421e 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -181,7 +181,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { { $set: { isActive: { $not: "$isActive" }, - status: "$$REMOVE", + status: { + $cond: { + if: { $eq: ["$status", "paused"] }, + then: "initializing", + else: "paused", + }, + }, }, }, ], diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 8116c893a..881f98f56 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -46,7 +46,7 @@ class IncidentService { handleIncident = async (monitor: Monitor, code: number): Promise => { const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId); // Monitor is down, create an incident - if (monitor.status === false) { + if (monitor.status === "down") { if (activeIncident) { return activeIncident; } else { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index cf3824d60..c5a3453b6 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -59,7 +59,7 @@ class SuperSimpleQueueHelper { throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" }); } - // Step 1. Check for maintenacne window, if found, skip the check + // Step 1. Check for maintenance window, if found, skip the check const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); if (maintenanceWindowActive) { this.logger.debug({ @@ -67,6 +67,7 @@ class SuperSimpleQueueHelper { service: SERVICE_NAME, method: "getMonitorJob", }); + // TODO update monitor status return; } diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index f71933425..2fe22e834 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -163,7 +163,7 @@ export const buildHardwareEmail = async (emailService: any, monitor: Monitor, al }; export const buildEmail = async (emailService: any, monitor: Monitor): Promise => { - const template = monitor.status === true ? "serverIsUpTemplate" : "serverIsDownTemplate"; + const template = monitor.status === "up" ? "serverIsUpTemplate" : "serverIsDownTemplate"; const context = { monitor: monitor.name, url: monitor.url }; const html = await emailService.buildEmail(template, context); return html; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 78b1dc8ab..531014a7a 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -1,7 +1,8 @@ -import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification } from "@/types/index.js"; +import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification, MonitorStatus } from "@/types/index.js"; import { shouldSendHardwareAlert } from "@/service/infrastructure/notificationProviders/utils.js"; import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; + export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; findById: (id: string, teamId: string) => Promise; @@ -11,7 +12,7 @@ export interface INotificationsService { handleNotifications: ( monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - prevStatus: boolean | undefined, + prevStatus: MonitorStatus, statusChanged: boolean ) => Promise; @@ -228,12 +229,7 @@ export class NotificationsService implements INotificationsService { return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus); }; - handleNotifications = async ( - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - prevStatus: boolean | undefined, - statusChanged: boolean - ) => { + handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, prevStatus: MonitorStatus, statusChanged: boolean) => { const { type } = monitor; const payload = monitorStatusResponse.payload as HardwareStatusPayload; // If this is a non-hardeware type monitor and status did not change, we're done diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 0e1011278..506ad5396 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -2,7 +2,6 @@ import { IMonitorsRepository } from "@/repositories/index.js"; import MonitorStats from "../../db/models/MonitorStats.js"; import { CheckModel } from "@/db/models/index.js"; import type { - CheckErrorInfo, Monitor, MonitorStatusResponse, StatusChangeResult, @@ -15,7 +14,6 @@ const SERVICE_NAME = "StatusService"; export interface IStatusService { updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: any }): Promise; - getStatusString(status: boolean | undefined): string; handleIncidentForCheck(check: any, monitor: Monitor, action: any, errorContext?: string): Promise; updateMonitorStatus( statusResponse: MonitorStatusResponse, @@ -39,7 +37,7 @@ export class StatusService implements IStatusService { return StatusService.SERVICE_NAME; } - async updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: any }) { + async updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: MonitorStatusResponse }) { try { const monitorId = monitor.id; const { responseTime, status } = networkResponse; @@ -60,7 +58,7 @@ export class StatusService implements IStatusService { // Update stats // Last response time - stats.lastResponseTime = responseTime; + stats.lastResponseTime = responseTime ?? 0; // Avg response time: let avgResponseTime = stats.avgResponseTime; @@ -111,12 +109,6 @@ export class StatusService implements IStatusService { } } - getStatusString = (status: boolean | undefined) => { - if (status === true) return "up"; - if (status === false) return "down"; - return "unknown"; - }; - handleIncidentForCheck = async (check: Check, monitor: Monitor, action: any, errorContext = "incident handling") => { try { let savedCheck; @@ -208,9 +200,7 @@ export class StatusService implements IStatusService { monitor.recentChecks.shift(); } - if (monitor.status === undefined || monitor.status === null) { - monitor.status = status; - } + monitor.status = status === true ? "up" : "down"; const prevStatus = monitor.status; let newStatus = monitor.status; @@ -233,13 +223,13 @@ export class StatusService implements IStatusService { const failureRate = (failures / monitor.statusWindow.length) * 100; // If threshold has been met and the monitor is not already down, mark down: - if (failureRate >= monitor.statusWindowThreshold && monitor.status !== false) { - newStatus = false; + if (failureRate >= monitor.statusWindowThreshold && monitor.status !== "down") { + newStatus = "down"; statusChanged = true; } // If the failure rate is below the threshold and the monitor is down, recover: - else if (failureRate < monitor.statusWindowThreshold && monitor.status === false) { - newStatus = true; + else if (failureRate < monitor.statusWindowThreshold && monitor.status === "down") { + newStatus = "up"; statusChanged = true; } diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 6bed40316..7fbfb79fe 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -11,6 +11,7 @@ import type { Monitor, MonitorMatchMethod, MonitorType, + MonitorStatus, } from "@/types/index.js"; export interface MonitorStatusResponse { @@ -106,7 +107,7 @@ export interface MonitorPayloadMap { export type StatusChangeResult = { monitor: Monitor; statusChanged: boolean; - prevStatus: boolean | undefined; + prevStatus: MonitorStatus; code: number; timestamp: number; }; From cbd8b5db47536cfc7cd779da28221168aa2f5006 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 19:38:32 +0000 Subject: [PATCH 277/392] update monitor status on maintenance --- server/src/config/services.ts | 1 + .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 4437e9176..9bf18ff1d 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -206,6 +206,7 @@ export const initializeServices = async ({ buffer: bufferService, incidentService, maintenanceWindowsRepository, + monitorsRepository, }); const superSimpleQueue = await SuperSimpleQueue.create({ diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index c5a3453b6..d5d275e59 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -3,7 +3,7 @@ import type { Monitor } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; import IncidentService from "@/service/business/incidentService.js"; -import { IMaintenanceWindowsRepository } from "@/repositories/index.js"; +import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js"; class SuperSimpleQueueHelper { static SERVICE_NAME = SERVICE_NAME; @@ -16,6 +16,7 @@ class SuperSimpleQueueHelper { private buffer: any; private incidentService: IncidentService; private maintenanceWindowsRepository: IMaintenanceWindowsRepository; + private monitorsRepository: IMonitorsRepository; constructor({ logger, @@ -26,6 +27,7 @@ class SuperSimpleQueueHelper { buffer, incidentService, maintenanceWindowsRepository, + monitorsRepository, }: { logger: any; networkService: INetworkService; @@ -35,6 +37,7 @@ class SuperSimpleQueueHelper { buffer: any; incidentService: IncidentService; maintenanceWindowsRepository: IMaintenanceWindowsRepository; + monitorsRepository: IMonitorsRepository; }) { this.logger = logger; this.networkService = networkService; @@ -44,6 +47,7 @@ class SuperSimpleQueueHelper { this.notificationsService = notificationsService; this.incidentService = incidentService; this.maintenanceWindowsRepository = maintenanceWindowsRepository; + this.monitorsRepository = monitorsRepository; } get serviceName() { @@ -60,6 +64,7 @@ class SuperSimpleQueueHelper { } // Step 1. Check for maintenance window, if found, skip the check + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); if (maintenanceWindowActive) { this.logger.debug({ @@ -67,7 +72,9 @@ class SuperSimpleQueueHelper { service: SERVICE_NAME, method: "getMonitorJob", }); - // TODO update monitor status + if (monitor.status !== "maintenance") { + await this.monitorsRepository.updateById(monitorId, teamId, { status: "maintenance" }); + } return; } From 4b1c588f5ad5c02dfeaba676bc11917e0a26c220 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 20:11:22 +0000 Subject: [PATCH 278/392] extract header --- .../v2/design-elements/StatusBox.tsx | 35 ++++++++++++------- .../v2/design-elements/StatusLabel.tsx | 35 +++++++------------ .../v2/monitors/HeaderMonitorsSummary.tsx | 29 +++++++++++++++ client/src/Components/v2/monitors/index.tsx | 1 + .../Pages/Infrastructure/Monitors/index.tsx | 20 +++-------- client/src/Pages/PageSpeed/Monitors/index.tsx | 20 +++-------- .../Status/Components/MonitorsList.tsx | 7 +--- .../Status/Components/StatusBar.tsx | 6 ++-- .../Components/UptimeMonitorsTable.tsx | 7 +--- client/src/Pages/Uptime/Monitors/index.tsx | 25 +++++-------- client/src/Types/Monitor.ts | 12 +++++-- client/src/Utils/MonitorUtils.ts | 21 ++++------- client/src/locales/en.json | 1 + 13 files changed, 105 insertions(+), 114 deletions(-) create mode 100644 client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/v2/design-elements/StatusBox.tsx index bf7979cca..a83ebcf5d 100644 --- a/client/src/Components/v2/design-elements/StatusBox.tsx +++ b/client/src/Components/v2/design-elements/StatusBox.tsx @@ -105,6 +105,29 @@ export const PausedStatusBox = ({ n }: { n: number }) => { /> ); }; +export const MaintenanceStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; +export const InitializingStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; + export const TotalChecksBox = ({ n }: { n: number }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -138,15 +161,3 @@ export const UpChecksBox = ({ n }: { n: number }) => { /> ); }; - -export const InitializingStatusBox = ({ n }: { n: number }) => { - const theme = useTheme(); - const { t } = useTranslation(); - return ( - - ); -}; diff --git a/client/src/Components/v2/design-elements/StatusLabel.tsx b/client/src/Components/v2/design-elements/StatusLabel.tsx index d7212ddff..cf404421e 100644 --- a/client/src/Components/v2/design-elements/StatusLabel.tsx +++ b/client/src/Components/v2/design-elements/StatusLabel.tsx @@ -11,33 +11,24 @@ import { useTranslation } from "react-i18next"; export const ValueTypes = ["positive", "negative", "neutral"] as const; export type ValueType = (typeof ValueTypes)[number]; -export const StatusLabel = ({ - status, - isActive, - sx, -}: { - status: MonitorStatus; - isActive?: boolean; - sx?: SxProps; -}) => { +export const StatusLabel = ({ status, sx }: { status: MonitorStatus; sx?: SxProps }) => { const { t } = useTranslation(); const theme = useTheme(); const palette = getStatusPalette(status); - const determineStatus = ( - isActive: boolean | undefined, - status: MonitorStatus - ): string => { - if (isActive === false) { + const determineStatus = (status: MonitorStatus): string => { + if (status === "up") { + return t("pages.common.monitors.status.up"); + } else if (status === "down") { + return t("pages.common.monitors.status.down"); + } else if (status === "maintenance") { + return t("pages.common.monitors.status.maintenance"); + } else if (status === "paused") { return t("pages.common.monitors.status.paused"); + } else if (status === "initializing") { + return t("pages.common.monitors.status.initializing"); } - if (status === true) { - return t("pages.common.monitors.status.up"); - } - if (status === false) { - return t("pages.common.monitors.status.down"); - } return t("pages.common.monitors.status.initializing"); }; @@ -64,9 +55,7 @@ export const StatusLabel = ({ borderRadius="50%" marginRight="5px" /> - - {determineStatus(isActive, status)} - + {determineStatus(status)} ); }; diff --git a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx new file mode 100644 index 000000000..b2a93a807 --- /dev/null +++ b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx @@ -0,0 +1,29 @@ +import { + UpStatusBox, + DownStatusBox, + PausedStatusBox, + InitializingStatusBox, +} from "@/Components/v2/design-elements"; +import Stack from "@mui/material/Stack"; + +import type { MonitorsSummary } from "@/Types/Monitor"; +import { useTheme } from "@mui/material"; + +interface MonitorsSummaryProps { + summary: MonitorsSummary | null; +} + +export const HeaderMonitorsSummary = ({ summary }: MonitorsSummaryProps) => { + const theme = useTheme(); + return ( + + + + + + + ); +}; diff --git a/client/src/Components/v2/monitors/index.tsx b/client/src/Components/v2/monitors/index.tsx index e2856d6a9..9496395f6 100644 --- a/client/src/Components/v2/monitors/index.tsx +++ b/client/src/Components/v2/monitors/index.tsx @@ -11,3 +11,4 @@ export * from "./charts/PiePageSpeedLegend"; export * from "./charts/HistogramPageSpeedDetails"; export * from "./charts/HistogramPageSpeedDetailsTooltip"; export * from "./charts/HistogramInfrastructure"; +export * from "./HeaderMonitorsSummary"; diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 1d38dbdd3..01de1c09c 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -1,13 +1,8 @@ import Stack from "@mui/material/Stack"; import useMediaQuery from "@mui/material/useMediaQuery"; -import { - MonitorBasePageWithStates, - UpStatusBox, - DownStatusBox, - PausedStatusBox, -} from "@/Components/v2/design-elements"; +import { MonitorBasePageWithStates } from "@/Components/v2/design-elements"; import { HeaderCreate } from "@/Components/v2/common"; -import { ControlsFilter } from "@/Components/v2/monitors"; +import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/v2/monitors"; import { TextField, Dialog } from "@/Components/v2/inputs"; import { useGet, useDelete } from "@/Hooks/UseApi"; @@ -99,7 +94,7 @@ const InfrastructureMonitors = () => { { refreshInterval: 5000, keepPreviousData: true } ); - const { summary, count } = monitorsWithChecksData ?? {}; + const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 }; const isLoading = monitorsWithChecksLoading; // Check if any filters are active @@ -138,14 +133,7 @@ const InfrastructureMonitors = () => { isLoading={isLoading} isAdmin={isAdmin} /> - - - - - + { const { t } = useTranslation(); - const theme = useTheme(); const isAdmin = useIsAdmin(); const { deleteFn, loading: isDeleting } = useDelete(); @@ -48,8 +43,8 @@ const PageSpeedMonitorsPage = () => { const [rowsPerPage, setRowsPerPage] = useState(10); const monitors = monitorsData?.monitors; - const monitorsCount = monitorsData?.count; - const summary = monitorsData?.summary; + const monitorsCount = monitorsData?.count ?? 0; + const summary = monitorsData?.summary ?? null; const isLoading = monitorsIsLoading || settingsIsLoading; @@ -80,14 +75,7 @@ const PageSpeedMonitorsPage = () => { isLoading={isLoading} isAdmin={isAdmin} /> - - - - - + { )} {monitors?.map((monitor) => { - const status = determineState(monitor); return ( { )} - + {statusPage.showCharts !== false && ( diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx index 8b3343249..b5c52b32b 100644 --- a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx +++ b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx @@ -12,16 +12,16 @@ const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => { icon: , }; - if (monitors.every((monitor) => monitor.status === true)) { + if (monitors.every((monitor) => monitor.status === "up")) { monitorsStatus.msg = t("pages.statusPages.statusBar.allUp"); monitorsStatus.color = theme.palette.success.main; monitorsStatus.icon = ; return monitorsStatus; - } else if (monitors.every((monitor) => monitor.status === false)) { + } else if (monitors.every((monitor) => monitor.status === "down")) { monitorsStatus.msg = t("pages.statusPages.statusBar.allDown"); monitorsStatus.color = theme.palette.error.main; return monitorsStatus; - } else if (monitors.some((monitor) => monitor.status === false)) { + } else if (monitors.some((monitor) => monitor.status === "down")) { monitorsStatus.msg = t("pages.statusPages.statusBar.degraded"); monitorsStatus.color = theme.palette.warning.main; return monitorsStatus; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 94c619672..75738e6ad 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -186,12 +186,7 @@ export const MonitorTable = ({ ), render: (row) => { - return ( - - ); + return ; }, }, { diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 702bbd928..9e36ccc86 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -1,10 +1,5 @@ -import { ControlsFilter } from "@/Components/v2/monitors"; -import { - MonitorBasePageWithStates, - UpStatusBox, - DownStatusBox, - PausedStatusBox, -} from "@/Components/v2/design-elements"; +import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/v2/monitors"; +import { MonitorBasePageWithStates } from "@/Components/v2/design-elements"; import { TextField, Dialog } from "@/Components/v2/inputs"; import Stack from "@mui/material/Stack"; import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable"; @@ -99,7 +94,11 @@ const UptimeMonitorsPage = () => { { refreshInterval: 5000, keepPreviousData: true } ); - const { monitors: monitorsWithChecks, summary, count } = monitorsWithChecksData ?? {}; + const { + monitors: monitorsWithChecks, + summary, + count, + } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 }; // Delete hook const { deleteFn, loading: isDeleting } = useDelete(); @@ -150,14 +149,8 @@ const UptimeMonitorsPage = () => { isLoading={isLoading} isAdmin={isAdmin} /> - - - - - + + { - if (typeof monitor === "undefined") return "pending"; - if (monitor?.isActive === false) return "paused"; - if (monitor?.status === undefined) return "pending"; - return monitor?.status == true ? "up" : "down"; -}; - export const getMonitorPath = (type: MonitorType): string => { const pathMap: Record = { http: "uptime", @@ -24,10 +17,10 @@ export const getMonitorPath = (type: MonitorType): string => { }; export const getStatusPalette = (status: MonitorStatus): PaletteKey => { - if (status === true) { + if (status === "up") { return "success"; } - if (status === false) { + if (status === "down") { return "error"; } return "warning"; @@ -43,11 +36,11 @@ export const getValuePalette = (value: ValueType): PaletteKey => { }; export const getStatusColor = (status: MonitorStatus, theme: any): string => { - if (status === true) { + if (status === "up") { return theme.palette.success.light; } - if (status === false) { + if (status === "down") { return theme.palette.error.light; } @@ -101,9 +94,9 @@ export const getStatusPageHeaderConfig = ( return { paletteKey: "error", message: "No monitors available" }; } - const allUp = monitors.every((monitor) => monitor.status === true); - const anyDown = monitors.some((monitor) => monitor.status === false); - const allDown = monitors.every((monitor) => monitor.status === false); + const allUp = monitors.every((monitor) => monitor.status === "up"); + const anyDown = monitors.some((monitor) => monitor.status === "down"); + const allDown = monitors.every((monitor) => monitor.status === "down"); if (allUp) return { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d2a066c11..7eb833308 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -775,6 +775,7 @@ "status": { "down": "down", "initializing": "initializing", + "maintenance": "maintenance", "paused": "paused", "total": "total", "up": "up" From 71cdd05b152a64daad9fd18b9e28142221f401df Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 11 Feb 2026 20:29:50 +0000 Subject: [PATCH 279/392] type errors --- client/src/Pages/Checks/Components/ChecksTable.tsx | 4 ++-- .../Infrastructure/Monitors/Components/MonitorsTable.tsx | 7 +------ client/src/Pages/Uptime/Details/Components/ChecksTable.tsx | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 6464be25c..bb1920ab2 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -6,7 +6,7 @@ import { } from "@/Components/v2/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/v2/design-elements/Table"; -import type { Monitor, MonitorStatus } from "@/Types/Monitor"; +import type { Monitor } from "@/Types/Monitor"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; @@ -52,7 +52,7 @@ export const ChecksTable = ({ id: "status", content: "Status", render: (row) => { - return ; + return ; }, }, { diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index ca236f45f..b2bcadc1a 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -195,12 +195,7 @@ export const InfraMonitorsTable = ({ ), render: (row) => { - return ( - - ); + return ; }, }, { diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 2cfdc1f15..ceb8e1a28 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -15,7 +15,7 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "status", content: t("common.table.headers.status"), render: (row) => { - return ; + return ; }, }, { From 0941f3ce1aa9056c8b7b8dbb320672c3842e9587 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Thu, 12 Feb 2026 12:13:36 -0500 Subject: [PATCH 280/392] zod schema and hook --- client/src/Hooks/useSettingsForm.ts | 35 +++++++++++++++++++++++++++ client/src/Validation/settings.ts | 37 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 client/src/Hooks/useSettingsForm.ts create mode 100644 client/src/Validation/settings.ts diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts new file mode 100644 index 000000000..6abf386be --- /dev/null +++ b/client/src/Hooks/useSettingsForm.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import { settingsSchema } from "@/Validation/settings"; +import type { Settings } from "@/Types/Settings"; +import type { SettingsFormData } from "@/Validation/settings"; + +interface UseSettingsFormOptions { + data?: Settings | null; +} +export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => { + return useMemo(() => { + const defaults: SettingsFormData = { + systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS || false, + systemEmailRequireTLS: data?.systemEmailRequireTLS || false, + systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized || true, + systemEmailConnectionHost: data?.systemEmailConnectionHost || "", + systemEmailSecure: data?.systemEmailSecure || true, + systemEmailPool: data?.systemEmailPool || false, + showURL: data?.showURL || false, + //timezone: data?.timezone || "UTC", + language: data?.language || "en", + //chartType: data?.chartType || "histogram", + checkTTL: data?.checkTTL || 30, + pagespeedApiKey: data?.pagespeedApiKey || "", + systemEmailHost: data?.systemEmailHost || "", + systemEmailPort: data?.systemEmailPort || "", + systemEmailAddress: data?.systemEmailAddress || "", + systemEmailUser: data?.systemEmailUser || "", + systemEmailPassword: data?.systemEmailPassword || "", + + } + + + + } +} \ No newline at end of file diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts new file mode 100644 index 000000000..99d0d826d --- /dev/null +++ b/client/src/Validation/settings.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const settingsSchema = z.object({ + systemEmailIgnoreTLS: z.boolean(), + systemEmailRequireTLS: z.boolean(), + systemEmailRejectUnauthorized: z.boolean(), + systemEmailConnectionHost: z.string().optional().or(z.literal("")), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + showURL: z.boolean().optional(), + timezone: z.string().min(1, "Timezone is required").optional(), + mode: z.enum(["light", "dark"]).optional(), + language: z.string().min(2, "Language is required"), + chartType: z.enum(["histogram", "heatmap"]).optional(), + checkTTL: z.coerce.number().int().min(1, "Please enter a value"), + pagespeedApiKey: z.string().optional().or(z.literal("")), + systemEmailHost: z.string().optional().or(z.literal("")), + systemEmailPort: z.coerce.number().nullable().optional().or(z.literal("")), + systemEmailAddress: z + .email("Please enter a valid email address") + .transform((val) => val.toLowerCase().trim()) + .optional() + .or(z.literal("")), + systemEmailUser: z.string().optional().or(z.literal("")), + systemEmailPassword: z.string().optional().or(z.literal("")), + systemEmailTLSServername: z.string().optional().or(z.literal("")), + globalThresholds: z + .object({ + cpu: z.coerce.number().min(1).max(100).optional(), + memory: z.coerce.number().min(1).max(100).optional(), + disk: z.coerce.number().min(1).max(100).optional(), + temperature: z.coerce.number().min(1).max(150).optional(), + }) + .optional(), +}); + +export type SettingsFormData = z.infer; From 2e30b622d3fb0c06aae8b88d80a6b2a57f853aef Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 17:26:17 +0000 Subject: [PATCH 281/392] put -> patch --- client/src/Pages/Settings/index.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index fb6185784..6e6574963 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -27,7 +27,7 @@ import { import SettingsStats from "./SettingsStats.jsx"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import { useGet, usePost, useDelete, useLazyGet, usePut } from "@/Hooks/UseApi"; +import { useGet, usePost, useDelete, useLazyGet, usePatch } from "@/Hooks/UseApi"; // Constants const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }]; @@ -62,10 +62,10 @@ const Settings = () => { } }, [fetchedSettings]); - const { put: saveSettingsFn, loading: isSaving } = usePut(); + const { patch: saveSettingsFn, loading: isSaving } = usePatch(); const saveSettings = async (settings) => { - const response = await saveSettingsFn("/settings", { settings }); + const response = await saveSettingsFn("/settings", settings); if (response?.data) { const data = response.data; setIsApiKeySet(data.pagespeedKeySet); From b540e48aabf130d3063e45e036841868971b8f78 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 18:01:12 +0000 Subject: [PATCH 282/392] clean up index.css --- .../src/Components/WalletProvider/index.css | 53 ------ .../src/Components/WalletProvider/index.jsx | 50 ------ .../src/Components/v1/Breadcrumbs/index.css | 1 - .../src/Components/v1/Inputs/Select/index.jsx | 1 - .../v1/Inputs/TextInput/Adornments/index.jsx | 1 - .../v2/design-elements/MonitorStatus.tsx | 2 - .../v2/design-elements/PulseDot.tsx | 17 +- client/src/Pages/Integrations/index.css | 10 -- client/src/Pages/Integrations/index.jsx | 156 ------------------ client/src/Utils/Theme/globalTheme.js | 8 +- client/src/Utils/Theme/v2Theme.ts | 7 + client/src/index.css | 66 -------- 12 files changed, 23 insertions(+), 349 deletions(-) delete mode 100644 client/src/Components/WalletProvider/index.css delete mode 100644 client/src/Components/WalletProvider/index.jsx delete mode 100644 client/src/Pages/Integrations/index.css delete mode 100644 client/src/Pages/Integrations/index.jsx diff --git a/client/src/Components/WalletProvider/index.css b/client/src/Components/WalletProvider/index.css deleted file mode 100644 index f3da31efc..000000000 --- a/client/src/Components/WalletProvider/index.css +++ /dev/null @@ -1,53 +0,0 @@ -.wallet-adapter-dropdown { - width: 100%; - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - gap: var(--env-var-spacing-1); -} - -.wallet-adapter-button { - display: flex; - align-items: center; - justify-content: center; - width: auto; - height: var(--env-var-height-2) !important; - font-size: var(--env-var-font-size-medium-plus) !important; - font-weight: 500 !important; - margin: 0; - padding: calc( - (var(--env-var-height-2) - var(--env-var-font-size-medium-plus) * 1.2) / 2 - ) - var(--env-var-spacing-1); - white-space: nowrap; -} - -.wallet-adapter-modal-title { - font-size: var(--font-size-h5) !important; -} - -/* Separator styling */ -.wallet-adapter-modal-divider { - background-color: var(--border-color) !important; - margin: var(--spacing-md) 0 !important; -} - -/* Responsive fixes */ -@media (max-width: 1200px) { - .wallet-adapter-button { - font-size: var(--env-var-font-size-medium) !important; - padding: calc((var(--env-var-height-2) - var(--env-var-font-size-medium) * 1.2) / 2) - var(--env-var-spacing-1-minus) !important; - } -} - -@media (max-width: 900px) { - .wallet-adapter-modal-wrapper { - flex-direction: column !important; - } - - .wallet-adapter-modal-divider { - margin: var(--spacing-sm) 0 !important; - } -} diff --git a/client/src/Components/WalletProvider/index.jsx b/client/src/Components/WalletProvider/index.jsx deleted file mode 100644 index 13cc83b07..000000000 --- a/client/src/Components/WalletProvider/index.jsx +++ /dev/null @@ -1,50 +0,0 @@ -// import { useMemo } from "react"; -// import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react"; -// import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; -// import { -// UnsafeBurnerWalletAdapter, -// PhantomWalletAdapter, -// } from "@solana/wallet-adapter-wallets"; - -// import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; -// import { clusterApiUrl } from "@solana/web3.js"; -// import PropTypes from "prop-types"; -// import "./index.css"; - -// // Default styles that can be overridden by your app -// import "@solana/wallet-adapter-react-ui/styles.css"; - -// export const Wallet = ({ children }) => { -// // The network can be set to 'devnet', 'testnet', or 'mainnet-beta'. -// const network = WalletAdapterNetwork.Mainnet; - -// // You can also provide a custom RPC endpoint. -// const endpoint = useMemo(() => clusterApiUrl(network), [network]); - -// const wallets = useMemo( -// () => [new PhantomWalletAdapter()], -// // eslint-disable-next-line react-hooks/exhaustive-deps -// [network] -// ); - -// return ( -// -// -// {children} -// -// -// ); -// }; - -// Wallet.propTypes = { -// children: PropTypes.node, -// }; - -const Wallet = ({ children }) => { - return children; -}; - -export default Wallet; diff --git a/client/src/Components/v1/Breadcrumbs/index.css b/client/src/Components/v1/Breadcrumbs/index.css index abb3c0a28..022db9a83 100644 --- a/client/src/Components/v1/Breadcrumbs/index.css +++ b/client/src/Components/v1/Breadcrumbs/index.css @@ -6,7 +6,6 @@ min-height: 16px; } .MuiBreadcrumbs-root .MuiBreadcrumbs-li a { - font-size: var(--env-var-font-size-medium); font-weight: 400; } .MuiBreadcrumbs-root .MuiBreadcrumbs-li:not(:last-child) { diff --git a/client/src/Components/v1/Inputs/Select/index.jsx b/client/src/Components/v1/Inputs/Select/index.jsx index 0f96a2ff6..ec2522d17 100644 --- a/client/src/Components/v1/Inputs/Select/index.jsx +++ b/client/src/Components/v1/Inputs/Select/index.jsx @@ -64,7 +64,6 @@ const Select = ({ const theme = useTheme(); const getItemValue = (item) => item?._id ?? item?.id; const itemStyles = { - fontSize: "var(--env-var-font-size-medium)", color: theme.palette.primary.contrastTextTertiary, borderRadius: theme.shape.borderRadius, margin: theme.spacing(2), diff --git a/client/src/Components/v1/Inputs/TextInput/Adornments/index.jsx b/client/src/Components/v1/Inputs/TextInput/Adornments/index.jsx index 078684405..2b3abab0f 100644 --- a/client/src/Components/v1/Inputs/TextInput/Adornments/index.jsx +++ b/client/src/Components/v1/Inputs/TextInput/Adornments/index.jsx @@ -18,7 +18,6 @@ export const HttpAdornment = ({ https }) => { > diff --git a/client/src/Components/v2/design-elements/MonitorStatus.tsx b/client/src/Components/v2/design-elements/MonitorStatus.tsx index 3d5e81845..09cd95f0f 100644 --- a/client/src/Components/v2/design-elements/MonitorStatus.tsx +++ b/client/src/Components/v2/design-elements/MonitorStatus.tsx @@ -23,7 +23,6 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { overflow={"hidden"} textOverflow={"ellipsis"} whiteSpace={"nowrap"} - maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"} > {monitor.name} @@ -40,7 +39,6 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { overflow={"hidden"} textOverflow={"ellipsis"} whiteSpace={"nowrap"} - maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"} > {formatUrl(monitor?.url)} diff --git a/client/src/Components/v2/design-elements/PulseDot.tsx b/client/src/Components/v2/design-elements/PulseDot.tsx index ab792b628..1f5b3401e 100644 --- a/client/src/Components/v2/design-elements/PulseDot.tsx +++ b/client/src/Components/v2/design-elements/PulseDot.tsx @@ -1,4 +1,17 @@ -import { Box, Stack, useTheme } from "@mui/material"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme, keyframes } from "@mui/material"; + +const ripple = keyframes` + from { + opacity: 1; + transform: scale(0); + } + to { + opacity: 0; + transform: scale(2); + } +`; interface PulseDotProps { color: string; @@ -28,7 +41,7 @@ export const PulseDot = ({ color }: PulseDotProps) => { height: "100%", backgroundColor: "inherit", borderRadius: "50%", - animation: "ripple 1.8s ease-out infinite", + animation: `${ripple} 1.8s ease-out infinite`, }, "&::after": { content: `""`, diff --git a/client/src/Pages/Integrations/index.css b/client/src/Pages/Integrations/index.css deleted file mode 100644 index 29969577a..000000000 --- a/client/src/Pages/Integrations/index.css +++ /dev/null @@ -1,10 +0,0 @@ -.integrations h1.MuiTypography-root { - font-size: var(--env-var-font-size-large); - font-weight: 600; -} -.integrations p.MuiTypography-root { - font-size: var(--env-var-font-size-medium); -} -.integrations button { - height: var(--env-var-height-2); -} diff --git a/client/src/Pages/Integrations/index.jsx b/client/src/Pages/Integrations/index.jsx deleted file mode 100644 index dde6b52ee..000000000 --- a/client/src/Pages/Integrations/index.jsx +++ /dev/null @@ -1,156 +0,0 @@ -import PropTypes from "prop-types"; -import { Stack, Typography, Grid, Button } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import Discord from "../../../assets/icons/discord-icon.svg?react"; -import Slack from "../../../assets/icons/slack-icon.svg?react"; -import Zapier from "../../../assets/icons/zapier-icon.svg?react"; -import { useTranslation } from "react-i18next"; - -import "./index.css"; - -/** - * Integrations component - * @param {Object} props - Props for the IntegrationsComponent. - * @param {string} props.icon - The icon for the integration image. - * @param {string} props.header - The header for the integration. - * @param {string} props.info - Information about the integration. - * @param {Function} props.onClick - The onClick handler for the integration button. - * @returns {JSX.Element} The JSX representation of the IntegrationsComponent. - */ -const IntegrationsComponent = ({ icon, header, info, onClick }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - {icon} - - {header} - - {info} - - - - - - ); -}; - -// PropTypes for IntegrationsComponent -IntegrationsComponent.propTypes = { - icon: PropTypes.object.isRequired, - header: PropTypes.string.isRequired, - info: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, -}; - -/** - * Integrations Page Component - * @returns {JSX.Element} The JSX representation of the Integrations page. - */ - -const Integrations = () => { - const theme = useTheme(); - const { t } = useTranslation(); - - const integrations = [ - { - icon: ( - - ), - header: t("integrationsSlack"), - info: t("integrationsSlackInfo"), - onClick: () => {}, - }, - { - icon: ( - - ), - header: t("integrationsDiscord"), - info: t("integrationsDiscordInfo"), - onClick: () => {}, - }, - { - icon: ( - - ), - header: t("integrationsZapier"), - info: t("integrationsZapierInfo"), - onClick: () => {}, - }, - // Add more integrations as needed - ]; - - return ( - - {t("integrations")} - {t("integrationsPrism")} - - {integrations.map((integration, index) => ( - - ))} - - - ); -}; - -export default Integrations; diff --git a/client/src/Utils/Theme/globalTheme.js b/client/src/Utils/Theme/globalTheme.js index 37143d730..98f3479be 100644 --- a/client/src/Utils/Theme/globalTheme.js +++ b/client/src/Utils/Theme/globalTheme.js @@ -38,7 +38,6 @@ const baseTheme = (palette) => ({ fontWeight: 400, }, label: { - fontSize: "var(--env-var-font-size-medium)", color: palette.primary.contrastTextSecondary, fontWeight: 500, }, @@ -332,8 +331,6 @@ const baseTheme = (palette) => ({ "& .MuiInputBase-input": { padding: ".75em", - minHeight: "var(--env-var-height-2)", - fontSize: "var(--env-var-font-size-medium)", fontWeight: 400, color: palette.primary.contrastTextSecondary, "&.Mui-disabled": { @@ -368,13 +365,12 @@ const baseTheme = (palette) => ({ "& .MuiFormHelperText-root": { color: palette.error.main, opacity: 0.8, - fontSize: "var(--env-var-font-size-medium)", + fontSize: "var()", marginLeft: 0, }, "& .MuiFormHelperText-root.Mui-error": { opacity: 0.8, - fontSize: "var(--env-var-font-size-medium)", color: palette.error.main, whiteSpace: "nowrap", }, @@ -762,7 +758,6 @@ const baseTheme = (palette) => ({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", - maxWidth: "calc((100vw - var(--env-var-width-2)) / 2)", }, }, { @@ -775,7 +770,6 @@ const baseTheme = (palette) => ({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", - maxWidth: "calc((100vw - var(--env-var-width-2)) / 2)", }, }, ], diff --git a/client/src/Utils/Theme/v2Theme.ts b/client/src/Utils/Theme/v2Theme.ts index 57ca76669..39ede6c7e 100644 --- a/client/src/Utils/Theme/v2Theme.ts +++ b/client/src/Utils/Theme/v2Theme.ts @@ -49,6 +49,13 @@ export const theme = (mode: string, palette: any) => }, components: { + MuiTouchRipple: { + styleOverrides: { + root: { + display: "none", + }, + }, + }, MuiButtonBase: { defaultProps: { disableRipple: true, diff --git a/client/src/index.css b/client/src/index.css index 48737d4bb..a662ef475 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -18,70 +18,4 @@ html { text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - - /* Generalized Stylings */ - --env-var-radius-2: 8px; - - --env-var-width-2: 360px; - --env-var-width-4: 100px; - - --env-var-height-2: 34px; - - --env-var-nav-bar-height: 70px; - --env-var-side-bar-width: 250px; - --env-var-side-bar-collapsed-width: 64px; - --env-var-side-bar-auth-footer-height: 50px; - - --env-var-spacing-1: 12px; - --env-var-spacing-1-plus: 16px; - --env-var-spacing-1-minus: 10px; - --env-var-spacing-2: 24px; - - --env-var-font-size-small: 11px; - --env-var-font-size-small-plus: 12px; - --env-var-font-size-medium: 13px; - --env-var-font-size-medium-plus: 14px; - --env-var-font-size-large: 16px; - --env-var-font-size-large-plus: 22px; - --env-var-font-size-xlarge: 30px; -} - -.MuiInputBase-root.Mui-disabled input { - cursor: not-allowed; -} - -/* .Toastify__toast-container { - min-width: 300px; - width: auto; -} -.Toastify__toast-body .alert { - min-width: 150px; - padding: 5px 10px; - align-items: center; -} -.Toastify [class^="Toastify__toast"] { - padding: 0; - margin: 0; -} -.Toastify__toast { - min-height: 0; - border-radius: 4px; -} -.Toastify [class*="Toastify__toast-theme"] { - background-color: transparent; -} */ - -.MuiTouchRipple-root { - display: none; -} - -@keyframes ripple { - from { - opacity: 1; - transform: scale(0); - } - to { - opacity: 0; - transform: scale(2); - } } From f38b305ba703f3606f80d83d240d4b49d3acb319 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 18:07:48 +0000 Subject: [PATCH 283/392] ui slice to TS --- client/src/Features/UI/uiSlice.js | 98 ---------------------- client/src/Features/UI/uiSlice.ts | 133 ++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 98 deletions(-) delete mode 100644 client/src/Features/UI/uiSlice.js create mode 100644 client/src/Features/UI/uiSlice.ts diff --git a/client/src/Features/UI/uiSlice.js b/client/src/Features/UI/uiSlice.js deleted file mode 100644 index 0e8ca8608..000000000 --- a/client/src/Features/UI/uiSlice.js +++ /dev/null @@ -1,98 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; - -const initialMode = window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches - ? "dark" - : "light"; - -const initialState = { - monitors: { - rowsPerPage: 10, - }, - team: { - rowsPerPage: 5, - }, - maintenance: { - rowsPerPage: 5, - }, - infrastructure: { - rowsPerPage: 5, - }, - logs: { - rowsPerPage: 15, - }, - sidebar: { - collapsed: false, - }, - mode: initialMode, - showURL: false, - greeting: { index: 0, lastUpdate: null }, - timezone: "America/Toronto", - distributedUptimeEnabled: false, - language: "en", - starPromptOpen: true, - chartType: "histogram", -}; - -const uiSlice = createSlice({ - name: "ui", - initialState, - reducers: { - setDistributedUptimeEnabled: (state, action) => { - state.distributedUptimeEnabled = action.payload; - }, - setRowsPerPage: (state, action) => { - const { table, value } = action.payload; - if (!state[table]) { - state[table] = {}; - } - state[table].rowsPerPage = value; - }, - toggleSidebar: (state) => { - state.sidebar.collapsed = !state.sidebar.collapsed; - }, - setCollapsed: (state, action) => { - const { collapsed } = action.payload; - state.sidebar.collapsed = collapsed; - }, - setMode: (state, action) => { - state.mode = action.payload; - }, - setShowURL: (state, action) => { - state.showURL = action.payload; - }, - setGreeting(state, action) { - if (!state.greeting) { - state.greeting = { index: 0, lastUpdate: null }; - } - state.greeting.index = action.payload.index; - state.greeting.lastUpdate = action.payload.lastUpdate; - }, - setTimezone(state, action) { - state.timezone = action.payload.timezone; - }, - setLanguage: (state, action) => { - state.language = action.payload; - }, - setStarPromptOpen: (state, action) => { - state.starPromptOpen = action.payload; - }, - setChartType: (state, action) => { - state.chartType = action.payload; - }, - }, -}); - -export default uiSlice.reducer; -export const { - setRowsPerPage, - toggleSidebar, - setCollapsed, - setMode, - setShowURL, - setGreeting, - setTimezone, - setDistributedUptimeEnabled, - setLanguage, - setStarPromptOpen, - setChartType, -} = uiSlice.actions; diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts new file mode 100644 index 000000000..0675784b9 --- /dev/null +++ b/client/src/Features/UI/uiSlice.ts @@ -0,0 +1,133 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; + +type ThemeMode = "light" | "dark"; +type ChartType = "histogram" | "line"; +type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs"; + +interface TableState { + rowsPerPage: number; +} + +interface SidebarState { + collapsed: boolean; +} + +interface GreetingState { + index: number; + lastUpdate: string | null; +} + +interface UIState { + monitors: TableState; + team: TableState; + maintenance: TableState; + infrastructure: TableState; + logs: TableState; + sidebar: SidebarState; + mode: ThemeMode; + showURL: boolean; + greeting: GreetingState; + timezone: string; + distributedUptimeEnabled: boolean; + language: string; + starPromptOpen: boolean; + chartType: ChartType; +} + +const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)") + ?.matches + ? "dark" + : "light"; + +const initialState: UIState = { + monitors: { + rowsPerPage: 10, + }, + team: { + rowsPerPage: 5, + }, + maintenance: { + rowsPerPage: 5, + }, + infrastructure: { + rowsPerPage: 5, + }, + logs: { + rowsPerPage: 15, + }, + sidebar: { + collapsed: false, + }, + mode: initialMode, + showURL: false, + greeting: { index: 0, lastUpdate: null }, + timezone: "America/Toronto", + distributedUptimeEnabled: false, + language: "en", + starPromptOpen: true, + chartType: "histogram", +}; + +const uiSlice = createSlice({ + name: "ui", + initialState, + reducers: { + setDistributedUptimeEnabled: (state, action: PayloadAction) => { + state.distributedUptimeEnabled = action.payload; + }, + setRowsPerPage: ( + state, + action: PayloadAction<{ table: TableName; value: number }> + ) => { + const { table, value } = action.payload; + state[table].rowsPerPage = value; + }, + toggleSidebar: (state) => { + state.sidebar.collapsed = !state.sidebar.collapsed; + }, + setCollapsed: (state, action: PayloadAction<{ collapsed: boolean }>) => { + state.sidebar.collapsed = action.payload.collapsed; + }, + setMode: (state, action: PayloadAction) => { + state.mode = action.payload; + }, + setShowURL: (state, action: PayloadAction) => { + state.showURL = action.payload; + }, + setGreeting: ( + state, + action: PayloadAction<{ index: number; lastUpdate: string | null }> + ) => { + state.greeting.index = action.payload.index; + state.greeting.lastUpdate = action.payload.lastUpdate; + }, + setTimezone: (state, action: PayloadAction<{ timezone: string }>) => { + state.timezone = action.payload.timezone; + }, + setLanguage: (state, action: PayloadAction) => { + state.language = action.payload; + }, + setStarPromptOpen: (state, action: PayloadAction) => { + state.starPromptOpen = action.payload; + }, + setChartType: (state, action: PayloadAction) => { + state.chartType = action.payload; + }, + }, +}); + +export type { UIState, ThemeMode, ChartType, TableName }; +export default uiSlice.reducer; +export const { + setRowsPerPage, + toggleSidebar, + setCollapsed, + setMode, + setShowURL, + setGreeting, + setTimezone, + setDistributedUptimeEnabled, + setLanguage, + setStarPromptOpen, + setChartType, +} = uiSlice.actions; From 85dbe34e009e0b796635b07b8bd747b46ecce24e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 18:30:15 +0000 Subject: [PATCH 284/392] store to ts --- client/src/main.tsx | 2 +- client/src/{store.js => store.ts} | 25 ++++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) rename client/src/{store.js => store.ts} (59%) diff --git a/client/src/main.tsx b/client/src/main.tsx index 813a839bd..1e339f87b 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -3,7 +3,7 @@ import App from "./App.jsx"; import "./index.css"; import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; -import { persistor, store } from "./store.js"; +import { persistor, store } from "@/store.js"; import { PersistGate } from "redux-persist/integration/react"; import I18nLoader from "./Components/v1/I18nLoader/index.jsx"; import { initApiClient } from "./Utils/ApiClient.js"; diff --git a/client/src/store.js b/client/src/store.ts similarity index 59% rename from client/src/store.js rename to client/src/store.ts index 69aa3d50b..9e064b55f 100644 --- a/client/src/store.js +++ b/client/src/store.ts @@ -1,18 +1,21 @@ import { configureStore, combineReducers } from "@reduxjs/toolkit"; - -import authReducer from "./Features/Auth/authSlice"; -import uiReducer from "./Features/UI/uiSlice"; +import authReducer from "@/Features/Auth/authSlice"; +import uiReducer from "@/Features/UI/uiSlice"; import storage from "redux-persist/lib/storage"; -import { persistReducer, persistStore, createTransform } from "redux-persist"; +import { + persistReducer, + persistStore, + createTransform, + PERSIST, + REHYDRATE, +} from "redux-persist"; const authTransform = createTransform( - (inboundState) => { + (inboundState: Record) => { const { profileImage, ...rest } = inboundState; return rest; }, - // No transformation on rehydration - null, - // Only applies to auth + undefined, { whitelist: ["auth"] } ); @@ -28,6 +31,7 @@ const rootReducer = combineReducers({ ui: uiReducer, }); +// @ts-expect-error - redux-persist types don't align perfectly with redux-toolkit const persistedReducer = persistReducer(persistConfig, rootReducer); export const store = configureStore({ @@ -35,10 +39,13 @@ export const store = configureStore({ middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { - ignoredActions: ["persist/PERSIST", "persist/REHYDRATE", "persist/REGISTER"], + ignoredActions: [PERSIST, REHYDRATE, "persist/REGISTER"], }, }), }); +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + export const persistor = persistStore(store); export default store; From b0b7b6a435a9bce516e51ae0d475dfa9bc3164d4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 18:30:44 +0000 Subject: [PATCH 285/392] fix status change issue --- .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 1 - server/src/service/infrastructure/statusService.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index d5d275e59..6cd1a7675 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -89,7 +89,6 @@ class SuperSimpleQueueHelper { // Step 4 Add check to buffer this.buffer.addToBuffer({ check }); - // Step 4. Update monitor status const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 506ad5396..284fd3ff7 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -3,6 +3,7 @@ import MonitorStats from "../../db/models/MonitorStats.js"; import { CheckModel } from "@/db/models/index.js"; import type { Monitor, + MonitorStatus, MonitorStatusResponse, StatusChangeResult, Check, @@ -200,14 +201,13 @@ export class StatusService implements IStatusService { monitor.recentChecks.shift(); } - monitor.status = status === true ? "up" : "down"; - const prevStatus = monitor.status; - let newStatus = monitor.status; + let newStatus: MonitorStatus = status === true ? "up" : "down"; let statusChanged = false; // Return early if not enough data points if (monitor.statusWindow.length < monitor.statusWindowSize) { + monitor.status = newStatus; const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); return { monitor: updated, From cafea2bf5dd058770e80891d3181f3fe5cd34c78 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 19:13:53 +0000 Subject: [PATCH 286/392] remove unused utils, convert utils to TS --- client/src/App.tsx | 8 - .../index.jsx => v2/i18nLoader/index.tsx} | 5 +- .../v2/monitors/charts/HistogramStatus.tsx | 2 +- client/src/Features/UI/uiSlice.ts | 16 +- .../Incidents/Components/IncidentTable.tsx | 4 +- client/src/Utils/Logger.js | 74 ------- client/src/Utils/MonitorUtils.ts | 8 +- client/src/Utils/ReadMe.md | 1 - client/src/Utils/fileUtils.js | 11 - client/src/Utils/greeting.jsx | 197 ------------------ client/src/Utils/{i18n.js => i18n.ts} | 20 +- client/src/Utils/monitorUtilsLegacy.js | 47 ----- client/src/Utils/roleUtils.js | 13 -- client/src/Utils/stringUtils.js | 46 ---- client/src/Utils/timeUtilsLegacy.js | 167 --------------- client/src/Utils/utils.js | 15 -- client/src/Validation/error.js | 101 --------- client/src/Validation/validation.js | 14 +- client/src/main.tsx | 2 +- 19 files changed, 40 insertions(+), 711 deletions(-) rename client/src/Components/{v1/I18nLoader/index.jsx => v2/i18nLoader/index.tsx} (63%) delete mode 100644 client/src/Utils/Logger.js delete mode 100644 client/src/Utils/ReadMe.md delete mode 100644 client/src/Utils/fileUtils.js delete mode 100644 client/src/Utils/greeting.jsx rename client/src/Utils/{i18n.js => i18n.ts} (55%) delete mode 100644 client/src/Utils/monitorUtilsLegacy.js delete mode 100644 client/src/Utils/roleUtils.js delete mode 100644 client/src/Utils/stringUtils.js delete mode 100644 client/src/Utils/timeUtilsLegacy.js delete mode 100644 client/src/Utils/utils.js delete mode 100644 client/src/Validation/error.js diff --git a/client/src/App.tsx b/client/src/App.tsx index 0a6b364f8..1004131e3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,7 +6,6 @@ import { ThemeProvider } from "@emotion/react"; import lightTheme from "./Utils/Theme/lightTheme"; import darkTheme from "./Utils/Theme/darkTheme"; import { CssBaseline, GlobalStyles } from "@mui/material"; -import { logger } from "./Utils/Logger"; // Import the logger import { Routes } from "./Routes"; import AppLayout from "@/Components/v2/layout/AppLayout"; import type { RootState } from "@/Types/state"; @@ -14,13 +13,6 @@ import type { RootState } from "@/Types/state"; function App() { const mode = useSelector((state: RootState) => state.ui.mode); - // Cleanup - useEffect(() => { - return () => { - logger.cleanup(); - }; - }, []); - const theme = mode === "light" ? lightTheme : darkTheme; return ( diff --git a/client/src/Components/v1/I18nLoader/index.jsx b/client/src/Components/v2/i18nLoader/index.tsx similarity index 63% rename from client/src/Components/v1/I18nLoader/index.jsx rename to client/src/Components/v2/i18nLoader/index.tsx index 676e63d7b..f0555afed 100644 --- a/client/src/Components/v1/I18nLoader/index.jsx +++ b/client/src/Components/v2/i18nLoader/index.tsx @@ -1,8 +1,9 @@ -import i18n from "../../../Utils/i18n.js"; +import i18n from "@/Utils/i18n.js"; import { useSelector } from "react-redux"; import { useEffect } from "react"; +import type { RootState } from "@/store"; const I18nLoader = () => { - const language = useSelector((state) => state.ui.language ?? "en"); + const language = useSelector((state: RootState) => state.ui.language ?? "en"); useEffect(() => { if (language && i18n.language !== language) { diff --git a/client/src/Components/v2/monitors/charts/HistogramStatus.tsx b/client/src/Components/v2/monitors/charts/HistogramStatus.tsx index 1c25c339e..d3aa62a69 100644 --- a/client/src/Components/v2/monitors/charts/HistogramStatus.tsx +++ b/client/src/Components/v2/monitors/charts/HistogramStatus.tsx @@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography"; import { BaseChart } from "@/Components/v2/design-elements"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; -import { formatDateWithTz } from "@/Utils/timeUtilsLegacy"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts"; import { getResponseTimeColor } from "@/Utils/MonitorUtils"; diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts index 0675784b9..84e8248db 100644 --- a/client/src/Features/UI/uiSlice.ts +++ b/client/src/Features/UI/uiSlice.ts @@ -12,11 +12,6 @@ interface SidebarState { collapsed: boolean; } -interface GreetingState { - index: number; - lastUpdate: string | null; -} - interface UIState { monitors: TableState; team: TableState; @@ -26,7 +21,6 @@ interface UIState { sidebar: SidebarState; mode: ThemeMode; showURL: boolean; - greeting: GreetingState; timezone: string; distributedUptimeEnabled: boolean; language: string; @@ -60,7 +54,6 @@ const initialState: UIState = { }, mode: initialMode, showURL: false, - greeting: { index: 0, lastUpdate: null }, timezone: "America/Toronto", distributedUptimeEnabled: false, language: "en", @@ -94,13 +87,7 @@ const uiSlice = createSlice({ setShowURL: (state, action: PayloadAction) => { state.showURL = action.payload; }, - setGreeting: ( - state, - action: PayloadAction<{ index: number; lastUpdate: string | null }> - ) => { - state.greeting.index = action.payload.index; - state.greeting.lastUpdate = action.payload.lastUpdate; - }, + setTimezone: (state, action: PayloadAction<{ timezone: string }>) => { state.timezone = action.payload.timezone; }, @@ -124,7 +111,6 @@ export const { setCollapsed, setMode, setShowURL, - setGreeting, setTimezone, setDistributedUptimeEnabled, setLanguage, diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index 121f33123..a9b4bc104 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -9,13 +9,13 @@ import type { Monitor } from "@/Types/Monitor"; import type { ActionMenuItem } from "@/Components/v2/actions-menu"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; +import { getMonitorPath } from "@/Utils/MonitorUtils"; interface IncidentsTableProps { title?: string; @@ -62,7 +62,7 @@ export const IncidentsTable = ({ label: t("pages.incidents.table.actions.goToMonitor"), action: () => { if (monitor) { - const path = TypeToPathMap[monitor.type as keyof typeof TypeToPathMap]; + const path = getMonitorPath(monitor.type); if (path && monitor.id) { navigate(`/${path}/${monitor.id}`); } diff --git a/client/src/Utils/Logger.js b/client/src/Utils/Logger.js deleted file mode 100644 index 849e10854..000000000 --- a/client/src/Utils/Logger.js +++ /dev/null @@ -1,74 +0,0 @@ -const LOG_LEVEL = import.meta.env.VITE_APP_LOG_LEVEL || "debug"; -const NO_OP = () => {}; - -class Logger { - constructor() { - let logLevel = LOG_LEVEL; - this.updateLogLevel(logLevel); - // Defer store subscription to avoid circular dependency during HMR - setTimeout(() => { - try { - import("../store").then(({ default: store }) => { - if (store) { - this.unsubscribe = store.subscribe(() => { - logLevel = "debug"; - this.updateLogLevel(logLevel); - }); - } - }); - } catch (e) { - // Store not ready yet, ignore - } - }, 0); - } - - updateLogLevel(logLevel) { - if (logLevel === "none") { - this.info = NO_OP; - this.error = NO_OP; - this.warn = NO_OP; - this.log = NO_OP; - return; - } - - if (logLevel === "error") { - this.error = console.error.bind(console); - this.info = NO_OP; - this.warn = NO_OP; - this.log = NO_OP; - return; - } - - if (logLevel === "warn") { - this.error = console.error.bind(console); - this.warn = console.warn.bind(console); - this.info = NO_OP; - this.log = NO_OP; - return; - } - - if (logLevel === "info") { - this.error = console.error.bind(console); - this.warn = console.warn.bind(console); - this.info = console.info.bind(console); - this.log = NO_OP; - return; - } - - if (logLevel === "debug") { - this.error = console.error.bind(console); - this.warn = console.warn.bind(console); - this.info = console.info.bind(console); - this.log = console.log.bind(console); - return; - } - } - - cleanup() { - if (this.unsubscribe) { - this.unsubscribe(); - } - } -} - -export const logger = new Logger(); diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 703579fc6..7d7149c9d 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -7,11 +7,11 @@ export const getMonitorPath = (type: MonitorType): string => { http: "uptime", port: "uptime", ping: "uptime", - hardware: "hardware", - pagespeed: "pagespeed", - docker: "docker", - game: "game-servers", + game: "uptime", unknown: "uptime", + docker: "uptime", + hardware: "infrastructure", + pagespeed: "pagespeed", }; return pathMap[type]; }; diff --git a/client/src/Utils/ReadMe.md b/client/src/Utils/ReadMe.md deleted file mode 100644 index 6488752d1..000000000 --- a/client/src/Utils/ReadMe.md +++ /dev/null @@ -1 +0,0 @@ -#Utils folder diff --git a/client/src/Utils/fileUtils.js b/client/src/Utils/fileUtils.js deleted file mode 100644 index 1c35738dd..000000000 --- a/client/src/Utils/fileUtils.js +++ /dev/null @@ -1,11 +0,0 @@ -export const formatBytes = (bytes) => { - if (bytes === 0) return "0 Bytes"; - const megabytes = bytes / (1024 * 1024); - return megabytes.toFixed(2) + " MB"; -}; - -export const checkImage = (url) => { - const img = new Image(); - img.src = url; - return img.naturalWidth !== 0; -}; diff --git a/client/src/Utils/greeting.jsx b/client/src/Utils/greeting.jsx deleted file mode 100644 index c4afbf88c..000000000 --- a/client/src/Utils/greeting.jsx +++ /dev/null @@ -1,197 +0,0 @@ -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Box, Typography } from "@mui/material"; -import { useDispatch, useSelector } from "react-redux"; -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { setGreeting } from "../Features/UI/uiSlice"; - -const early = [ - { - prepend: "Rise and shine", - append: "If you’re up this early, you might as well be a legend!", - emoji: "☕", - }, - { - prepend: "Good morning", - append: "The world’s still asleep, but you’re already awesome!", - emoji: "🦉", - }, - { - prepend: "Good morning", - append: "Are you a wizard? Only magical people are up at this hour!", - emoji: "🌄", - }, - { - prepend: "Up before the roosters", - append: "Ready to tackle the day before it even starts?", - emoji: "🐓", - }, - { - prepend: "Early bird special", - append: "Let’s get things done while everyone else is snoozing!", - emoji: "🌟", - }, -]; - -const morning = [ - { - prepend: "Good morning", - append: "Is it coffee o’clock yet, or should we start with high fives?", - emoji: "☕", - }, - { - prepend: "Morning", - append: "The sun is up, and so are you—time to be amazing!", - emoji: "🌞", - }, - { - prepend: "Good morning", - append: "Time to make today the best thing since sliced bread!", - emoji: "🥐", - }, - { - prepend: "Morning", - append: "Let’s kick off the day with more energy than a double espresso!", - emoji: "🚀", - }, - { - prepend: "Rise and shine", - append: "You’re about to make today so great, even Monday will be jealous!", - emoji: "🌟", - }, -]; - -const afternoon = [ - { - prepend: "Good afternoon", - append: "How about a break to celebrate how awesome you’re doing?", - emoji: "🥪", - }, - { - prepend: "Afternoon", - append: "If you’re still going strong, you’re officially a rockstar!", - emoji: "🌞", - }, - { - prepend: "Hey there", - append: "The afternoon is your playground—let’s make it epic!", - emoji: "🍕", - }, - { - prepend: "Good afternoon", - append: "Time to crush the rest of the day like a pro!", - emoji: "🏆", - }, - { - prepend: "Afternoon", - append: "Time to turn those afternoon slumps into afternoon triumphs!", - emoji: "🎉", - }, -]; - -const evening = [ - { - prepend: "Good evening", - append: "Time to wind down and think about how you crushed today!", - emoji: "🌇", - }, - { - prepend: "Evening", - append: "You’ve earned a break—let’s make the most of these evening vibes!", - emoji: "🍹", - }, - { - prepend: "Hey there", - append: "Time to relax and bask in the glow of your day’s awesomeness!", - emoji: "🌙", - }, - { - prepend: "Good evening", - append: "Ready to trade productivity for chill mode?", - emoji: "🛋️ ", - }, - { - prepend: "Evening", - append: "Let’s call it a day and toast to your success!", - emoji: "🕶️", - }, -]; - -/** - * Greeting component that displays a personalized greeting message - * based on the time of day and the user's first name. - * - * @component - * @example - * return ; - * - * @param {Object} props - * @param {string} props.type - The type of monitor to be displayed in the message - * @returns {JSX.Element} The rendered Greeting component - */ - -const Greeting = ({ type = "" }) => { - const theme = useTheme(); - const dispatch = useDispatch(); - const { t } = useTranslation(); - const { firstName } = useSelector((state) => state.auth.user); - const index = useSelector((state) => state.ui.greeting?.index ?? 0); - const lastUpdate = useSelector((state) => state.ui.greeting?.lastUpdate ?? null); - - const now = new Date(); - const hour = now.getHours(); - - useEffect(() => { - const hourDiff = lastUpdate ? hour - lastUpdate : null; - - if (!lastUpdate || hourDiff >= 1) { - let random = Math.floor(Math.random() * 5); - dispatch(setGreeting({ index: random, lastUpdate: hour })); - } - }, [dispatch, hour, lastUpdate]); - - let greetingArray = - hour < 6 ? early : hour < 12 ? morning : hour < 18 ? afternoon : evening; - const { prepend, append, emoji } = greetingArray[index]; - - return ( - - - - {t("greeting.prepend", { defaultValue: prepend })},{" "} - - - {firstName} {emoji} - - - - {t("greeting.append", { defaultValue: append })} —{" "} - {t("greeting.overview", { type: t(`menu.${type}`) })} - - - ); -}; - -Greeting.propTypes = { - type: PropTypes.string, -}; - -export default Greeting; diff --git a/client/src/Utils/i18n.js b/client/src/Utils/i18n.ts similarity index 55% rename from client/src/Utils/i18n.js rename to client/src/Utils/i18n.ts index 030091d48..db448a065 100644 --- a/client/src/Utils/i18n.js +++ b/client/src/Utils/i18n.ts @@ -1,16 +1,26 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; +import type { Resource } from "i18next"; const primaryLanguage = "en"; -// Load all translation files eagerly -const translations = import.meta.glob("../locales/*.json", { eager: true }); +interface TranslationModule { + default?: Record; + [key: string]: unknown; +} -const resources = {}; +// Load all translation files eagerly +const translations = import.meta.glob("../locales/*.json", { + eager: true, +}); + +const resources: Resource = {}; Object.keys(translations).forEach((path) => { - const langCode = path.match(/\/([^/]+)\.json$/)[1]; + const match = path.match(/\/([^/]+)\.json$/); + if (!match) return; + const langCode = match[1]; resources[langCode] = { - translation: translations[path].default || translations[path], + translation: translations[path].default ?? translations[path], }; }); diff --git a/client/src/Utils/monitorUtilsLegacy.js b/client/src/Utils/monitorUtilsLegacy.js deleted file mode 100644 index e3645b0d0..000000000 --- a/client/src/Utils/monitorUtilsLegacy.js +++ /dev/null @@ -1,47 +0,0 @@ -import { capitalizeFirstLetter } from "./stringUtils"; - -/** - * Helper function to get duration since last check or the last date checked - * @param {Array} checks Array of check objects. - * @param {boolean} duration Whether the function should return the duration since last checked or the date itself - * @returns {number} Timestamp of the most recent check. - */ -export const getLastChecked = (checks, duration = true) => { - if (!checks || checks.length === 0) { - return 0; // Handle case when no checks are available - } - - // Data is sorted newest -> oldest, so newest check is the most recent - if (!duration) { - return new Date(checks[0].createdAt); - } - return new Date() - new Date(checks[0].createdAt); -}; - -export const parseDomainName = (url) => { - url = url.replace(/^https?:\/\//, ""); - // Remove leading/trailing dots - url = url.replace(/^\.+|\.+$/g, ""); - // Split by dots - const parts = url.split("."); - // Remove common prefixes and empty parts and exclude the last element of the array (the last element should be the TLD) - const cleanParts = parts.filter((part) => part !== "www" && part !== "").slice(0, -1); - // If there's more than one part, append the two words and capitalize the first letters (e.g. ["api", "test"] -> "Api Test") - const domainPart = - cleanParts.length > 1 - ? cleanParts.map((part) => capitalizeFirstLetter(part)).join(" ") - : capitalizeFirstLetter(cleanParts[0]); - - if (domainPart) return domainPart; - - return url; -}; - -export const TypeToPathMap = { - http: "uptime", - port: "uptime", - docker: "uptime", - ping: "uptime", - hardware: "infrastructure", - pagespeed: "pagespeed", -}; diff --git a/client/src/Utils/roleUtils.js b/client/src/Utils/roleUtils.js deleted file mode 100644 index f359246b2..000000000 --- a/client/src/Utils/roleUtils.js +++ /dev/null @@ -1,13 +0,0 @@ -export const ROLES = { - SUPERADMIN: "superadmin", - ADMIN: "admin", - USER: "user", - DEMO: "demo", -}; - -export const VALID_ROLES = [ROLES.ADMIN, ROLES.USER, ROLES.DEMO]; - -export const EDITABLE_ROLES = [ - { role: ROLES.ADMIN, _id: ROLES.ADMIN }, - { role: ROLES.USER, _id: ROLES.USER }, -]; diff --git a/client/src/Utils/stringUtils.js b/client/src/Utils/stringUtils.js deleted file mode 100644 index bb62d0169..000000000 --- a/client/src/Utils/stringUtils.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Helper function to get first letter capitalized string - * @param {string} str String whose first letter is to be capitalized - * @returns A string with first letter capitalized - */ -export const capitalizeFirstLetter = (str) => { - if (str === null || str === undefined) { - return ""; - } - if (typeof str !== "string") { - throw new TypeError("Input must be a string"); - } - if (str.length === 0) { - return ""; - } - return str.charAt(0).toUpperCase() + str.slice(1); -}; - -/** - * Helper function to get first letter as a lower case string - * @param {string} str String whose first letter is to be lower cased - * @returns A string with first letter lower cased - */ - -export const toLowerCaseFirstLetter = (str) => { - if (str === null || str === undefined) { - return ""; - } - if (typeof str !== "string") { - throw new TypeError("Input must be a string"); - } - if (str.length === 0) { - return ""; - } - return str.charAt(0).toLowerCase() + str.slice(1); -}; - -/** - * Checks if a string is null, undefined, or empty (including strings with only whitespace). - * @param {string} str - The string to check. - * @returns {boolean} - Returns true if the string is null, undefined, or empty. - */ -export const isEmpty = (str) => { - // Check if string is null, undefined, or empty (including whitespace only) - return str === null || typeof str === "undefined" || str.trim().length === 0; -}; diff --git a/client/src/Utils/timeUtilsLegacy.js b/client/src/Utils/timeUtilsLegacy.js deleted file mode 100644 index 365572f6b..000000000 --- a/client/src/Utils/timeUtilsLegacy.js +++ /dev/null @@ -1,167 +0,0 @@ -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -import customParseFormat from "dayjs/plugin/customParseFormat"; - -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(customParseFormat); -dayjs.extend(duration); - -export const MS_PER_SECOND = 1000; -export const MS_PER_MINUTE = 60 * MS_PER_SECOND; -export const MS_PER_HOUR = 60 * MS_PER_MINUTE; -export const MS_PER_DAY = 24 * MS_PER_HOUR; -export const MS_PER_WEEK = MS_PER_DAY * 7; - -export const formatDuration = (ms) => { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - let dateStr = ""; - - days && (dateStr += `${days}d `); - hours && (dateStr += `${hours % 24}h `); - minutes && (dateStr += `${minutes % 60}m `); - seconds && (dateStr += `${seconds % 60}s `); - - dateStr === "" && (dateStr = "0s"); - - return dateStr; -}; - -export const formatDurationRounded = (ms) => { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - let time = ""; - if (days > 0) { - time += `${days} day${days !== 1 ? "s" : ""}`; - return time; - } - if (hours > 0) { - time += `${hours} hour${hours !== 1 ? "s" : ""}`; - return time; - } - if (minutes > 0) { - time += `${minutes} minute${minutes !== 1 ? "s" : ""}`; - return time; - } - if (seconds > 0) { - time += `${seconds} second${seconds !== 1 ? "s" : ""}`; - return time; - } - - return time; -}; - -export const formatDurationSplit = (ms) => { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - return days > 0 - ? { time: days, format: days === 1 ? "day" : "days" } - : hours > 0 - ? { time: hours, format: hours === 1 ? "hour" : "hours" } - : minutes > 0 - ? { time: minutes, format: minutes === 1 ? "minute" : "minutes" } - : seconds > 0 - ? { time: seconds, format: seconds === 1 ? "second" : "seconds" } - : { time: 0, format: "seconds" }; -}; - -export const getHumanReadableDuration = (ms) => { - const durationObj = dayjs.duration(ms); - - const parts = { - days: Math.floor(durationObj.asDays()), - hours: durationObj.hours(), - minutes: durationObj.minutes(), - seconds: durationObj.seconds(), - milliseconds: durationObj.milliseconds(), - }; - - const result = []; - - if (parts.days > 0) { - result.push(`${parts.days}d`); - } - if (parts.hours > 0) { - result.push(`${parts.hours}h`); - } - if (result.length < 2 && parts.minutes > 0) { - result.push(`${parts.minutes}m`); - } - if (result.length < 2 && parts.seconds > 0) { - result.push(`${parts.seconds}s`); - } - if (result.length < 2 && parts.milliseconds > 0 && parts.seconds < 1) { - result.push(`${parts.milliseconds.toFixed(2)}ms`); - } - - if (result.length === 0) { - // fallback for durations < 1s - return "0s"; - } - - return result.join(" "); -}; - -export const formatDate = (date, customOptions) => { - const options = { - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - ...customOptions, - }; - - // Return the date using the specified options - return date - .toLocaleString("en-US", options) - .replace(/\b(AM|PM)\b/g, (match) => match.toLowerCase()); -}; - -export const formatDateWithTz = (timestamp, format, timezone) => { - const formattedDate = dayjs(timestamp).tz(timezone).format(format); - return formattedDate; -}; - -export const tickDateFormatLookup = (range) => { - switch (range) { - case "recent": - return "h:mm A"; - case "day": - return "h A"; - case "week": - return "MMM D"; - case "month": - return "MMM D"; - default: - return "MMM D, h A"; - } -}; - -export const tooltipDateFormatLookup = (range) => { - switch (range) { - case "recent": - return "MMM D, h:mm A"; - case "day": - return "MMM D, h:mm A"; - case "week": - return "ddd, MMM D"; - case "month": - return "ddd, MMM D"; - default: - return "MMM D, h:mm A"; - } -}; diff --git a/client/src/Utils/utils.js b/client/src/Utils/utils.js deleted file mode 100644 index 81ec44b06..000000000 --- a/client/src/Utils/utils.js +++ /dev/null @@ -1,15 +0,0 @@ -export const safelyParseFloat = (value) => { - const parsedValue = parseFloat(value); - if (isNaN(parsedValue)) { - return 0; - } - 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/Validation/error.js b/client/src/Validation/error.js deleted file mode 100644 index 5738edf40..000000000 --- a/client/src/Validation/error.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Update errors if passed id matches the error.details[0].path, otherwise remove - * the error for the id - * @param {*} prev Previous errors * - * @param {*} id ID of the field whose error is to be either updated or removed - * @param {*} error the error object - * @returns the Update Errors with the specific field with id being either removed or updated - */ - -const buildErrors = (prev, id, error) => { - const updatedErrors = { ...prev }; - if (error && id == error.details[0].path) { - updatedErrors[id] = error.details[0].message ?? "Validation error"; - } else { - delete updatedErrors[id]; - } - return updatedErrors; -}; - -/** - * Processes Joi validation errors and returns a filtered object of error messages for fields that have been touched. - * - * @param {Object} validation - The Joi validation result object. - * @param {Object} validation.error - The error property of the validation result containing details of validation failures. - * @param {Object[]} validation.error.details - An array of error details from the Joi validation. Each item contains information about the path and the message. - * @param {Object} touchedErrors - An object representing which fields have been interacted with. Keys are field IDs (field names), and values are booleans indicating whether the field has been touched. - * @returns {Object} - An object where keys are the field IDs (if they exist in `touchedErrors` and are in the error details) and values are their corresponding error messages. - */ -const getTouchedFieldErrors = (validation, touchedErrors) => { - let newErrors = {}; - - if (validation?.error) { - newErrors = validation.error.details.reduce((errors, detail) => { - const fieldId = detail.path[0]; - if (touchedErrors[fieldId] && !(fieldId in errors)) { - errors[fieldId] = detail.message; - } - return errors; - }, {}); - } - - return newErrors; -}; -/** - * - * @param {*} form The form object of the submitted form data - * @param {*} validation The Joi validation rules - * @param {*} setErrors The function used to set the local errors - * @returns true if there is no error or false if there is error after validating the form - * the error will be reset to {} if returns false; otherwise the errors object will be set with - * the new value - */ -const hasValidationErrors = (form, validation, setErrors) => { - const { error } = validation.validate(form, { - abortEarly: false, - }); - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - if ( - ![ - "clientHost", - "refreshTokenSecret", - "dbConnectionString", - "refreshTokenTTL", - "jwtTTL", - "notify-email-list", - "_id", - "__v", - "createdAt", - "updatedAt", - ].includes(err.path[0]) - ) { - newErrors[err.path[0]] = err.message ?? "Validation error"; - } - // Handle conditionally usage number required cases - if (!form.cpu || form.usage_cpu) { - newErrors["usage_cpu"] = null; - } - if (!form.memory || form.usage_memory) { - newErrors["usage_memory"] = null; - } - if (!form.disk || form.usage_disk) { - newErrors["usage_disk"] = null; - } - if (!form.temperature || form.usage_temperature) { - newErrors["usage_temperature"] = null; - } - }); - if (Object.values(newErrors).some((v) => v)) { - setErrors(newErrors); - return true; - } else { - setErrors({}); - return false; - } - } - setErrors({}); - return false; -}; -export { buildErrors, hasValidationErrors, getTouchedFieldErrors }; diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 27d585074..fa92f2e99 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -1,6 +1,18 @@ import joi from "joi"; import dayjs from "dayjs"; -import { ROLES } from "../Utils/roleUtils"; +export const ROLES = { + SUPERADMIN: "superadmin", + ADMIN: "admin", + USER: "user", + DEMO: "demo", +}; + +export const VALID_ROLES = [ROLES.ADMIN, ROLES.USER, ROLES.DEMO]; + +export const EDITABLE_ROLES = [ + { role: ROLES.ADMIN, _id: ROLES.ADMIN }, + { role: ROLES.USER, _id: ROLES.USER }, +]; const THRESHOLD_COMMON_BASE_MSG = "Threshold must be a number."; diff --git a/client/src/main.tsx b/client/src/main.tsx index 1e339f87b..f075886d3 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -5,7 +5,7 @@ import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; import { persistor, store } from "@/store.js"; import { PersistGate } from "redux-persist/integration/react"; -import I18nLoader from "./Components/v1/I18nLoader/index.jsx"; +import I18nLoader from "./Components/v2/i18nLoader"; import { initApiClient } from "./Utils/ApiClient.js"; initApiClient(store); From f600647ff0f693f79023c1298424c6f8192eeb09 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 19:16:16 +0000 Subject: [PATCH 287/392] fix ts error --- client/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 1004131e3..a8a25669e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, type CSSProperties } from "react"; +import { type CSSProperties } from "react"; import { useSelector } from "react-redux"; import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; From 8089e71f96c41fb0b5029fdcc8cb420881c721e1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 19:42:58 +0000 Subject: [PATCH 288/392] warn instead of throw on failure to build check --- .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 6cd1a7675..6c9e9264a 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -86,7 +86,15 @@ class SuperSimpleQueueHelper { // Step 3. Build check const check = await this.checkService.buildCheck(status); - + if (!check) { + this.logger.warn({ + message: `No check could be built for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getMonitorJob", + details: { code: status.code, message: status.message }, + }); + return; + } // Step 4 Add check to buffer this.buffer.addToBuffer({ check }); // Step 4. Update monitor status From f337ca5ff04c7c281d3e0a58883778812bdb89f6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 22:37:40 +0000 Subject: [PATCH 289/392] initial commit --- server/src/config/controllers.ts | 2 +- server/src/config/services.ts | 14 +--------- server/src/controllers/settingsController.ts | 4 +-- .../monitor-stats/IMonitorStatsRepository.ts | 1 + .../MongoMonitorStatsRepository.ts | 5 ++++ .../service/infrastructure/bufferService.ts | 12 ++++++--- .../service/infrastructure/statusService.ts | 27 ++++++++++++------- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 7a20ed93c..8a83a0f63 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -30,7 +30,7 @@ export const initializeControllers = (services: InitializedServices): Initialize return { authController: new AuthController(services.userService), monitorController: new MonitorController(services.monitorService), - settingsController: new SettingsController(services.settingsService, services.emailService, services.db), + settingsController: new SettingsController(services.settingsService, services.emailService), checkController: new CheckController(services.checkService), inviteController: new InviteController(services.inviteService), maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService), diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 9bf18ff1d..f88bc2584 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -41,18 +41,6 @@ import crypto from "crypto"; import { games, GameDig } from "gamedig"; import jmespath from "jmespath"; -// DB Modules -import { GenerateAvatarImage } from "../utils/imageProcessing.js"; -import { ParseBoolean } from "../utils/utils.js"; - -// Models -import InviteToken from "../db/models/Invite.js"; -import Team from "../db/models/Team.js"; -import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; -import MonitorStats from "../db/models/MonitorStats.js"; -import NotificationModel from "../db/models/Notification.js"; -import RecoveryToken from "../db/models/RecoveryToken.js"; - // repositories import { MongoMonitorsRepository, @@ -176,7 +164,7 @@ export const initializeServices = async ({ const bufferService = new BufferService({ logger, checkService, settingsService }); - const statusService = new StatusService({ logger, buffer: bufferService, monitorsRepository }); + const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository); const webhookProvider = new WebhookProvider(logger); const slackProvider = new SlackProvider(logger); diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 0a01daa0c..3cbfd3f6d 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -8,11 +8,9 @@ class SettingsController { static SERVICE_NAME = SERVICE_NAME; private settingsService: any; private emailService: any; - private db: any; - constructor(settingsService: any, emailService: any, db: any) { + constructor(settingsService: any, emailService: any) { this.settingsService = settingsService; this.emailService = emailService; - this.db = db; } get serviceName() { diff --git a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts index 08309b4ee..c718e7eb3 100644 --- a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts @@ -1,6 +1,7 @@ import type { MonitorStats } from "@/types/index.js"; export interface IMonitorStatsRepository { // create + create(data: Omit): Promise; // single fetch findByMonitorId(monitorId: string): Promise; // update diff --git a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts index 52dbe8aa5..7211c7d89 100644 --- a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts @@ -32,6 +32,11 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository { }; }; + create = async (data: Omit): Promise => { + const created = await MonitorStatsModel.create(data); + return this.toEntity(created); + }; + findByMonitorId = async (monitorId: string): Promise => { const monitorStats = await MonitorStatsModel.findOne({ monitorId: new mongoose.Types.ObjectId(monitorId) }); if (!monitorStats) { diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 461001800..415c22281 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -2,7 +2,14 @@ import type { Check } from "@/types/index.js"; const SERVICE_NAME = "BufferService"; -class BufferService { +export interface IBufferService { + addToBuffer(check: Check): void; + removeCheckFromBuffer(check: Check): boolean; + scheduleNextFlush(): void; + flushBuffer(): Promise; +} + +class BufferService implements IBufferService { static SERVICE_NAME = SERVICE_NAME; private BUFFER_TIMEOUT: number; private logger: any; @@ -13,7 +20,6 @@ class BufferService { constructor({ logger, checkService, settingsService }: { logger: any; checkService: any; settingsService: any }) { this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute - console.log(this.BUFFER_TIMEOUT); this.logger = logger; this.checksService = checkService; this.SERVICE_NAME = SERVICE_NAME; @@ -30,7 +36,7 @@ class BufferService { return BufferService.SERVICE_NAME; } - addToBuffer({ check }: { check: Check }) { + addToBuffer(check: Check) { try { this.buffer.push(check); } catch (error: any) { diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 284fd3ff7..66b12a315 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -1,6 +1,4 @@ -import { IMonitorsRepository } from "@/repositories/index.js"; -import MonitorStats from "../../db/models/MonitorStats.js"; -import { CheckModel } from "@/db/models/index.js"; +import { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository } from "@/repositories/index.js"; import type { Monitor, MonitorStatus, @@ -10,7 +8,9 @@ import type { HardwareStatusPayload, PageSpeedStatusPayload, CheckSnapshot, + MonitorStats, } from "@/types/index.js"; +import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "StatusService"; export interface IStatusService { @@ -27,11 +27,21 @@ export class StatusService implements IStatusService { private logger: any; private buffer: any; private monitorsRepository: IMonitorsRepository; + private monitorStatsRepository: IMonitorStatsRepository; + private checksRepository: IChecksRepository; - constructor({ logger, buffer, monitorsRepository }: { logger: any; buffer: any; monitorsRepository: IMonitorsRepository }) { + constructor( + logger: ILogger, + buffer: any, + monitorsRepository: IMonitorsRepository, + monitorStatsRepository: IMonitorStatsRepository, + checksRepository: IChecksRepository + ) { this.logger = logger; this.buffer = buffer; this.monitorsRepository = monitorsRepository; + this.monitorStatsRepository = monitorStatsRepository; + this.checksRepository = checksRepository; } get serviceName() { @@ -42,18 +52,17 @@ export class StatusService implements IStatusService { try { const monitorId = monitor.id; const { responseTime, status } = networkResponse; - // Get stats - let stats = await MonitorStats.findOne({ monitorId }); + let stats = await this.monitorStatsRepository.findByMonitorId(monitorId); if (!stats) { - stats = new MonitorStats({ + stats = { monitorId, avgResponseTime: 0, totalChecks: 0, totalUpChecks: 0, totalDownChecks: 0, uptimePercentage: 0, - lastCheck: null, - }); + lastResponseTime: 0, + }; } // Update stats From b6b1b8464c9e871c42dd3c610cd196d529dcc312 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 23:03:16 +0000 Subject: [PATCH 290/392] remove old DB references --- server/src/repositories/checks/IChecksRepository.ts | 1 + .../src/repositories/checks/MongoChecksRepistory.ts | 5 +++++ server/src/service/infrastructure/statusService.ts | 11 +++++------ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/repositories/checks/IChecksRepository.ts index 74def03c2..b75b18f13 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/repositories/checks/IChecksRepository.ts @@ -11,6 +11,7 @@ import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory export interface IChecksRepository { // create + create(check: Check): Promise; createChecks(checks: Check[]): Promise; // single fetch diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index a36788e2c..c9cdbffd6 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -227,6 +227,11 @@ class MongoChecksRepository implements IChecksRepository { } as unknown as CheckDocument; }; + create = async (check: Check) => { + const savedCheck = await CheckModel.create(check); + return this.toEntity(savedCheck); + }; + createChecks = async (checks: Check[]) => { const docs = checks.map((check) => this.toDocument(check)); const inserted = await CheckModel.insertMany(docs); diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 66b12a315..de8fb9963 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -52,7 +52,7 @@ export class StatusService implements IStatusService { try { const monitorId = monitor.id; const { responseTime, status } = networkResponse; - let stats = await this.monitorStatsRepository.findByMonitorId(monitorId); + let stats: Omit = await this.monitorStatsRepository.findByMonitorId(monitorId); if (!stats) { stats = { monitorId, @@ -62,6 +62,7 @@ export class StatusService implements IStatusService { totalDownChecks: 0, uptimePercentage: 0, lastResponseTime: 0, + lastCheckTimestamp: 0, }; } @@ -105,8 +106,7 @@ export class StatusService implements IStatusService { // latest check stats.lastCheckTimestamp = new Date().getTime(); - - await stats.save(); + await this.monitorStatsRepository.create(stats); return true; } catch (error: any) { this.logger.error({ @@ -121,11 +121,10 @@ export class StatusService implements IStatusService { handleIncidentForCheck = async (check: Check, monitor: Monitor, action: any, errorContext = "incident handling") => { try { - let savedCheck; + let savedCheck: Check | null = null; if (!check.id) { try { - const checkModel = new CheckModel(check); - savedCheck = await checkModel.save(); + savedCheck = await this.checksRepository.create(check); this.buffer.removeCheckFromBuffer(check); } catch (checkError: any) { From 5ab5cbe622a35dcffa52194b433e7a2c7140b280 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 23:31:37 +0000 Subject: [PATCH 291/392] status box fix --- client/package-lock.json | 8 ++++--- .../v2/design-elements/StatusBox.tsx | 24 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 84fc4c3a6..038814543 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2472,11 +2472,13 @@ } }, "node_modules/axios": { - "version": "1.13.4", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/v2/design-elements/StatusBox.tsx index a83ebcf5d..1be76988b 100644 --- a/client/src/Components/v2/design-elements/StatusBox.tsx +++ b/client/src/Components/v2/design-elements/StatusBox.tsx @@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; import { BaseBox } from "@/Components/v2/design-elements"; -import Background from "@/assets/Images/background-grid.svg?react"; import { useTranslation } from "react-i18next"; import type { SxProps } from "@mui/material"; @@ -18,6 +17,7 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => { return ( { > - - + top={0} + left={0} + right={0} + bottom={0} + sx={{ + pointerEvents: "none", + backgroundImage: ` + linear-gradient(${theme.palette.divider} 1px, transparent 1px), + linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px) + `, + backgroundSize: "24px 24px", + maskImage: + "linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)", + WebkitMaskImage: + "linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)", + }} + /> {children} ); From afe2b9d3bbfbb3e6809d039770daed898d1daf09 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Feb 2026 23:37:08 +0000 Subject: [PATCH 292/392] remove unused images --- client/src/assets/Images/Google.png | Bin 853 -> 0 bytes .../src/assets/Images/avatar_placeholder.png | Bin 5546 -> 0 bytes client/src/assets/Images/background-grid.svg | 67 ------------------ .../assets/Images/create-placeholder-dark.svg | 36 ---------- .../src/assets/Images/create-placeholder.svg | 29 -------- client/src/assets/Images/data_placeholder.svg | 19 ----- .../assets/Images/data_placeholder_dark.svg | 19 ----- .../Images/jupiter_logo_banner_dark.svg | 35 --------- .../Images/jupiter_logo_banner_light.svg | 35 --------- client/src/assets/Images/logo_placeholder.svg | 5 -- .../assets/Images/solana_logo_banner_dark.svg | 24 ------- .../Images/solana_logo_banner_light.svg | 49 ------------- 12 files changed, 318 deletions(-) delete mode 100644 client/src/assets/Images/Google.png delete mode 100644 client/src/assets/Images/avatar_placeholder.png delete mode 100644 client/src/assets/Images/background-grid.svg delete mode 100644 client/src/assets/Images/create-placeholder-dark.svg delete mode 100644 client/src/assets/Images/create-placeholder.svg delete mode 100644 client/src/assets/Images/data_placeholder.svg delete mode 100644 client/src/assets/Images/data_placeholder_dark.svg delete mode 100644 client/src/assets/Images/jupiter_logo_banner_dark.svg delete mode 100644 client/src/assets/Images/jupiter_logo_banner_light.svg delete mode 100644 client/src/assets/Images/logo_placeholder.svg delete mode 100644 client/src/assets/Images/solana_logo_banner_dark.svg delete mode 100644 client/src/assets/Images/solana_logo_banner_light.svg diff --git a/client/src/assets/Images/Google.png b/client/src/assets/Images/Google.png deleted file mode 100644 index 490f4a20138afe2c3aecfbacaf00da91f4086cbb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 853 zcmV-b1FHOqP)P000;W1^@s654Bdt00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yPk==P+9lWnZ|dss+$i|$vJS6K&KLTfWdichvkQxGfK`A$x?S@7+-^M zXb{!#(@uL-)ULMJ9ZCNife9{BEk9tF`br0d2nH&6!90L!xdR@?l?TfxI8UCMnoHS@ z_O@%OD-y@ih?8g2H~@ikXzJ7VQhB!Pe4K)iunNM3pp0ZWc5cB~sS?Yb~~j~(Hi05 zVp~YLBOhpX(z9)m8qB#XP>5q$ilri^>7gQtnMld}9^O0)n1Q~12eE0=@^!|EBZeBl8 zt|b@~UWmG$ZX|J4al4!oAzdKbMca5^FX1rZPjK*&~NBRfg3XW}tU@_p?B1FV}-Yb?s?lM#_-!AIqn3iwNq(bTe z5-XyyzBIP(N_UDYC~kM{k4M;Xk4H0)8}_=gU{}n({vtQU&Y1iX91_g8l`{h0`acd{ zD{K3%KN69bu`pw70u@B?0K6&F2Ift%<*g#gK3Vh<{u1?~YM2uUlR_TMGl1KwFajC2 fX^c7@+#>z}qn;xd!^AiT00000NkvXXu0mjf;7fKm diff --git a/client/src/assets/Images/avatar_placeholder.png b/client/src/assets/Images/avatar_placeholder.png deleted file mode 100644 index 38e896811951f0ccf5799097a774793eddb644c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5546 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/create-placeholder-dark.svg b/client/src/assets/Images/create-placeholder-dark.svg deleted file mode 100644 index 2dd68750b..000000000 --- a/client/src/assets/Images/create-placeholder-dark.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/create-placeholder.svg b/client/src/assets/Images/create-placeholder.svg deleted file mode 100644 index bdd9ef945..000000000 --- a/client/src/assets/Images/create-placeholder.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/data_placeholder.svg b/client/src/assets/Images/data_placeholder.svg deleted file mode 100644 index bf840b95f..000000000 --- a/client/src/assets/Images/data_placeholder.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/data_placeholder_dark.svg b/client/src/assets/Images/data_placeholder_dark.svg deleted file mode 100644 index 7e2a811d5..000000000 --- a/client/src/assets/Images/data_placeholder_dark.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/jupiter_logo_banner_dark.svg b/client/src/assets/Images/jupiter_logo_banner_dark.svg deleted file mode 100644 index 3d3e23b14..000000000 --- a/client/src/assets/Images/jupiter_logo_banner_dark.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/jupiter_logo_banner_light.svg b/client/src/assets/Images/jupiter_logo_banner_light.svg deleted file mode 100644 index 4744e0bcb..000000000 --- a/client/src/assets/Images/jupiter_logo_banner_light.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/logo_placeholder.svg b/client/src/assets/Images/logo_placeholder.svg deleted file mode 100644 index 82f307b2d..000000000 --- a/client/src/assets/Images/logo_placeholder.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/client/src/assets/Images/solana_logo_banner_dark.svg b/client/src/assets/Images/solana_logo_banner_dark.svg deleted file mode 100644 index 08ec87cb1..000000000 --- a/client/src/assets/Images/solana_logo_banner_dark.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/Images/solana_logo_banner_light.svg b/client/src/assets/Images/solana_logo_banner_light.svg deleted file mode 100644 index 774018a6a..000000000 --- a/client/src/assets/Images/solana_logo_banner_light.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 974d33b3dea55118b52c5ea032bbb9411e319dbe Mon Sep 17 00:00:00 2001 From: mannilakash Date: Fri, 13 Feb 2026 10:32:52 -0500 Subject: [PATCH 293/392] enhancement: add average and max response time for histogram --- .../common/charts/HistogramResponseTime.tsx | 209 ++++++++++++------ client/src/locales/en.json | 4 + 2 files changed, 148 insertions(+), 65 deletions(-) diff --git a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx index e3b82cd50..c3c68643f 100644 --- a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx @@ -1,102 +1,181 @@ import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import type { ResponsiveStyleValue } from "@mui/system"; import type { CheckSnapshot } from "@/Types/Check"; import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; interface HistogramResponseTimeProps { checks: CheckSnapshot[]; height?: ResponsiveStyleValue; gap?: ResponsiveStyleValue; + showStats?: boolean; + statsPosition?: "left" | "right"; +} + +interface ResponseTimeStats { + max: number; + avg: number; } const DEFAULT_HEIGHT = 50; +const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => { + if (!Array.isArray(checks) || checks.length === 0) { + return { max: 0, avg: 0 }; + } + + const validChecks = checks.filter( + (check) => + (check as any).status !== "placeholder" && + (check.originalResponseTime != null || check.responseTime != null) + ); + + if (validChecks.length === 0) { + return { max: 0, avg: 0 }; + } + + const responseTimes = validChecks.map( + (check) => check.originalResponseTime ?? check.responseTime ?? 0 + ); + + const max = Math.max(...responseTimes); + const avg = Math.round( + responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length + ); + + return { max, avg }; +}; + export const HistogramResponseTime = ({ checks, height = DEFAULT_HEIGHT, gap, + showStats = true, + statsPosition = "right", }: HistogramResponseTimeProps) => { const theme = useTheme(); + const { t } = useTranslation(); + + const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]); if (!Array.isArray(checks) || checks.length === 0) return null; - let data = Array(); + const data = + checks.length !== 25 + ? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })] + : checks; - if (!checks || checks.length === 0) { - data = []; - } - if (checks.length !== 25) { - const placeholders = Array(25 - checks.length).fill({ - status: "placeholder", - }); - data = [...checks, ...placeholders]; - } else { - data = checks; - } const chartHeight = typeof height === "number" ? `${height}px` : height; const gridGap = gap ?? theme.spacing(0.5); - return ( - - 0 || stats.avg > 0) && ( + + - {data.map((check, index) => { - const isPlaceholder = (check as any).status === "placeholder"; - const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`; - const barColor = - check.status === true - ? theme.palette.success.light - : theme.palette.error.light; - const bar = ( - + {t("common.charts.histogram.avg", { value: stats.avg })} + + + {t("common.charts.histogram.max", { value: stats.max })} + + + ); + + return ( + + {statsPosition === "left" && statsContent} + + + {data.map((check, index) => { + const isPlaceholder = (check as any).status === "placeholder"; + const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`; + const barColor = + check.status === true + ? theme.palette.success.light + : theme.palette.error.light; + const bar = ( - - ); + > + + + ); - return isPlaceholder ? ( - - {bar} - - ) : ( - - {bar} - - ); - })} + return isPlaceholder ? ( + + {bar} + + ) : ( + + {bar} + + ); + })} + - + {statsPosition === "right" && statsContent} + ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d2a066c11..3b58827ca 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -231,6 +231,10 @@ "high": "High", "low": "Low", "uptime": "Uptime" + }, + "histogram": { + "avg": "Avg: {{value}} ms", + "max": "Max: {{value}} ms" } }, "dialogs": { From 39a449e5948dc65ce0d29216433499c4e10aaf0a Mon Sep 17 00:00:00 2001 From: mannilakash Date: Fri, 13 Feb 2026 11:14:25 -0500 Subject: [PATCH 294/392] fix: padding fix for stats text --- .../Components/v2/common/charts/HistogramResponseTime.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx index c3c68643f..75ae25001 100644 --- a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx @@ -78,8 +78,8 @@ export const HistogramResponseTime = ({ alignItems={statsPosition === "left" ? "flex-end" : "flex-start"} sx={{ minWidth: 70, - pr: statsPosition === "left" ? theme.spacing(2) : 0, - pl: statsPosition === "right" ? theme.spacing(2) : 0, + pr: statsPosition === "left" ? theme.spacing(8) : 0, + pl: statsPosition === "right" ? theme.spacing(8) : 0, }} > {t("common.charts.histogram.avg", { value: stats.avg })} @@ -99,7 +99,7 @@ export const HistogramResponseTime = ({ color: theme.palette.text.secondary, fontSize: "0.65rem", fontWeight: 500, - lineHeight: 1.4, + pt: theme.spacing(2), }} > {t("common.charts.histogram.max", { value: stats.max })} From dfc1733820d33dd9e1383b2957d5012827638dce Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 13 Feb 2026 18:51:29 +0000 Subject: [PATCH 295/392] remove icons --- client/src/assets/icons/discord-icon.svg | 8 -------- client/src/assets/icons/slack-icon.svg | 10 ---------- client/src/assets/icons/upt_logo.png | Bin 438683 -> 0 bytes client/src/assets/icons/zapier-icon.svg | 8 -------- 4 files changed, 26 deletions(-) delete mode 100644 client/src/assets/icons/discord-icon.svg delete mode 100644 client/src/assets/icons/slack-icon.svg delete mode 100644 client/src/assets/icons/upt_logo.png delete mode 100644 client/src/assets/icons/zapier-icon.svg diff --git a/client/src/assets/icons/discord-icon.svg b/client/src/assets/icons/discord-icon.svg deleted file mode 100644 index c03e8e127..000000000 --- a/client/src/assets/icons/discord-icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/client/src/assets/icons/slack-icon.svg b/client/src/assets/icons/slack-icon.svg deleted file mode 100644 index ff256fd4d..000000000 --- a/client/src/assets/icons/slack-icon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/client/src/assets/icons/upt_logo.png b/client/src/assets/icons/upt_logo.png deleted file mode 100644 index d443d0897b123ed3bbca27a609a28695b51a749c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 438683 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 z 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
@;qbI!nt+G-H%(O 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
Lk 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%0D_0*ziGcK1mvc+P$x%phHc^ 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˄HMh0AE297#X)t0VuO$iLWR0})thix?##WO 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 diff --git a/client/src/assets/icons/zapier-icon.svg b/client/src/assets/icons/zapier-icon.svg deleted file mode 100644 index 6ee0b5eca..000000000 --- a/client/src/assets/icons/zapier-icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file From 13251ca7c581d6e03bce6aa52505cffdd5dc8bfa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 13 Feb 2026 19:04:19 +0000 Subject: [PATCH 296/392] fix initializing stats --- server/src/service/infrastructure/statusService.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index de8fb9963..0ab47f85b 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -52,7 +52,18 @@ export class StatusService implements IStatusService { try { const monitorId = monitor.id; const { responseTime, status } = networkResponse; - let stats: Omit = await this.monitorStatsRepository.findByMonitorId(monitorId); + let stats: Omit | null = null; + stats = await this.monitorStatsRepository + .findByMonitorId(monitorId) + .then((result) => result) + .catch(() => { + this.logger.debug({ + service: SERVICE_NAME, + method: "updateRunningStats", + message: `No existing stats found for monitor ${monitorId}, initializing new stats.`, + }); + return null; + }); if (!stats) { stats = { monitorId, From bd941db771fdfbeeb63cb47527c74f151e6b4cc5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 13 Feb 2026 20:41:27 +0000 Subject: [PATCH 297/392] clean up strings --- scripts/flatten-locales.js | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 scripts/flatten-locales.js diff --git a/scripts/flatten-locales.js b/scripts/flatten-locales.js new file mode 100644 index 000000000..ba8d5d16c --- /dev/null +++ b/scripts/flatten-locales.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); + +function flattenKeys(obj, prefix = "", result = []) { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const newKey = prefix ? `${prefix}.${key}` : key; + + if ( + typeof obj[key] === "object" && + obj[key] !== null && + !Array.isArray(obj[key]) + ) { + flattenKeys(obj[key], newKey, result); + } else { + result.push(newKey); + } + } + } + return result; +} + +// Paths +const localesDir = path.join(__dirname, "../client/src/locales"); +const inputPath = path.join(localesDir, "en.json"); +const outputPath = path.join(localesDir, "keys.json"); + +try { + const jsonContent = fs.readFileSync(inputPath, "utf8"); + const jsonData = JSON.parse(jsonContent); + + const flattenedKeys = flattenKeys(jsonData); + flattenedKeys.sort(); + + fs.writeFileSync(outputPath, JSON.stringify(flattenedKeys, null, 2), "utf8"); + + console.log(`Keys written to: ${outputPath}`); +} catch (error) { + console.error(error.message); + process.exit(1); +} From fd2c54d3d3e2c8009ed53d33a0aaf7434625aa0f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 13 Feb 2026 20:42:26 +0000 Subject: [PATCH 298/392] remove unused --- .../src/Components/v2/inputs/ImageUpload.tsx | 10 +- .../src/Pages/Auth/SetNewPassword/index.tsx | 14 +- client/src/Pages/CreateMonitor/index.tsx | 90 +- .../Incidents/Components/DialogResolution.tsx | 9 +- .../Maintenance/MaintenanceWindowTable.tsx | 4 +- .../Components/HeaderStatusPageControls.tsx | 2 +- client/src/Pages/StatusPage/Status/index.tsx | 1 - client/src/Utils/MonitorUtils.ts | 37 - client/src/Validation/login.ts | 7 +- client/src/Validation/recovery.ts | 5 +- client/src/locales/en.json | 771 ++---------------- 11 files changed, 172 insertions(+), 778 deletions(-) diff --git a/client/src/Components/v2/inputs/ImageUpload.tsx b/client/src/Components/v2/inputs/ImageUpload.tsx index 6c9fc1783..0d0b77b40 100644 --- a/client/src/Components/v2/inputs/ImageUpload.tsx +++ b/client/src/Components/v2/inputs/ImageUpload.tsx @@ -38,11 +38,11 @@ export const ImageUpload = ({ const isValidType = accept.some((type) => file.type.includes(type)); const isValidSize = file.size <= maxSize; if (!isValidType) { - setLocalError(t("common.errors.invalidFileFormat")); + setLocalError(t("components.imageUpload.errors.invalidFileFormat")); return; } if (!isValidSize) { - setLocalError(t("common.errors.invalidFileSize")); + setLocalError(t("components.imageUpload.errors.invalidFileSize")); return; } setLocalError(null); @@ -137,15 +137,15 @@ export const ImageUpload = ({ color="primary" fontWeight={500} > - {t("common.imageUpload.clickToUpload")} + {t("components.imageUpload.clickToUpload")} {" "} - {t("common.imageUpload.orDragAndDrop")} + {t("components.imageUpload.orDragAndDrop")} - {accept.join(", ").toUpperCase()} • {t("common.imageUpload.maxSize")}{" "} + {accept.join(", ").toUpperCase()} • {t("components.imageUpload.maxSize")}{" "} {Math.round(maxSize / 1024 / 1024)}MB diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx index 09bd565f1..2e99be254 100644 --- a/client/src/Pages/Auth/SetNewPassword/index.tsx +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -97,27 +97,27 @@ const SetNewPasswordPage = () => { /> @@ -127,7 +127,7 @@ const SetNewPasswordPage = () => { fullWidth loading={loading} > - {t("auth.forgotPassword.buttons.resetPassword")} + {t("common.buttons.resetPassword")} { value={field.value ?? ""} onChange={(e) => field.onChange(Number(e.target.value) || 0)} type="number" - fieldLabel={t("portToMonitor")} - placeholder="5173" + fieldLabel={t("pages.createMonitor.form.general.option.port.label")} + placeholder={t( + "pages.createMonitor.form.general.option.port.placeholder" + )} fullWidth error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} @@ -351,10 +353,12 @@ const CreateMonitorPage = () => { )} /> @@ -617,11 +661,21 @@ const CreateMonitorPage = () => { "pages.createMonitor.form.advanced.option.matchMethod.label" )} > - {t("matchMethodOptions.equal")} - - {t("matchMethodOptions.include")} + + {t( + "pages.createMonitor.form.advanced.option.matchMethod.equal" + )} + + + {t( + "pages.createMonitor.form.advanced.option.matchMethod.include" + )} + + + {t( + "pages.createMonitor.form.advanced.option.matchMethod.regex" + )} - {t("matchMethodOptions.regex")} )} /> diff --git a/client/src/Pages/Incidents/Components/DialogResolution.tsx b/client/src/Pages/Incidents/Components/DialogResolution.tsx index 648af52a9..a102b1de3 100644 --- a/client/src/Pages/Incidents/Components/DialogResolution.tsx +++ b/client/src/Pages/Incidents/Components/DialogResolution.tsx @@ -46,10 +46,9 @@ export const DialogResolution = ({ return ( setComment(e.target.value)} fullWidth diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx index 42378bb67..876969d3d 100644 --- a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -196,13 +196,13 @@ export const MaintenanceWindowTable = ({ /> { setDeleteDialogOpen(false); setSelectedWindow(null); }} onConfirm={handleDelete} - confirmText={t("delete")} loading={deleteLoading} /> diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx index d738671ac..0e8f444c9 100644 --- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx +++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx @@ -64,7 +64,7 @@ export const HeaderStatusPageControls = ({ }, }} > - {t("publicLink")} + {t("components.headerStatusPageControls.publicLink")} diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 1b509507e..b1826b867 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -96,7 +96,6 @@ const StatusPageView = () => { }} /> )} - {t("statusPageStatusServiceStatus")} { ? `${strippedUrl.slice(0, maxLength)}…` : strippedUrl; }; - -export interface IStatusPageHeaderConfig { - paletteKey: PaletteKey; - message: string; -} -export const getStatusPageHeaderConfig = ( - monitors: Monitor[], - t: any -): IStatusPageHeaderConfig => { - if (!monitors || monitors.length === 0) { - return { paletteKey: "error", message: "No monitors available" }; - } - - const allUp = monitors.every((monitor) => monitor.status === "up"); - const anyDown = monitors.some((monitor) => monitor.status === "down"); - const allDown = monitors.every((monitor) => monitor.status === "down"); - - if (allUp) - return { - paletteKey: "success", - message: t("statusPage.details.statusHeader.allUp"), - }; - if (allDown) - return { - paletteKey: "error", - message: t("statusPage.details.statusHeader.allDown"), - }; - if (anyDown) - return { - paletteKey: "warning", - message: t("statusPage.details.statusHeader.anyDown"), - }; - return { - paletteKey: "warning", - message: t("statusPage.details.statusHeader.anyDown"), - }; -}; diff --git a/client/src/Validation/login.ts b/client/src/Validation/login.ts index 03eb8d4e2..f14463734 100644 --- a/client/src/Validation/login.ts +++ b/client/src/Validation/login.ts @@ -2,11 +2,10 @@ import { z } from "zod"; export const loginSchema = z.object({ email: z - .string() - .min(1, "auth.common.inputs.email.errors.empty") - .email("auth.common.inputs.email.errors.invalid") + .email("Please enter a valid email address") + .min(1, "Please enter your email address") .transform((val) => val.toLowerCase().trim()), - password: z.string().min(1, "auth.common.inputs.password.errors.empty"), + password: z.string().min(1, "Please enter your password"), }); export type LoginFormData = z.infer; diff --git a/client/src/Validation/recovery.ts b/client/src/Validation/recovery.ts index 356f28738..4a244ed66 100644 --- a/client/src/Validation/recovery.ts +++ b/client/src/Validation/recovery.ts @@ -2,9 +2,8 @@ import { z } from "zod"; export const recoverySchema = z.object({ email: z - .string() - .min(1, "auth.common.inputs.email.errors.empty") - .email("auth.common.inputs.email.errors.invalid") + .email("Please enter a valid email address") + .min(1, "Please enter your email address") .transform((val) => val.toLowerCase().trim()), }); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 7eb833308..935077a2b 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,189 +1,4 @@ { - "aboutus": "About Us", - "access": "Access", - "actions": "Actions", - "add": "Add", - "addMonitors": "Add monitors", - "advancedMatching": "Advanced matching", - "area": "Area", - "auth": { - "common": { - "errors": { - "validation": "Error validating data." - }, - "fields": { - "role": { - "errors": { - "min": "At least one role is required" - } - } - }, - "inputs": { - "email": { - "errors": { - "empty": "To continue, please enter your email address", - "invalid": "Please recheck validity of entered email address" - }, - "label": "Email", - "placeholder": "jordan.ellis@domain.com" - }, - "firstName": { - "errors": { - "empty": "Please enter your name", - "length": "Name must be less than 50 characters", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - }, - "label": "Name", - "placeholder": "Jordan" - }, - "lastName": { - "errors": { - "empty": "Please enter your surname", - "length": "Surname must be less than 50 characters", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - }, - "label": "Surname", - "placeholder": "Ellis" - }, - "password": { - "errors": { - "empty": "Please enter your password", - "length": "Password must be at least 8 characters long", - "lowercase": "Password must contain at least 1 lowercase letter", - "number": "Password must contain at least 1 number", - "special": "Password must contain at least 1 special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", - "uppercase": "Password must contain at least 1 uppercase letter" - }, - "label": "Password", - "rules": { - "length": { - "beginning": "Must be at least", - "highlighted": "8 characters long" - }, - "lowercase": { - "beginning": "Must contain at least", - "highlighted": "one lower character" - }, - "match": { - "beginning": "Passwords", - "highlighted": "must match" - }, - "number": { - "beginning": "Must contain at least", - "highlighted": "one number" - }, - "special": { - "beginning": "Must contain at least", - "highlighted": "one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" - }, - "uppercase": { - "beginning": "Must contain at least", - "highlighted": "one upper character" - } - } - }, - "passwordConfirm": { - "errors": { - "different": "Entered passwords don't match, so one of them is probably mistyped", - "empty": "Please enter your password again for confirmation (helps with typos)" - }, - "label": "Confirm password", - "placeholder": "Re-enter password to confirm" - } - }, - "navigation": { - "continue": "Continue" - }, - "passwordRules": { - "length": "Must be at least 8 characters long", - "special": "Must contain at least one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)", - "number": "Must contain at least one number", - "uppercase": "Must contain at least one upper character", - "lowercase": "Must contain at least one lower character", - "match": "Passwords must match" - } - }, - "forgotPassword": { - "buttons": { - "openEmail": "Open email app", - "resetPassword": "Reset password" - }, - "heading": "Forgot password?", - "links": { - "login": "Go back to Log In", - "resend": "Didn't receive the email? Click to resend" - }, - "subheadings": { - "stepFour": "Your password has been successfully reset. Click below to log in magically.", - "stepOne": "No worries, we'll send you reset instructions.", - "stepThree": "Your new password must be different from previously used passwords.", - "stepTwo": "We sent a password reset link to " - }, - "toasts": { - "emailNotFound": "Email not found.", - "error": "Unable to reset password. Please try again later or contact support.", - "redirect": "Redirecting in ...", - "sent": "Instructions sent to .", - "success": "Your password was reset successfully." - } - }, - "login": { - "errors": { - "password": { - "incorrect": "The password you provided does not match our records" - } - }, - "heading": "Log in to continue", - "links": { - "forgotPassword": "Forgot password?", - "forgotPasswordLink": "Reset password", - "register": "Do not have an account?", - "registerLink": "Register here" - }, - "toasts": { - "incorrectPassword": "Incorrect password", - "success": "Welcome back! You're successfully logged in." - }, - "welcome": "Welcome back to Checkmate!" - }, - "registration": { - "description": { - "superAdmin": "Create your super admin account to get started", - "user": "Sign up as a user and ask super admin for access to your monitors" - }, - "heading": { - "user": "Sign Up" - }, - "toasts": { - "success": "Welcome! Your account was created successfully." - }, - "welcome": "Welcome to Checkmate!" - } - }, - "avgCpuTemperature": "Average CPU Temperature", - "bar": "Bar", - "basicInformation": "Basic Information", - "bulkImport": { - "fallbackPage": "Import a file to upload a list of servers in bulk", - "invalidFileType": "Invalid file type", - "noFileSelected": "No file selected", - "selectFile": "Select File", - "selectFileDescription": "You can download our or sample", - "selectFileTips": "Select CSV file to upload", - "title": "Bulk Import", - "uploadFailed": "Upload failed", - "uploadSuccess": "Monitors created successfully!" - }, - "bytesSent": "Bytes Sent", - "addMember": "Add member", - "cancel": "Cancel", - "checkHooks": { - "failureResolveOne": "Failed to resolve incident." - }, - "chooseGame": "Choose game", - "city": "CITY", - "ClickUpload": "Click to upload", - "close": "Close", "common": { "auth": { "roles": { @@ -222,7 +37,8 @@ "testNotifications": "Test notifications", "toggleTheme": "Toggles light & dark", "flushQueue": "Flush queue", - "notFound": "Go to the main dashboard" + "notFound": "Go to the main dashboard", + "resetPassword": "Reset password" }, "charts": { "labels": { @@ -290,28 +106,23 @@ "interval": "Interval", "active": "Active" } - }, - "toasts": { - "checkConnection": "Please check your connection", - "networkError": "Network error", - "unknownError": "Unknown error" - }, + } + }, + "components": { "imageUpload": { "clickToUpload": "Click to upload", "dragAndDrop": "or drag and drop", "supportedFormats": "Supported formats", "maxSize": "Max size", - "orDragAndDrop": "or drag and drop" + "orDragAndDrop": "or drag and drop", + "errors": { + "invalidFileSize": "File size is too large!", + "invalidFileFormat": "Unsupported file format!" + } + }, + "headerStatusPageControls": { + "publicLink": "Public link" }, - "errors": { - "invalidFileType": "Invalid file type", - "fileTooLarge": "File too large" - } - }, - "commonSave": "Save", - "commonSaving": "Saving...", - "companyName": "Company name", - "components": { "headerTimeRange": { "labels": { "day": "Day", @@ -376,229 +187,7 @@ "description": "See the latest releases and help grow the community on GitHub" } }, - "configure": "Configure", - "confirmPassword": "Confirm password", - "cores": "Cores", - "cpu": "CPU", - "cpuFrequency": "CPU Frequency", - "cpuLogical": "CPU (Logical)", - "cpuPhysical": "CPU (Physical)", - "cpuTemperature": "CPU Temperature", - "cpuUsage": "CPU usage", - "createA": "Create a", - "createMaintenance": "Create maintenance", - "createMaintenanceWindow": "Create maintenance window", - "createMonitor": "Create monitor", - "createNew": "Create new", - "delete": "Delete", - "generateToken": "Generate token", - "DeleteAccountButton": "Remove account", - "DeleteAccountTitle": "Remove account", - "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", - "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "deleteStatusPage": "Do you want to delete this status page?", - "deleteStatusPageConfirm": "Yes, delete status page", - "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", - "DeleteWarningTitle": "Really remove this account?", - "details": "Details", - "device": "Device", - "diagnosticsPage": { - "diagnosticDescription": "System diagnostics", - "gauges": { - "heapAllocationSubtitle": "% of available memory", - "heapAllocationTitle": "Heap allocation", - "heapUsageSubtitle": "% of available memory", - "heapUsageTitle": "Heap usage", - "heapUtilizationSubtitle": "% of allocated", - "heapUtilizationTitle": "Heap utilization", - "instantCpuUsageSubtitle": "% of 1s used by CPU", - "instantCpuUsageTitle": "Instant CPU usage" - }, - "stats": { - "eventLoopDelayTitle": "Event loop delay", - "osMemoryLimitTitle": "OS Memory Limit", - "totalHeapSizeTitle": "Total heap size", - "uptimeTitle": "Uptime", - "usedHeapSizeTitle": "Used heap size" - } - }, - "disk": "Disk", - "diskUsage": "Disk Usage", - "displayName": "Display name", - "download": "Download", - "DragandDrop": "drag and drop", - "duration": "Duration", - "edit": "Edit", - "editing": "Editing...", - "editMaintenance": "Edit maintenance", - "editUserPage": { - "form": { - "email": "Email", - "firstName": "First name", - "lastName": "Last name", - "role": "Roles", - "save": "Save" - }, - "table": { - "actionHeader": "Action", - "roleHeader": "Role" - }, - "title": "Edit user", - "toast": { - "successUserUpdate": "User updated successfully", - "validationErrors": "Validation errors" - } - }, - "EmailDescriptionText": "This is your current email address — it cannot be changed.", - "errorPages": { - "serverUnreachable": { - "alertBox": "Server Connection Error", - "description": "We're unable to connect to the server. Please check your internet connection or verify your deployment configuration if the problem persists.", - "retryButton": { - "default": "Retry connection", - "processing": "Connecting..." - }, - "toasts": { - "reconnected": "Successfully reconnected to the server.", - "stillUnreachable": "Server is still unreachable. Please try again later." - } - } - }, - "errors": "Errors", - "expectedValue": "Expected value", - "failedToSendEmail": "Failed to send email", - "FirstName": "First name", - "frequency": "Frequency", - "friendlyNameInput": "Friendly name", - "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", - "gb": "GB", - "general": { - "noOptionsFound": "No {{unit}} found" - }, - "greeting": { - "append": "The afternoon is your playground—let's make it epic!", - "overview": "Here's an overview of your {{type}} monitors.", - "prepend": "Hey there" - }, - "high": "high", - "host": "Host", - "http": "HTTP", - "https": "HTTPS", - "incidentsPage": { - "resolveIncidentDialogCommentLabel": "Comment (optional)", - "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", - "resolveIncidentDialogConfirm": "Resolve", - "resolveIncidentDialogTitle": "Resolve Incident" - }, - "integrations": "Integrations", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", - "integrationsPrism": "Connect Prism to your favorite service.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", - "invalidFileFormat": "Unsupported file format!", - "invalidFileSize": "File size is too large!", - "inviteNoTokenFound": "No invite token found", - "LastName": "Last name", - "logsPage": { - "logLevelSelect": { - "title": "Log level", - "values": { - "all": "All", - "debug": "Debug", - "error": "Error", - "info": "Info", - "warn": "Warn" - } - }, - "noLogs": "No logs found", - "table": { - "level": "Level", - "logs": "logs", - "message": "Message", - "method": "Method", - "service": "Service", - "timestamp": "Timestamp" - }, - "tabs": { - "diagnostics": "Diagnostics", - "logs": "Server logs", - "queue": "Job queue" - }, - "title": "Logs" - }, - "low": "low", - "maintenance": "maintenance", - "maintenanceRepeat": "Maintenance Repeat", - "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "maintenanceWindowDescription": "During maintenance windows, all monitoring is suspended for selected monitors. No network checks will be performed, preventing any status updates or notifications from being triggered. Your monitors will appear frozen at their last known status, and status pages will display a maintenance indicator. Once the maintenance window ends, monitoring automatically resumes, and alerts will trigger if issues are detected. Maintenance periods do not count against uptime calculations.", - "maintenanceWindowName": "Maintenance Window Name", - "matchMethod": "Match Method", - "matchMethodOptions": { - "equal": "Equal", - "include": "Include", - "regex": "Regex" - }, - "MaxSize": "Maximum Size", - "mb": "MB", - "mem": "Mem", - "memory": "Memory", - "memoryUsage": "Memory usage", - "menu": { - "changelog": "Changelog", - "checks": "Checks", - "discussions": "Discussions", - "docs": "Docs", - "incidents": "Incidents", - "inviteMember": "Invite member", - "infrastructure": "Infrastructure", - "logOut": "Log out", - "logs": "Logs", - "maintenance": "Maintenance", - "notifications": "Notifications", - "pagespeed": "Pagespeed", - "password": "Password", - "profile": "Profile", - "settings": "Settings", - "statusPages": "Status pages", - "support": "Support", - "team": "Team", - "uptime": "Uptime" - }, - "message": "Message", - "monitor": "monitor", - "monitorHooks": { - "failureAddDemoMonitors": "Failed to add demo monitors", - "successAddDemoMonitors": "Successfully added demo monitors" - }, - "monitors": "monitors", - "monitorsToApply": "Monitors to apply maintenance window to", - "ms": "ms", - "navControls": "Controls", - "network": "Network", - "nextWindow": "Next window", - "notFoundButton": "Go to the main dashboard", - "notifications": { - "fallback": { - "actionButton": "Create notification channel!", - "checks": [ - "Alert teams about downtime or performance issues", - "Let engineers know when incidents happen", - "Keep administrators informed of system changes" - ], - "title": "A notification channel is used to:" - }, - "fetch": { - "failed": "Failed to fetch notifications" - }, - "test": { - "success": "Test notification sent successfully" - } - }, - "now": "Now", - "os": "OS", + "pages": { "notFound": { "title": "Oh no! You dropped your sushi!", @@ -681,7 +270,32 @@ }, "auth": { "common": { - "passwordRules": {}, + "passwordRules": { + "length": { + "beginning": "Must be at least", + "highlighted": "8 characters long" + }, + "lowercase": { + "beginning": "Must contain at least", + "highlighted": "one lower character" + }, + "match": { + "beginning": "Passwords", + "highlighted": "must match" + }, + "number": { + "beginning": "Must contain at least", + "highlighted": "one number" + }, + "special": { + "beginning": "Must contain at least", + "highlighted": "one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" + }, + "uppercase": { + "beginning": "Must contain at least", + "highlighted": "one upper character" + } + }, "form": { "option": { "email": { @@ -798,7 +412,10 @@ "label": "JSONPath expression" }, "matchMethod": { - "label": "Match method" + "label": "Match method", + "equal": "Equal", + "include": "Include", + "regex": "Regex" } }, "title": "Advanced settings" @@ -807,7 +424,19 @@ "description": "How often do you want to check the status of this monitor?", "option": { "frequency": { - "label": "Check frequency" + "label": "Check frequency", + "value": { + "fifteenMinutes": "15 minutes", + "fifteenSeconds": "15 seconds", + "fiveMinutes": "5 minutes", + "fourMinutes": "4 minutes", + "oneMinute": "1 minute", + "tenMinutes": "10 minutes", + "thirtyMinutes": "30 minutes", + "thirtySeconds": "30 seconds", + "threeMinutes": "3 minutes", + "twoMinutes": "2 minutes" + } } }, "title": "Check frequency" @@ -833,6 +462,14 @@ "url": { "label": "URL", "placeholder": "https://www.google.com" + }, + "game": { + "label": "Choose game", + "placeholder": "Select a game" + }, + "port": { + "label": "Port to monitor", + "placeholder": 80 } }, "title": "General settings", @@ -897,6 +534,15 @@ }, "incidents": { "dialog": { + "resolveIncident": { + "title": "Resolve incident", + "option": { + "comment": { + "label": "Comment (optional)", + "placeholder": "Add a comment about the resolution..." + } + } + }, "details": { "analysis": "Incident analysis", "comment": "Comment:", @@ -1371,272 +1017,5 @@ "title": "An uptime monitor is used to:" } } - }, - "pageSpeedAddApiKey": "to add your API key.", - "pageSpeedLearnMoreLink": "Click here", - "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", - "passwordPanel": { - "confirmNewPassword": "Confirm new password", - "currentPassword": "Current password", - "enterCurrentPassword": "Enter your current password", - "enterNewPassword": "Enter your new password", - "newPassword": "New password", - "passwordChangedSuccess": "Your password was changed successfully.", - "passwordInputIncorrect": "Your password input was incorrect.", - "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)." - }, - "pause": "Pause", - "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "portToMonitor": "Port to monitor", - "publicLink": "Public link", - "queuePage": { - "failedJobTable": { - "failCountHeader": "Fail count", - "failedAtHeader": "Last failed at", - "failReasonHeader": "Fail reason", - "monitorIdHeader": "Monitor ID", - "monitorUrlHeader": "Monitor URL", - "title": "Failed jobs" - }, - "flushButton": "Flush queue", - "jobTable": { - "activeHeader": "Active", - "failCountHeader": "Fail count", - "idHeader": "Monitor ID", - "intervalHeader": "Interval", - "lastFinishedAtHeader": "Last finished at", - "lastRunHeader": "Last run at", - "lastRunTookHeader": "Last run took", - "lockedAtHeader": "Locked at", - "runCountHeader": "Run count", - "title": "Jobs currently in queue", - "typeHeader": "Type", - "urlHeader": "URL" - }, - "metricsTable": { - "metricHeader": "Metric", - "title": "Queue metrics", - "valueHeader": "Value" - }, - "refreshButton": "Refresh" - }, - "rate": "Rate", - "remove": "Remove", - "repeat": "Repeat", - "reset": "Reset", - "response": "RESPONSE", - "responseTime": "Response time", - "resume": "Resume", - "roles": { - "admin": "Admin", - "demoUser": "Demo user", - "superAdmin": "Super admin", - "teamMember": "Team member" - }, - "save": "Save", - "sendInvite": "Send invite", - "selectAll": "Select all", - "settingsFailedToClearStats": "Failed to clear stats", - "settingsFailedToDeleteMonitors": "Failed to delete all monitors", - "settingsFailedToSave": "Failed to save settings", - "settingsGeneralSettings": "General settings", - "settingsMonitorsDeleted": "Successfully deleted all monitors", - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "Developed by Bluewave Labs", - "title": "About" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "Add demo monitors", - "description": "Add sample monitors for demonstration purposes.", - "title": "Demo monitors" - }, - "emailSettings": { - "buttonSendTestEmail": "Send test e-mail", - "description": "Configure the email settings for your system. This is used to send notifications and alerts.", - "descriptionTransport": "This builds an SMTP transport for NodeMailer", - "labelAddress": "Email address - Used for authentication", - "labelConnectionHost": "Email connection host - Hostname to use in the HELO/EHLO greeting", - "labelHost": "Email host - Hostname or IP address to connect to", - "labelIgnoreTLS": "Disable STARTTLS: Don't use TLS even if the server supports it", - "labelPassword": "Email password - Password for authentication", - "labelPasswordSet": "Password is set. Click Reset to change it.", - "labelPool": "Enable connection pooling: Reuse existing connections to improve performance", - "labelPort": "Email port - Port to connect to", - "labelRejectUnauthorized": "Reject invalid certificates: Reject connections with self-signed or untrusted certificates", - "labelRequireTLS": "Force STARTTLS: Require TLS upgrade, fail if not supported", - "labelSecure": "Use SSL (recommended): Encrypt the connection using SSL/TLS", - "labelTLSServername": "TLS Servername - Optional Hostname for TLS Validation when host is an IP", - "labelUser": "Email user - Username for authentication, overrides email address if specified", - "linkTransport": "See specifications here", - "placeholderUser": "Leave empty if not required", - "title": "Email", - "toastEmailRequiredFieldsError": "Email address, host, port and password are required" - }, - "globalThresholds": { - "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring.", - "title": "Global Thresholds" - }, - "pageSpeedSettings": { - "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", - "labelApiKey": "PageSpeed API key", - "labelApiKeySet": "API key is set. Click Reset to change it.", - "title": "Google PageSpeed API key" - }, - "saveButtonLabel": "Save", - "statsSettings": { - "clearAllStatsButton": "Clear all stats", - "clearAllStatsDescription": "Clear all stats. This is irreversible.", - "clearAllStatsDialogConfirm": "Yes, clear all stats", - "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", - "clearAllStatsDialogTitle": "Do you want to clear all stats?", - "description": "Define how long you want to retain historical data. You can also clear all existing data.", - "title": "Monitor history" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "Remove all monitors", - "description": "Remove all monitors from your system.", - "dialogConfirm": "Yes, remove all monitors", - "dialogTitle": "Do you want to remove all monitors?", - "title": "System reset" - }, - "timezoneSettings": { - "description": "Select the timezone used to display dates and times throughout the application.", - "title": "Display timezone" - }, - "title": "Settings", - "uiSettings": { - "chartTypeHeatmap": "Heatmap", - "chartTypeHistogram": "Histogram", - "description": "Switch between light and dark mode, or change user interface language.", - "labelChartType": "Chart type", - "labelLanguage": "Language", - "labelTheme": "Theme mode", - "title": "Appearance" - }, - "urlSettings": { - "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", - "label": "Display IP/URL on status page", - "selectDisabled": "Disabled", - "selectEnabled": "Enabled", - "title": "Monitor IP/URL on Status Page" - } - }, - "settingsStatsCleared": "Stats cleared successfully", - "settingsSuccessSaved": "Settings saved successfully", - "settingsTestEmailFailed": "Failed to send test email", - "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", - "settingsTestEmailSuccess": "Test email sent successfully", - "settingsTestEmailUnknownError": "Unknown error", - "shown": "Shown", - "starPromptDescription": "See the latest releases and help grow the community on GitHub", - "starPromptTitle": "Star Checkmate", - "startTime": "Start time", - "state": "State", - "status": "Status", - "statusCode": "Status code", - "statusPage": { - "contents": "Contents", - "deleteFailed": "Failed to delete status page", - "deleteSuccess": "Status page deleted successfully", - "generalSettings": "General settings", - "details": { - "statusHeader": { - "allUp": "All systems operational", - "allDown": "All systems are experiencing issues", - "anyDown": "Some systems are experiencing issues" - } - } - }, - "statusPageCreate": { - "buttonSave": "Save" - }, - "statusPageStatusServiceStatus": "Service status", - "submit": "Submit", - "SupportedFormats": "Supported formats", - "teamPanel": { - "addTeamMember": { - "addButton": "Add Member", - "addMemberMenu": "Add Team Member", - "description": "Create a new user and share the credentials with them. This method gives the member immediate access to all monitors.", - "title": "Register new team member" - }, - "addMember": "Add member", - "cancel": "Cancel", - "changeTeamPassword": { - "changePasswordMenu": "Reset Password", - "description": "Create a new password for this team member. You will need to share the password with them securely.", - "success": "Password successfully reset. Make sure to provide the credentials to the member in a secure way.", - "title": "Reset team member password" - }, - "email": "Email", - "emailToken": "E-mail token", - "filter": { - "all": "All", - "member": "Member" - }, - "getToken": "Get token", - "inviteDescription": "When you add a new team member, they will get access to all monitors.", - "inviteLink": "Invite link", - "inviteNewTeamMember": "Invite new team member", - "inviteTeamMember": "Invite a team member", - "noMembers": "There are no team members with this role", - "register": "Register a team member", - "registerTeamMember": { - "auth": { - "common": { - "inputs": { - "role": { - "errors": { - "empty": "Role is required" - } - } - } - } - } - }, - "registerToast": { - "success": "User created, share credentials with the member securely." - }, - "role": "Role", - "selectRole": "Select role", - "table": { - "created": "Created", - "email": "Email", - "name": "Name", - "role": "Role" - }, - "teamMembers": "Team members" - }, - "time": { - "fifteenMinutes": "15 minutes", - "fifteenSeconds": "15 seconds", - "fiveMinutes": "5 minutes", - "fourMinutes": "4 minutes", - "oneMinute": "1 minute", - "tenMinutes": "10 minutes", - "thirtyMinutes": "30 minutes", - "thirtySeconds": "30 seconds", - "threeMinutes": "3 minutes", - "twoMinutes": "2 minutes" - }, - "timezone": "Timezone", - "timeZoneInfo": "All dates and times are in GMT+0 time zone.", - "title": "Title", - "total": "Total", - "type": "Type", - "update": "Update", - "uptime": "Uptime", - "url": "URL", - "used": "Used", - "window": "window", - "YourPhoto": "Profile photo", - "failedDeleteMonitor": "Failed to delete monitor", - "failedPauseMonitor": "Failed to pause monitor", - "hour": "hour", - "minute": "minute", - "monitorDeleted": "Monitor deleted successfully", - "monitorPaused": "Monitor paused successfully", - "monitorResumed": "Monitor resumed successfully", - "name": "Name" + } } From 1888ee04786899587ee5d65ab4dc10eb0d6f1dbf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 13 Feb 2026 21:19:55 +0000 Subject: [PATCH 299/392] remove unused monitor import --- client/src/Utils/MonitorUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index be4595090..02c291efc 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,4 +1,4 @@ -import type { Monitor, MonitorStatus, MonitorType } from "@/Types/Monitor"; +import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/v2Theme"; import type { ValueType } from "@/Components/v2/design-elements/StatusLabel"; From 2f6dbe9a3ad9cb10374da442edae1a11aca71bd7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 13 Feb 2026 23:56:35 +0000 Subject: [PATCH 300/392] update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2299e795a..f6e292bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .VSCodeCounter *.sh mongo -node_modules/ \ No newline at end of file +node_modules/ +docs/architecture \ No newline at end of file From 86fd535eafc81e3a39e99f8bef7c11c940508a5f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 00:08:28 +0000 Subject: [PATCH 301/392] routes to TS --- client/src/Pages/NotFound/index.tsx | 4 ++-- client/src/Routes/{index.jsx => index.tsx} | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename client/src/Routes/{index.jsx => index.tsx} (98%) diff --git a/client/src/Pages/NotFound/index.tsx b/client/src/Pages/NotFound/index.tsx index 709f808be..868a1573a 100644 --- a/client/src/Pages/NotFound/index.tsx +++ b/client/src/Pages/NotFound/index.tsx @@ -9,8 +9,8 @@ import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; interface NotFoundProps { - title: string; - desc: string; + title?: string; + desc?: string; } const NotFoundPage = ({ title, desc }: NotFoundProps) => { diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.tsx similarity index 98% rename from client/src/Routes/index.jsx rename to client/src/Routes/index.tsx index 3b0b76fc6..e3c0b0667 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@mui/material"; import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; import { useSelector } from "react-redux"; +import type { RootState } from "@/store"; import { Navigate, Route, Routes as LibRoutes } from "react-router"; import RootLayout from "@/Components/v2/layout/RootLayout"; import NotFound from "@/Pages/NotFound"; @@ -59,7 +60,7 @@ import { import CreateMonitor from "@/Pages/CreateMonitor"; const Routes = () => { - const mode = useSelector((state) => state.ui.mode); + const mode = useSelector((state: RootState) => state.ui.mode); const v2theme = mode === "light" ? lightTheme : darkTheme; return ( @@ -390,12 +391,11 @@ const Routes = () => { /> - + } From 002a82bb9a90c57e625da3b0ef78f4ada5868ff7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 00:19:01 +0000 Subject: [PATCH 302/392] fix port input --- client/src/Pages/CreateMonitor/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index e7ceb52b1..d7dde2a08 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -329,8 +329,11 @@ const CreateMonitorPage = () => { render={({ field, fieldState }) => ( field.onChange(Number(e.target.value) || 0)} + value={field.value === 0 ? "" : field.value} + onChange={(e) => { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} type="number" fieldLabel={t("pages.createMonitor.form.general.option.port.label")} placeholder={t( From c31973804803a06e98efba60d620f94bcf97b985 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 00:40:59 +0000 Subject: [PATCH 303/392] add config box for thresholds --- client/src/Pages/CreateMonitor/index.tsx | 108 +++++++++++++++++++++++ client/src/locales/en.json | 23 ++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index e7ceb52b1..76848d269 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -486,6 +486,114 @@ const CreateMonitorPage = () => { } /> + {/* Alert Thresholds - only for hardware type */} + {generalSettingsConfig.showSecret && ( + + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + type="number" + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.cpuThreshold.label" + )} + placeholder={t( + "pages.createMonitor.form.thresholds.option.cpuThreshold.placeholder" + )} + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + type="number" + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.memoryThreshold.label" + )} + placeholder={t( + "pages.createMonitor.form.thresholds.option.memoryThreshold.placeholder" + )} + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + type="number" + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.diskThreshold.label" + )} + placeholder={t( + "pages.createMonitor.form.thresholds.option.diskThreshold.placeholder" + )} + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + type="number" + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.tempThreshold.label" + )} + placeholder={t( + "pages.createMonitor.form.thresholds.option.tempThreshold.placeholder" + )} + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + + } + /> + )} + Date: Sat, 14 Feb 2026 01:02:15 +0000 Subject: [PATCH 304/392] use sliderwithlabels --- client/src/Pages/CreateMonitor/index.tsx | 88 +++++++++--------------- 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 76848d269..67cc512f8 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -496,96 +496,72 @@ const CreateMonitorPage = () => { ( - ( + { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" + sliderMaxWidth={{ xs: "100%", md: "50%" }} fieldLabel={t( "pages.createMonitor.form.thresholds.option.cpuThreshold.label" )} - placeholder={t( - "pages.createMonitor.form.thresholds.option.cpuThreshold.placeholder" - )} - fullWidth - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> )} /> ( - ( + { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" + sliderMaxWidth={{ xs: "100%", md: "50%" }} fieldLabel={t( "pages.createMonitor.form.thresholds.option.memoryThreshold.label" )} - placeholder={t( - "pages.createMonitor.form.thresholds.option.memoryThreshold.placeholder" - )} - fullWidth - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> )} /> ( - ( + { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" + sliderMaxWidth={{ xs: "100%", md: "50%" }} fieldLabel={t( "pages.createMonitor.form.thresholds.option.diskThreshold.label" )} - placeholder={t( - "pages.createMonitor.form.thresholds.option.diskThreshold.placeholder" - )} - fullWidth - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> )} /> ( - ( + { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" + sliderMaxWidth={{ xs: "100%", md: "50%" }} fieldLabel={t( "pages.createMonitor.form.thresholds.option.tempThreshold.label" )} - placeholder={t( - "pages.createMonitor.form.thresholds.option.tempThreshold.placeholder" - )} - fullWidth - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}°C`} /> )} /> From f694917dd40524001ddd55bd37ca7e5a573c47e1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 01:07:46 +0000 Subject: [PATCH 305/392] remove unused --- client/src/locales/en.json | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c199e3151..8e18d8e2a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -526,20 +526,16 @@ "description": "Define the thresholds at which alerts should be triggered for this hardware monitor.", "option": { "cpuThreshold": { - "label": "CPU alert threshold (%)", - "placeholder": "80" + "label": "CPU alert threshold (%)" }, "memoryThreshold": { - "label": "Memory alert threshold (%)", - "placeholder": "80" + "label": "Memory alert threshold (%)" }, "diskThreshold": { - "label": "Disk alert threshold (%)", - "placeholder": "80" + "label": "Disk alert threshold (%)" }, "tempThreshold": { - "label": "Temperature alert threshold (°C)", - "placeholder": "80" + "label": "Temperature alert threshold (°C)" } } } From 2430bbeebba522f309699ba89e9736e0a1dbc6b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 01:15:12 +0000 Subject: [PATCH 306/392] remove slider hover --- client/src/Components/v2/inputs/Slider.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/Components/v2/inputs/Slider.tsx b/client/src/Components/v2/inputs/Slider.tsx index 97a16ac2c..1bdb57cb3 100644 --- a/client/src/Components/v2/inputs/Slider.tsx +++ b/client/src/Components/v2/inputs/Slider.tsx @@ -39,9 +39,14 @@ export const SliderInput = forwardRef( opacity: 1, }, "& .MuiSlider-thumb": { + height: 15, + width: 15, backgroundColor: "#fff", "&:hover, &.Mui-focusVisible": { - boxShadow: `0 0 0 8px ${theme.palette.primary.main}20`, + boxShadow: "none", + }, + "&:active": { + boxShadow: "none", }, }, "& .MuiSlider-valueLabel": { From 111b68b8df7a20ec5bd0ed08c9d22fd7567f672e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 01:21:02 +0000 Subject: [PATCH 307/392] revert slider size change --- client/src/Components/v2/inputs/Slider.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/src/Components/v2/inputs/Slider.tsx b/client/src/Components/v2/inputs/Slider.tsx index 1bdb57cb3..83037ab54 100644 --- a/client/src/Components/v2/inputs/Slider.tsx +++ b/client/src/Components/v2/inputs/Slider.tsx @@ -39,8 +39,6 @@ export const SliderInput = forwardRef( opacity: 1, }, "& .MuiSlider-thumb": { - height: 15, - width: 15, backgroundColor: "#fff", "&:hover, &.Mui-focusVisible": { boxShadow: "none", From 4a73170583dc642d0bef3f4f242f9dd156df6357 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:05:31 +0000 Subject: [PATCH 308/392] initial commit --- .../SuperSimpleQueueHelper.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 6c9e9264a..e9439feef 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -2,9 +2,24 @@ const SERVICE_NAME = "JobQueueHelper"; import type { Monitor } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; +import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js"; import IncidentService from "@/service/business/incidentService.js"; import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js"; +export interface MonitorActionDecision { + shouldCreateIncident: boolean; + shouldResolveIncident: boolean; + shouldSendNotification: boolean; + incidentReason: "status_down" | "threshold_breach" | null; + notificationReason: "status_change" | "threshold_breach" | null; + thresholdBreaches?: { + cpu?: boolean; + memory?: boolean; + disk?: boolean; + temp?: boolean; + }; +} + class SuperSimpleQueueHelper { static SERVICE_NAME = SERVICE_NAME; @@ -166,6 +181,126 @@ class SuperSimpleQueueHelper { }, false); return maintenanceWindowIsActive; } + + /** + * Evaluates what actions should be taken based on monitor status and hardware thresholds. + * This is a pure decision function that returns what should happen without executing anything. + * + * @param statusChangeResult - Result from statusService.updateMonitorStatus() + * @param networkResponse - Network response containing payload data + * @returns Decision object indicating what actions to take + */ + private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { + const { monitor, statusChanged, prevStatus } = statusChangeResult; + + // Initialize result + const decision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + }; + + // CASE 1: Non-hardware monitors (http, ping, port, docker, pagespeed, game) + if (monitor.type !== "hardware") { + // Only act on status changes + if (statusChanged) { + if (monitor.status === "down") { + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (monitor.status === "up" && prevStatus === "down") { + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } + } + return decision; + } + + // CASE 2: Hardware monitors + const payload = networkResponse.payload as HardwareStatusPayload; + const metrics = payload?.data; + + if (!metrics) { + return decision; // No metrics, can't evaluate + } + + // Check if thresholds exist + const thresholds = monitor.thresholds || {}; + const hasThresholds = + thresholds.usage_cpu !== undefined || + thresholds.usage_memory !== undefined || + thresholds.usage_disk !== undefined || + thresholds.usage_temperature !== undefined; + + // Evaluate threshold breaches + const breaches = { + cpu: false, + memory: false, + disk: false, + temp: false, + }; + + if (hasThresholds) { + // CPU check + if (thresholds.usage_cpu !== undefined) { + const cpuUsage = metrics.cpu?.usage_percent ?? -1; + breaches.cpu = cpuUsage !== -1 && cpuUsage > thresholds.usage_cpu; + } + + // Memory check + if (thresholds.usage_memory !== undefined) { + const memoryUsage = metrics.memory?.usage_percent ?? -1; + breaches.memory = memoryUsage !== -1 && memoryUsage > thresholds.usage_memory; + } + + // Disk check + if (thresholds.usage_disk !== undefined) { + breaches.disk = metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > thresholds.usage_disk!) ?? false; + } + + // Temperature check (alert if ANY sensor exceeds threshold) + if (thresholds.usage_temperature !== undefined) { + const temps = metrics.cpu?.temperature ?? []; + breaches.temp = temps.some((temp: number) => temp > thresholds.usage_temperature!); + } + } + + const anyThresholdBreached = Object.values(breaches).some((b) => b); + + // Check if countdown has reached zero for any threshold + const shouldNotifyThreshold = + (breaches.cpu && (monitor.cpuAlertThreshold ?? monitor.alertThreshold) <= 0) || + (breaches.memory && (monitor.memoryAlertThreshold ?? monitor.alertThreshold) <= 0) || + (breaches.disk && (monitor.diskAlertThreshold ?? monitor.alertThreshold) <= 0) || + (breaches.temp && (monitor.tempAlertThreshold ?? monitor.alertThreshold) <= 0); + + // Decision logic for hardware + if (statusChanged && monitor.status === "down") { + // Hardware service is DOWN (unreachable) + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { + // Hardware service recovered + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } else if (anyThresholdBreached && shouldNotifyThreshold) { + // Threshold breach alert (service is UP but metrics are high) + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "threshold_breach"; + decision.notificationReason = "threshold_breach"; + decision.thresholdBreaches = breaches; + } + + return decision; + } } export default SuperSimpleQueueHelper; From a7b0ce4d8ffd5d7d7cded37a1b0c9c4c91b91578 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:21:18 +0000 Subject: [PATCH 309/392] remove thresholds --- client/src/Types/Monitor.ts | 8 ---- server/src/db/models/Monitor.ts | 16 +------ .../monitors/MongoMonitorsRepository.ts | 2 - .../SuperSimpleQueueHelper.ts | 44 +++++++++---------- .../notificationProviders/utils.ts | 12 +++-- .../infrastructure/notificationsService.ts | 9 +++- server/src/types/monitor.ts | 8 ---- 7 files changed, 36 insertions(+), 63 deletions(-) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index ea6f4d019..2f1c1838d 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -21,13 +21,6 @@ export const MonitorStatuses = [ ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; -export interface MonitorThresholds { - usage_cpu?: number; - usage_memory?: number; - usage_disk?: number; - usage_temperature?: number; -} - export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; export interface Monitor { @@ -53,7 +46,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - thresholds?: MonitorThresholds; alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 9111ce47f..bb3108ed3 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,5 +1,5 @@ import { Schema, model, Types, type UpdateQuery } from "mongoose"; -import type { Monitor, MonitorMatchMethod, MonitorThresholds, CheckSnapshot } from "@/types/monitor.js"; +import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -13,7 +13,6 @@ type MonitorDocumentBase = Omit< notifications: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; - thresholds?: MonitorThresholds; }; interface MonitorDocument extends MonitorDocumentBase { @@ -24,16 +23,6 @@ interface MonitorDocument extends MonitorDocumentBase { updatedAt: Date; } -const thresholdsSchema = new Schema( - { - usage_cpu: { type: Number }, - usage_memory: { type: Number }, - usage_disk: { type: Number }, - usage_temperature: { type: Number }, - }, - { _id: false } -); - const checkSnapshotSchema = new Schema( { id: { type: String, required: true }, @@ -132,9 +121,6 @@ const MonitorSchema = new Schema( secret: { type: String, }, - thresholds: { - type: thresholdsSchema, - }, alertThreshold: { type: Number, default: 5, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 65d93421e..7390d0e0e 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -317,7 +317,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - thresholds: doc.thresholds ?? undefined, alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, @@ -370,7 +369,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - thresholds: doc.thresholds ?? undefined, alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index e9439feef..0b143450c 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -182,14 +182,6 @@ class SuperSimpleQueueHelper { return maintenanceWindowIsActive; } - /** - * Evaluates what actions should be taken based on monitor status and hardware thresholds. - * This is a pure decision function that returns what should happen without executing anything. - * - * @param statusChangeResult - Result from statusService.updateMonitorStatus() - * @param networkResponse - Network response containing payload data - * @returns Decision object indicating what actions to take - */ private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { const { monitor, statusChanged, prevStatus } = statusChangeResult; @@ -202,10 +194,10 @@ class SuperSimpleQueueHelper { notificationReason: null, }; - // CASE 1: Non-hardware monitors (http, ping, port, docker, pagespeed, game) + // CASE 1: Non-hardware if (monitor.type !== "hardware") { - // Only act on status changes if (statusChanged) { + // This is the current state of the monitor AFTER the most recent check if (monitor.status === "down") { decision.shouldCreateIncident = true; decision.shouldSendNotification = true; @@ -220,7 +212,7 @@ class SuperSimpleQueueHelper { return decision; } - // CASE 2: Hardware monitors + // CASE 2: Hardware const payload = networkResponse.payload as HardwareStatusPayload; const metrics = payload?.data; @@ -229,12 +221,11 @@ class SuperSimpleQueueHelper { } // Check if thresholds exist - const thresholds = monitor.thresholds || {}; const hasThresholds = - thresholds.usage_cpu !== undefined || - thresholds.usage_memory !== undefined || - thresholds.usage_disk !== undefined || - thresholds.usage_temperature !== undefined; + monitor.cpuAlertThreshold !== undefined || + monitor.memoryAlertThreshold !== undefined || + monitor.diskAlertThreshold !== undefined || + monitor.tempAlertThreshold !== undefined; // Evaluate threshold breaches const breaches = { @@ -246,26 +237,31 @@ class SuperSimpleQueueHelper { if (hasThresholds) { // CPU check - if (thresholds.usage_cpu !== undefined) { + if (monitor.cpuAlertThreshold !== undefined) { const cpuUsage = metrics.cpu?.usage_percent ?? -1; - breaches.cpu = cpuUsage !== -1 && cpuUsage > thresholds.usage_cpu; + // Convert threshold from percentage (0-100) to decimal (0-1) for comparison + breaches.cpu = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; } // Memory check - if (thresholds.usage_memory !== undefined) { + if (monitor.memoryAlertThreshold !== undefined) { const memoryUsage = metrics.memory?.usage_percent ?? -1; - breaches.memory = memoryUsage !== -1 && memoryUsage > thresholds.usage_memory; + // Convert threshold from percentage (0-100) to decimal (0-1) for comparison + breaches.memory = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; } // Disk check - if (thresholds.usage_disk !== undefined) { - breaches.disk = metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > thresholds.usage_disk!) ?? false; + if (monitor.diskAlertThreshold !== undefined) { + // Convert threshold from percentage (0-100) to decimal (0-1) for comparison + breaches.disk = + metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; } // Temperature check (alert if ANY sensor exceeds threshold) - if (thresholds.usage_temperature !== undefined) { + if (monitor.tempAlertThreshold !== undefined) { const temps = metrics.cpu?.temperature ?? []; - breaches.temp = temps.some((temp: number) => temp > thresholds.usage_temperature!); + // Temperature threshold is in Celsius, compare directly + breaches.temp = temps.some((temp: number) => temp > monitor.tempAlertThreshold); } } diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index 2fe22e834..ac964d93f 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -23,8 +23,10 @@ export const buildHardwareAlerts = ( monitor: Monitor, networkResponse: MonitorStatusResponse ): { alertsToSend: string[]; discordPayload: any } => { - const thresholds = monitor.thresholds || {}; - const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; + // Thresholds are stored as percentages (0-100), convert to decimal (0-1) for comparison + const cpuThreshold = monitor.cpuAlertThreshold !== undefined ? monitor.cpuAlertThreshold / 100 : -1; + const memoryThreshold = monitor.memoryAlertThreshold !== undefined ? monitor.memoryAlertThreshold / 100 : -1; + const diskThreshold = monitor.diskAlertThreshold !== undefined ? monitor.diskAlertThreshold / 100 : -1; const payload = networkResponse?.payload as HardwareStatusPayload; const metrics = payload.data || {}; @@ -124,8 +126,10 @@ export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): st }; export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: MonitorStatusResponse): boolean => { - const thresholds = monitor.thresholds || {}; - const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; + // Thresholds are stored as percentages (0-100), convert to decimal (0-1) for comparison + const cpuThreshold = monitor.cpuAlertThreshold !== undefined ? monitor.cpuAlertThreshold / 100 : -1; + const memoryThreshold = monitor.memoryAlertThreshold !== undefined ? monitor.memoryAlertThreshold / 100 : -1; + const diskThreshold = monitor.diskAlertThreshold !== undefined ? monitor.diskAlertThreshold / 100 : -1; const payload = networkResponse?.payload as HardwareStatusPayload; const metrics = payload.data || {}; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 531014a7a..b2ae59b7c 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -239,9 +239,14 @@ export class NotificationsService implements INotificationsService { // Deal with hardware thresholds if (type === "hardware") { - const thresholds = monitor.thresholds; + // Check if any thresholds are set + const hasThresholds = + monitor.cpuAlertThreshold !== undefined || + monitor.memoryAlertThreshold !== undefined || + monitor.diskAlertThreshold !== undefined || + monitor.tempAlertThreshold !== undefined; - if (thresholds === undefined) return false; // No thresholds set, we're done + if (!hasThresholds) return false; // No thresholds set, we're done const metrics = payload?.data ?? null; if (metrics === null) return false; // No metrics, we're done diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 384e14fe0..83a207e2d 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -7,13 +7,6 @@ export type MonitorType = (typeof MonitorTypes)[number]; export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance"] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; -export interface MonitorThresholds { - usage_cpu?: number; - usage_memory?: number; - usage_disk?: number; - usage_temperature?: number; -} - export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; export interface Monitor { @@ -39,7 +32,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - thresholds?: MonitorThresholds; alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; From 2fb6cb0b6f3f614dc0fb1c860a84ca9364858676 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:29:30 +0000 Subject: [PATCH 310/392] remove alertThreshold --- client/src/Hooks/useMonitorForm.ts | 8 ++-- client/src/Types/Monitor.ts | 1 - server/src/db/models/Monitor.ts | 47 ++----------------- .../monitors/MongoMonitorsRepository.ts | 2 - .../SuperSimpleQueueHelper.ts | 8 ++-- .../notificationProviders/utils.ts | 10 ++-- server/src/types/monitor.ts | 1 - 7 files changed, 17 insertions(+), 60 deletions(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 153cf9f19..633fcb5c9 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -83,10 +83,10 @@ export const useMonitorForm = ({ type: "hardware", url: data?.url || "", secret: data?.secret || "", - cpuAlertThreshold: data?.cpuAlertThreshold ?? 80, - memoryAlertThreshold: data?.memoryAlertThreshold ?? 80, - diskAlertThreshold: data?.diskAlertThreshold ?? 80, - tempAlertThreshold: data?.tempAlertThreshold ?? 80, + cpuAlertThreshold: data?.cpuAlertThreshold ?? 100, + memoryAlertThreshold: data?.memoryAlertThreshold ?? 100, + diskAlertThreshold: data?.diskAlertThreshold ?? 100, + tempAlertThreshold: data?.tempAlertThreshold ?? 100, selectedDisks: data?.selectedDisks || [], }; break; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 2f1c1838d..fdd66b671 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -46,7 +46,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; diskAlertThreshold: number; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index bb3108ed3..673872fba 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -121,33 +121,21 @@ const MonitorSchema = new Schema( secret: { type: String, }, - alertThreshold: { - type: Number, - default: 5, - }, cpuAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, memoryAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, diskAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, tempAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, selectedDisks: { type: [String], @@ -175,33 +163,6 @@ const MonitorSchema = new Schema( } ); -MonitorSchema.pre("save", function (next) { - if (!this.cpuAlertThreshold || this.isModified("alertThreshold")) { - this.cpuAlertThreshold = this.alertThreshold; - } - if (!this.memoryAlertThreshold || this.isModified("alertThreshold")) { - this.memoryAlertThreshold = this.alertThreshold; - } - if (!this.diskAlertThreshold || this.isModified("alertThreshold")) { - this.diskAlertThreshold = this.alertThreshold; - } - if (!this.tempAlertThreshold || this.isModified("alertThreshold")) { - this.tempAlertThreshold = this.alertThreshold; - } - next(); -}); - -MonitorSchema.pre("findOneAndUpdate", function (next) { - const update = this.getUpdate() as UpdateQuery | null; - if (update && !Array.isArray(update) && update.alertThreshold !== undefined) { - update.cpuAlertThreshold = update.alertThreshold; - update.memoryAlertThreshold = update.alertThreshold; - update.diskAlertThreshold = update.alertThreshold; - update.tempAlertThreshold = update.alertThreshold; - } - next(); -}); - MonitorSchema.index({ teamId: 1, type: 1 }); const MonitorModel = model("Monitor", MonitorSchema); diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 7390d0e0e..71d4a2a41 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -317,7 +317,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, diskAlertThreshold: doc.diskAlertThreshold, @@ -369,7 +368,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, diskAlertThreshold: doc.diskAlertThreshold, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 0b143450c..85e54f793 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -269,10 +269,10 @@ class SuperSimpleQueueHelper { // Check if countdown has reached zero for any threshold const shouldNotifyThreshold = - (breaches.cpu && (monitor.cpuAlertThreshold ?? monitor.alertThreshold) <= 0) || - (breaches.memory && (monitor.memoryAlertThreshold ?? monitor.alertThreshold) <= 0) || - (breaches.disk && (monitor.diskAlertThreshold ?? monitor.alertThreshold) <= 0) || - (breaches.temp && (monitor.tempAlertThreshold ?? monitor.alertThreshold) <= 0); + (breaches.cpu && (monitor.cpuAlertThreshold ?? 100) <= 0) || + (breaches.memory && (monitor.memoryAlertThreshold ?? 100) <= 0) || + (breaches.disk && (monitor.diskAlertThreshold ?? 100) <= 0) || + (breaches.temp && (monitor.tempAlertThreshold ?? 100) <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index ac964d93f..284855846 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -88,12 +88,12 @@ export const buildHardwareAlerts = ( const thresholdKey = alertThresholdKeyMap[type]; // Iterate over each alert type to see if any need to be decremented if (alerts[type] === true) { - const nextValue = ((monitor[thresholdKey] ?? monitor.alertThreshold) as number) - 1; + const nextValue = ((monitor[thresholdKey] ?? 100) as number) - 1; monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered if (monitor[thresholdKey] <= 0) { // If threshold drops below 0, reset and send notification - monitor[thresholdKey] = monitor.alertThreshold; + monitor[thresholdKey] = 100; const formatAlert = { cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, @@ -136,17 +136,17 @@ export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: Monit const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold; - if (cpuBreach && ((monitor.cpuAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + if (cpuBreach && ((monitor.cpuAlertThreshold ?? 100) as number) - 1 <= 0) { return true; } const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold; - if (memoryBreach && ((monitor.memoryAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + if (memoryBreach && ((monitor.memoryAlertThreshold ?? 100) as number) - 1 <= 0) { return true; } const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold); - if (diskBreach && ((monitor.diskAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + if (diskBreach && ((monitor.diskAlertThreshold ?? 100) as number) - 1 <= 0) { return true; } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 83a207e2d..d92b351a9 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -32,7 +32,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; diskAlertThreshold: number; From 97ace1e05856da2fdf4473a4d0690d9cd3987801 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:38:00 +0000 Subject: [PATCH 311/392] remove unused checks --- .../SuperSimpleQueueHelper.ts | 69 +++++++------------ .../notificationProviders/utils.ts | 8 +-- 2 files changed, 28 insertions(+), 49 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 85e54f793..ec8a792f2 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -220,59 +220,38 @@ class SuperSimpleQueueHelper { return decision; // No metrics, can't evaluate } - // Check if thresholds exist - const hasThresholds = - monitor.cpuAlertThreshold !== undefined || - monitor.memoryAlertThreshold !== undefined || - monitor.diskAlertThreshold !== undefined || - monitor.tempAlertThreshold !== undefined; - // Evaluate threshold breaches + // CPU check + const cpuUsage = metrics.cpu?.usage_percent ?? -1; + const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; + + // Memory check + const memoryUsage = metrics.memory?.usage_percent ?? -1; + const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; + + // Disk check + const diskBreach = + metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; + + // Temperature check (alert if ANY sensor exceeds threshold) + const temps = metrics.cpu?.temperature ?? []; + const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); + const breaches = { - cpu: false, - memory: false, - disk: false, - temp: false, + cpu: cpuBreach, + memory: memoryBreach, + disk: diskBreach, + temp: tempBreach, }; - if (hasThresholds) { - // CPU check - if (monitor.cpuAlertThreshold !== undefined) { - const cpuUsage = metrics.cpu?.usage_percent ?? -1; - // Convert threshold from percentage (0-100) to decimal (0-1) for comparison - breaches.cpu = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; - } - - // Memory check - if (monitor.memoryAlertThreshold !== undefined) { - const memoryUsage = metrics.memory?.usage_percent ?? -1; - // Convert threshold from percentage (0-100) to decimal (0-1) for comparison - breaches.memory = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; - } - - // Disk check - if (monitor.diskAlertThreshold !== undefined) { - // Convert threshold from percentage (0-100) to decimal (0-1) for comparison - breaches.disk = - metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; - } - - // Temperature check (alert if ANY sensor exceeds threshold) - if (monitor.tempAlertThreshold !== undefined) { - const temps = metrics.cpu?.temperature ?? []; - // Temperature threshold is in Celsius, compare directly - breaches.temp = temps.some((temp: number) => temp > monitor.tempAlertThreshold); - } - } - const anyThresholdBreached = Object.values(breaches).some((b) => b); // Check if countdown has reached zero for any threshold const shouldNotifyThreshold = - (breaches.cpu && (monitor.cpuAlertThreshold ?? 100) <= 0) || - (breaches.memory && (monitor.memoryAlertThreshold ?? 100) <= 0) || - (breaches.disk && (monitor.diskAlertThreshold ?? 100) <= 0) || - (breaches.temp && (monitor.tempAlertThreshold ?? 100) <= 0); + (breaches.cpu && monitor.cpuAlertThreshold <= 0) || + (breaches.memory && monitor.memoryAlertThreshold <= 0) || + (breaches.disk && monitor.diskAlertThreshold <= 0) || + (breaches.temp && monitor.tempAlertThreshold <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index 284855846..ed9d0177c 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -88,7 +88,7 @@ export const buildHardwareAlerts = ( const thresholdKey = alertThresholdKeyMap[type]; // Iterate over each alert type to see if any need to be decremented if (alerts[type] === true) { - const nextValue = ((monitor[thresholdKey] ?? 100) as number) - 1; + const nextValue = monitor[thresholdKey] - 1; monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered if (monitor[thresholdKey] <= 0) { @@ -136,17 +136,17 @@ export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: Monit const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold; - if (cpuBreach && ((monitor.cpuAlertThreshold ?? 100) as number) - 1 <= 0) { + if (cpuBreach && monitor.cpuAlertThreshold - 1 <= 0) { return true; } const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold; - if (memoryBreach && ((monitor.memoryAlertThreshold ?? 100) as number) - 1 <= 0) { + if (memoryBreach && monitor.memoryAlertThreshold - 1 <= 0) { return true; } const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold); - if (diskBreach && ((monitor.diskAlertThreshold ?? 100) as number) - 1 <= 0) { + if (diskBreach && monitor.diskAlertThreshold - 1 <= 0) { return true; } From 9c467c763dfacb24b45397f1adb22d88c9468175 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:49:32 +0000 Subject: [PATCH 312/392] add threshold counters --- client/src/Types/Monitor.ts | 4 ++++ server/src/db/models/Monitor.ts | 16 ++++++++++++++++ .../monitors/MongoMonitorsRepository.ts | 8 ++++++++ server/src/types/monitor.ts | 4 ++++ 4 files changed, 32 insertions(+) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index fdd66b671..0c7cea5bd 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -47,9 +47,13 @@ export interface Monitor { notifications: string[]; secret?: string; cpuAlertThreshold: number; + cpuAlertCounter: number; memoryAlertThreshold: number; + memoryAlertCounter: number; diskAlertThreshold: number; + diskAlertCounter: number; tempAlertThreshold: number; + tempAlertCounter: number; selectedDisks: string[]; gameId?: string; group: string | null; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 673872fba..a459cdf3e 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -125,18 +125,34 @@ const MonitorSchema = new Schema( type: Number, default: 100, }, + cpuAlertCounter: { + type: Number, + default: 5, + }, memoryAlertThreshold: { type: Number, default: 100, }, + memoryAlertCounter: { + type: Number, + default: 5, + }, diskAlertThreshold: { type: Number, default: 100, }, + diskAlertCounter: { + type: Number, + default: 5, + }, tempAlertThreshold: { type: Number, default: 100, }, + tempAlertCounter: { + type: Number, + default: 5, + }, selectedDisks: { type: [String], default: [], diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 71d4a2a41..c942d749a 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -318,9 +318,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { notifications: notificationIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, + cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, + memoryAlertCounter: doc.memoryAlertCounter, diskAlertThreshold: doc.diskAlertThreshold, + diskAlertCounter: doc.diskAlertCounter, tempAlertThreshold: doc.tempAlertThreshold, + tempAlertCounter: doc.tempAlertCounter, selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, @@ -369,9 +373,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { notifications: notificationIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, + cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, + memoryAlertCounter: doc.memoryAlertCounter, diskAlertThreshold: doc.diskAlertThreshold, + diskAlertCounter: doc.diskAlertCounter, tempAlertThreshold: doc.tempAlertThreshold, + tempAlertCounter: doc.tempAlertCounter, selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index d92b351a9..da95f4292 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -33,9 +33,13 @@ export interface Monitor { notifications: string[]; secret?: string; cpuAlertThreshold: number; + cpuAlertCounter: number; memoryAlertThreshold: number; + memoryAlertCounter: number; diskAlertThreshold: number; + diskAlertCounter: number; tempAlertThreshold: number; + tempAlertCounter: number; selectedDisks: string[]; gameId?: string; group: string | null; From 62778886e1184e63f5470174b296d802058267c4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:55:47 +0000 Subject: [PATCH 313/392] use counters --- .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index ec8a792f2..621253f81 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -246,12 +246,12 @@ class SuperSimpleQueueHelper { const anyThresholdBreached = Object.values(breaches).some((b) => b); - // Check if countdown has reached zero for any threshold + // Check if countdown counter has reached zero for any breached threshold const shouldNotifyThreshold = - (breaches.cpu && monitor.cpuAlertThreshold <= 0) || - (breaches.memory && monitor.memoryAlertThreshold <= 0) || - (breaches.disk && monitor.diskAlertThreshold <= 0) || - (breaches.temp && monitor.tempAlertThreshold <= 0); + (breaches.cpu && monitor.cpuAlertCounter <= 0) || + (breaches.memory && monitor.memoryAlertCounter <= 0) || + (breaches.disk && monitor.diskAlertCounter <= 0) || + (breaches.temp && monitor.tempAlertCounter <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { From 22645d86f718ce3833edaf50f6d6a05b9ae8b2c6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:14:51 +0000 Subject: [PATCH 314/392] handle infra thresholds --- .../SuperSimpleQueueHelper.ts | 45 +++------------ .../service/infrastructure/statusService.ts | 56 +++++++++++++++++++ server/src/types/network.ts | 6 ++ 3 files changed, 71 insertions(+), 36 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 621253f81..3a5de5970 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -183,7 +183,7 @@ class SuperSimpleQueueHelper { } private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { - const { monitor, statusChanged, prevStatus } = statusChangeResult; + const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChangeResult; // Initialize result const decision: MonitorActionDecision = { @@ -213,45 +213,18 @@ class SuperSimpleQueueHelper { } // CASE 2: Hardware - const payload = networkResponse.payload as HardwareStatusPayload; - const metrics = payload?.data; - - if (!metrics) { - return decision; // No metrics, can't evaluate + if (!thresholdBreaches) { + return decision; // No breach data available } - // Evaluate threshold breaches - // CPU check - const cpuUsage = metrics.cpu?.usage_percent ?? -1; - const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; - - // Memory check - const memoryUsage = metrics.memory?.usage_percent ?? -1; - const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; - - // Disk check - const diskBreach = - metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; - - // Temperature check (alert if ANY sensor exceeds threshold) - const temps = metrics.cpu?.temperature ?? []; - const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); - - const breaches = { - cpu: cpuBreach, - memory: memoryBreach, - disk: diskBreach, - temp: tempBreach, - }; - - const anyThresholdBreached = Object.values(breaches).some((b) => b); + const anyThresholdBreached = Object.values(thresholdBreaches).some((b) => b); // Check if countdown counter has reached zero for any breached threshold const shouldNotifyThreshold = - (breaches.cpu && monitor.cpuAlertCounter <= 0) || - (breaches.memory && monitor.memoryAlertCounter <= 0) || - (breaches.disk && monitor.diskAlertCounter <= 0) || - (breaches.temp && monitor.tempAlertCounter <= 0); + (thresholdBreaches.cpu && monitor.cpuAlertCounter <= 0) || + (thresholdBreaches.memory && monitor.memoryAlertCounter <= 0) || + (thresholdBreaches.disk && monitor.diskAlertCounter <= 0) || + (thresholdBreaches.temp && monitor.tempAlertCounter <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { @@ -271,7 +244,7 @@ class SuperSimpleQueueHelper { decision.shouldSendNotification = true; decision.incidentReason = "threshold_breach"; decision.notificationReason = "threshold_breach"; - decision.thresholdBreaches = breaches; + decision.thresholdBreaches = thresholdBreaches; } return decision; diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 0ab47f85b..cf9e1945a 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -253,6 +253,61 @@ export class StatusService implements IStatusService { } monitor.status = newStatus; + + // Evaluate hardware threshold breaches (only for hardware monitors) + let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; + if (monitor.type === "hardware" && statusResponse.payload) { + const payload = statusResponse.payload as HardwareStatusPayload; + const metrics = payload?.data; + + if (metrics) { + // Evaluate threshold breaches + const cpuUsage = metrics.cpu?.usage_percent ?? -1; + const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; + + const memoryUsage = metrics.memory?.usage_percent ?? -1; + const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; + + const diskBreach = + metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; + + const temps = metrics.cpu?.temperature ?? []; + const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); + + thresholdBreaches = { + cpu: cpuBreach, + memory: memoryBreach, + disk: diskBreach, + temp: tempBreach, + }; + + // Update counters: decrement if breached, reset to 5 if not breached + if (cpuBreach) { + monitor.cpuAlertCounter = Math.max(0, monitor.cpuAlertCounter - 1); + } else { + monitor.cpuAlertCounter = 5; + } + + if (memoryBreach) { + monitor.memoryAlertCounter = Math.max(0, monitor.memoryAlertCounter - 1); + } else { + monitor.memoryAlertCounter = 5; + } + + if (diskBreach) { + monitor.diskAlertCounter = Math.max(0, monitor.diskAlertCounter - 1); + } else { + monitor.diskAlertCounter = 5; + } + + if (tempBreach) { + monitor.tempAlertCounter = Math.max(0, monitor.tempAlertCounter - 1); + } else { + monitor.tempAlertCounter = 5; + } + } + } + const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); return { @@ -261,6 +316,7 @@ export class StatusService implements IStatusService { prevStatus, code, timestamp: new Date().getTime(), + thresholdBreaches, }; } catch (error: any) { error.service = SERVICE_NAME; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 7fbfb79fe..d87f1ea4f 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -110,4 +110,10 @@ export type StatusChangeResult = { prevStatus: MonitorStatus; code: number; timestamp: number; + thresholdBreaches?: { + cpu: boolean; + memory: boolean; + disk: boolean; + temp: boolean; + }; }; From e50240885d7e608c5dbb51ea39339d5fc1721baf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:25:15 +0000 Subject: [PATCH 315/392] debug logging --- .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 3a5de5970..78e7d54d7 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -115,6 +115,10 @@ class SuperSimpleQueueHelper { // Step 4. Update monitor status const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); + // Step 5. Get decisions + const decision = this.evaluateMonitorAction(statusChangeResult); + console.log(JSON.stringify(decision, null, 2)); + // Step 5 handle notifications (best effort, continue even in event of failure, don't wait) this.notificationsService .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) @@ -182,7 +186,7 @@ class SuperSimpleQueueHelper { return maintenanceWindowIsActive; } - private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { + private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChangeResult; // Initialize result From b75c1a4fcbeb481c04b1c81d71845ebf3a7a9632 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:29:06 +0000 Subject: [PATCH 316/392] create incident on hardware breach --- .../SuperSimpleQueueHelper.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 78e7d54d7..7e4c94586 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -218,10 +218,22 @@ class SuperSimpleQueueHelper { // CASE 2: Hardware if (!thresholdBreaches) { - return decision; // No breach data available + // No breach data available - still handle status changes + if (statusChanged && monitor.status === "down") { + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } + return decision; } const anyThresholdBreached = Object.values(thresholdBreaches).some((b) => b); + const noThresholdsBreached = Object.values(thresholdBreaches).every((b) => !b); // Check if countdown counter has reached zero for any breached threshold const shouldNotifyThreshold = @@ -233,6 +245,7 @@ class SuperSimpleQueueHelper { // Decision logic for hardware if (statusChanged && monitor.status === "down") { // Hardware service is DOWN (unreachable) + // Status down takes precedence over threshold breaches decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "status_down"; @@ -243,12 +256,17 @@ class SuperSimpleQueueHelper { decision.shouldSendNotification = true; decision.notificationReason = "status_change"; } else if (anyThresholdBreached && shouldNotifyThreshold) { - // Threshold breach alert (service is UP but metrics are high) + // Threshold breach: Counters have reached zero + // Create incident if service is UP but metrics exceed thresholds decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "threshold_breach"; decision.notificationReason = "threshold_breach"; decision.thresholdBreaches = thresholdBreaches; + } else if (noThresholdsBreached && !anyThresholdBreached) { + // All thresholds returned to normal - resolve any active threshold incidents + decision.shouldResolveIncident = true; + // Don't send notification for resolution (can be added later if needed) } return decision; From 68db4b8701f76588f6ff74d900ba760399d034c0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:52:14 +0000 Subject: [PATCH 317/392] breached status --- .../v2/design-elements/StatusLabel.tsx | 2 + client/src/Types/Monitor.ts | 1 + client/src/Utils/MonitorUtils.ts | 3 + client/src/locales/en.json | 1 + .../SuperSimpleQueueHelper.ts | 69 +++---------------- .../service/infrastructure/statusService.ts | 31 ++++++++- server/src/types/monitor.ts | 2 +- 7 files changed, 48 insertions(+), 61 deletions(-) diff --git a/client/src/Components/v2/design-elements/StatusLabel.tsx b/client/src/Components/v2/design-elements/StatusLabel.tsx index cf404421e..19bb8d157 100644 --- a/client/src/Components/v2/design-elements/StatusLabel.tsx +++ b/client/src/Components/v2/design-elements/StatusLabel.tsx @@ -21,6 +21,8 @@ export const StatusLabel = ({ status, sx }: { status: MonitorStatus; sx?: SxProp return t("pages.common.monitors.status.up"); } else if (status === "down") { return t("pages.common.monitors.status.down"); + } else if (status === "breached") { + return t("pages.common.monitors.status.breached"); } else if (status === "maintenance") { return t("pages.common.monitors.status.maintenance"); } else if (status === "paused") { diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 0c7cea5bd..cf637c184 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -18,6 +18,7 @@ export const MonitorStatuses = [ "paused", "initializing", "maintenance", + "breached", ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 02c291efc..b73fc5bc4 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -23,6 +23,9 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => { if (status === "down") { return "error"; } + if (status === "breached") { + return "error"; + } return "warning"; }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8e18d8e2a..d8a46fb7c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -387,6 +387,7 @@ }, "status": { "down": "down", + "breached": "breached", "initializing": "initializing", "maintenance": "maintenance", "paused": "paused", diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 7e4c94586..4082368a7 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -187,7 +187,7 @@ class SuperSimpleQueueHelper { } private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { - const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChangeResult; + const { monitor, statusChanged, prevStatus } = statusChangeResult; // Initialize result const decision: MonitorActionDecision = { @@ -198,75 +198,28 @@ class SuperSimpleQueueHelper { notificationReason: null, }; - // CASE 1: Non-hardware - if (monitor.type !== "hardware") { - if (statusChanged) { - // This is the current state of the monitor AFTER the most recent check - if (monitor.status === "down") { - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "status_down"; - decision.notificationReason = "status_change"; - } else if (monitor.status === "up" && prevStatus === "down") { - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } - } + // Simplified logic: Just check status changes + if (!statusChanged) { return decision; } - // CASE 2: Hardware - if (!thresholdBreaches) { - // No breach data available - still handle status changes - if (statusChanged && monitor.status === "down") { - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "status_down"; - decision.notificationReason = "status_change"; - } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } - return decision; - } - - const anyThresholdBreached = Object.values(thresholdBreaches).some((b) => b); - const noThresholdsBreached = Object.values(thresholdBreaches).every((b) => !b); - - // Check if countdown counter has reached zero for any breached threshold - const shouldNotifyThreshold = - (thresholdBreaches.cpu && monitor.cpuAlertCounter <= 0) || - (thresholdBreaches.memory && monitor.memoryAlertCounter <= 0) || - (thresholdBreaches.disk && monitor.diskAlertCounter <= 0) || - (thresholdBreaches.temp && monitor.tempAlertCounter <= 0); - - // Decision logic for hardware - if (statusChanged && monitor.status === "down") { - // Hardware service is DOWN (unreachable) - // Status down takes precedence over threshold breaches + if (monitor.status === "down") { + // Monitor went down (unreachable) decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "status_down"; decision.notificationReason = "status_change"; - } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { - // Hardware service recovered - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } else if (anyThresholdBreached && shouldNotifyThreshold) { - // Threshold breach: Counters have reached zero - // Create incident if service is UP but metrics exceed thresholds + } else if (monitor.status === "breached") { + // Hardware monitor exceeded thresholds decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "threshold_breach"; decision.notificationReason = "threshold_breach"; - decision.thresholdBreaches = thresholdBreaches; - } else if (noThresholdsBreached && !anyThresholdBreached) { - // All thresholds returned to normal - resolve any active threshold incidents + } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { + // Monitor recovered from down or breached state decision.shouldResolveIncident = true; - // Don't send notification for resolution (can be added later if needed) + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; } return decision; diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index cf9e1945a..b01131bbe 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -252,8 +252,6 @@ export class StatusService implements IStatusService { statusChanged = true; } - monitor.status = newStatus; - // Evaluate hardware threshold breaches (only for hardware monitors) let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; if (monitor.type === "hardware" && statusResponse.payload) { @@ -305,9 +303,38 @@ export class StatusService implements IStatusService { } else { monitor.tempAlertCounter = 5; } + + // Check if any counter has reached zero (initial breach) + const anyCounterZero = + monitor.cpuAlertCounter === 0 || monitor.memoryAlertCounter === 0 || monitor.diskAlertCounter === 0 || monitor.tempAlertCounter === 0; + + const anyThresholdBreached = cpuBreach || memoryBreach || diskBreach || tempBreach; + const allThresholdsNormal = !cpuBreach && !memoryBreach && !diskBreach && !tempBreach; + + // Update monitor status based on threshold breach state + if (newStatus !== "down") { + // Don't override "down" status - service unreachable takes precedence + // Check current monitor status, not newStatus for comparison + if (anyCounterZero && anyThresholdBreached && monitor.status !== "breached") { + // Initial breach: counter hit zero, change status to breached + newStatus = "breached"; + statusChanged = true; + } else if (anyCounterZero && anyThresholdBreached && monitor.status === "breached") { + // Already breached, keep status but don't mark as changed + newStatus = "breached"; + // statusChanged remains false + } else if (allThresholdsNormal && monitor.status === "breached") { + // All thresholds returned to normal, recover from breached state + newStatus = "up"; + statusChanged = true; + } + } } } + // Apply the final status + monitor.status = newStatus; + const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); return { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index da95f4292..021172c3f 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -4,7 +4,7 @@ export type { CheckSnapshot } from "@/types/check.js"; export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; -export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance"] as const; +export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; From 32a7bd6543b4b72f8b5b7038a601fbb1fa4f70d3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:57:27 +0000 Subject: [PATCH 318/392] add breached status to summary header --- .../Components/v2/design-elements/StatusBox.tsx | 11 +++++++++++ .../v2/monitors/HeaderMonitorsSummary.tsx | 2 ++ client/src/Types/Monitor.ts | 1 + .../monitors/MongoMonitorsRepository.ts | 17 ++++++++++++++++- server/src/types/monitor.ts | 1 + 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/v2/design-elements/StatusBox.tsx index 1be76988b..73a881736 100644 --- a/client/src/Components/v2/design-elements/StatusBox.tsx +++ b/client/src/Components/v2/design-elements/StatusBox.tsx @@ -139,6 +139,17 @@ export const InitializingStatusBox = ({ n }: { n: number }) => { /> ); }; +export const BreachedStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; export const TotalChecksBox = ({ n }: { n: number }) => { const theme = useTheme(); diff --git a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx index b2a93a807..49a4fd471 100644 --- a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx +++ b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx @@ -3,6 +3,7 @@ import { DownStatusBox, PausedStatusBox, InitializingStatusBox, + BreachedStatusBox, } from "@/Components/v2/design-elements"; import Stack from "@mui/material/Stack"; @@ -22,6 +23,7 @@ export const HeaderMonitorsSummary = ({ summary }: MonitorsSummaryProps) => { > + diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index cf637c184..043076704 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -72,6 +72,7 @@ export interface MonitorsSummary { pausedMonitors: number; initializingMonitors: number; maintenanceMonitors: number; + breachedMonitors: number; } export interface MonitorsWithChecksResponse { diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index c942d749a..c6f3266a6 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -252,13 +252,28 @@ class MongoMonitorsRepository implements IMonitorsRepository { $cond: [{ $eq: ["$status", "maintenance"] }, 1, 0], }, }, + breachedMonitors: { + $sum: { + $cond: [{ $eq: ["$status", "breached"] }, 1, 0], + }, + }, }, }, { $project: { _id: 0 } }, ]; const [summary] = await MonitorModel.aggregate(pipeline); - return summary ?? { totalMonitors: 0, upMonitors: 0, downMonitors: 0, pausedMonitors: 0, initializingMonitors: 0, maintenanceMonitors: 0 }; + return ( + summary ?? { + totalMonitors: 0, + upMonitors: 0, + downMonitors: 0, + pausedMonitors: 0, + initializingMonitors: 0, + maintenanceMonitors: 0, + breachedMonitors: 0, + } + ); }; findGroupsByTeamId = async (teamId: string): Promise => { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 021172c3f..a196aaae6 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -55,6 +55,7 @@ export interface MonitorsSummary { pausedMonitors: number; initializingMonitors: number; maintenanceMonitors: number; + breachedMonitors: number; } export interface MonitorsWithChecksByTeamIdResult { From 70ae15725eeaa1b75040953a046892190bee6c82 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 23:09:27 +0000 Subject: [PATCH 319/392] conditinally render breached stauts box --- .../src/Components/v2/monitors/HeaderMonitorsSummary.tsx | 8 ++++++-- client/src/Pages/Infrastructure/Monitors/index.tsx | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx index 49a4fd471..38a6adfde 100644 --- a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx +++ b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx @@ -12,9 +12,13 @@ import { useTheme } from "@mui/material"; interface MonitorsSummaryProps { summary: MonitorsSummary | null; + showBreached?: boolean; } -export const HeaderMonitorsSummary = ({ summary }: MonitorsSummaryProps) => { +export const HeaderMonitorsSummary = ({ + summary, + showBreached = false, +}: MonitorsSummaryProps) => { const theme = useTheme(); return ( { > - + {showBreached && } diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 01de1c09c..8d1ef1a3a 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -133,7 +133,10 @@ const InfrastructureMonitors = () => { isLoading={isLoading} isAdmin={isAdmin} /> - + Date: Sun, 15 Feb 2026 18:09:21 +0000 Subject: [PATCH 320/392] use decision object in incidentService --- .../src/service/business/incidentService.ts | 28 +++++++---- .../SuperSimpleQueueHelper.ts | 47 +++++++++---------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 881f98f56..bcb17d3a5 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -4,6 +4,7 @@ import { AppError } from "@/utils/AppError.js"; import { ParseBoolean } from "@/utils/utils.js"; import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; import type { Incident } from "@/types/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; const dateRangeLookup: Record = { recent: new Date(new Date().setHours(new Date().getHours() - 2)), @@ -43,10 +44,14 @@ class IncidentService { return IncidentService.SERVICE_NAME; } - handleIncident = async (monitor: Monitor, code: number): Promise => { + handleIncident = async (monitor: Monitor, code: number, decision: MonitorActionDecision): Promise => { + if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) { + return null; + } + const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId); - // Monitor is down, create an incident - if (monitor.status === "down") { + + if (decision.shouldCreateIncident) { if (activeIncident) { return activeIncident; } else { @@ -61,14 +66,17 @@ class IncidentService { } } - // Monitor is up, resolve active incidents - if (!activeIncident) { - return null; + if (decision.shouldResolveIncident) { + if (!activeIncident) { + return null; + } + activeIncident.status = false; + activeIncident.endTime = Date.now().toString(); + activeIncident.resolutionType = "automatic"; + return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); } - activeIncident.status = false; - activeIncident.endTime = Date.now().toString(); - activeIncident.resolutionType = "automatic"; - return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); + + return null; }; resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 4082368a7..84a26d1e2 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -117,33 +117,32 @@ class SuperSimpleQueueHelper { // Step 5. Get decisions const decision = this.evaluateMonitorAction(statusChangeResult); - console.log(JSON.stringify(decision, null, 2)); - // Step 5 handle notifications (best effort, continue even in event of failure, don't wait) - this.notificationsService - .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) - .catch((error: any) => { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "getMonitorJob", - details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, - stack: error.stack, + // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) + if (decision.shouldSendNotification) { + this.notificationsService + .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) + .catch((error: any) => { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getMonitorJob", + details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, + stack: error.stack, + }); }); - }); - - // Step 6. Handle incidents (best effort, don't wait) - if (statusChangeResult.statusChanged) { - this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code).catch((error: any) => { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "getMonitorJob", - details: `Error handling incident for job ${monitor.id}: ${error.message}`, - stack: error.stack, - }); - }); } + + // Step 7. Handle incidents (best effort, don't wait) + this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision).catch((error: any) => { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "getMonitorJob", + details: `Error handling incident for job ${monitor.id}: ${error.message}`, + stack: error.stack, + }); + }); } catch (error: any) { this.logger.warn({ message: error.message, From 8020bfde21d5611050d6e0ebfb38affa0149b666 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 15 Feb 2026 18:20:42 +0000 Subject: [PATCH 321/392] refactor handleIncidents --- .../SuperSimpleQueueHelper.ts | 18 ++-- .../notificationProviders/utils.ts | 87 +++++++------------ .../infrastructure/notificationsService.ts | 42 ++------- 3 files changed, 46 insertions(+), 101 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 84a26d1e2..274318ad7 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -120,17 +120,15 @@ class SuperSimpleQueueHelper { // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) if (decision.shouldSendNotification) { - this.notificationsService - .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) - .catch((error: any) => { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "getMonitorJob", - details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, - stack: error.stack, - }); + this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getMonitorJob", + details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, + stack: error.stack, }); + }); } // Step 7. Handle incidents (best effort, don't wait) diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index ed9d0177c..b258ccd52 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -27,15 +27,22 @@ export const buildHardwareAlerts = ( const cpuThreshold = monitor.cpuAlertThreshold !== undefined ? monitor.cpuAlertThreshold / 100 : -1; const memoryThreshold = monitor.memoryAlertThreshold !== undefined ? monitor.memoryAlertThreshold / 100 : -1; const diskThreshold = monitor.diskAlertThreshold !== undefined ? monitor.diskAlertThreshold / 100 : -1; + const tempThreshold = monitor.tempAlertThreshold !== undefined ? monitor.tempAlertThreshold : -1; const payload = networkResponse?.payload as HardwareStatusPayload; const metrics = payload.data || {}; - const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; + const { cpu = {}, memory = {}, disk = [] } = metrics; + const cpuUsage = cpu.usage_percent ?? -1; + const memoryUsage = memory.usage_percent ?? -1; + // Get max temperature from CPU temperature sensors array + const temps = cpu.temperature ?? []; + const maxTemp = temps.length > 0 ? Math.max(...temps) : -1; const alerts: Record = { cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false, memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false, disk: disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold) ?? false, + temp: tempThreshold !== -1 && maxTemp > tempThreshold ? true : false, }; const alertsToSend = []; @@ -45,6 +52,7 @@ export const buildHardwareAlerts = ( { name: "URL", value: monitor.url, inline: false }, ]; const goToIncidentField = { name: `Go to incident`, value: `${clientHost}/infrastructure/${monitor.id}` }; + const formatDiscordAlert = { cpu: () => ({ title: "CPU alert", @@ -77,38 +85,33 @@ export const buildHardwareAlerts = ( goToIncidentField, ], }), + + temp: () => ({ + title: "Temperature alert", + description: `Your current temperature (${maxTemp.toFixed(0)}°C) is above your threshold (${tempThreshold.toFixed(0)}°C)`, + color: 15548997, + fields: [...monitorInfoFields, goToIncidentField], + footer: { text: "Checkmate" }, + }), }; - const alertTypes = ["cpu", "memory", "disk"] as const; - const alertThresholdKeyMap: Record<(typeof alertTypes)[number], "cpuAlertThreshold" | "memoryAlertThreshold" | "diskAlertThreshold"> = { - cpu: "cpuAlertThreshold", - memory: "memoryAlertThreshold", - disk: "diskAlertThreshold", - }; + + const alertTypes = ["cpu", "memory", "disk", "temp"] as const; + for (const type of alertTypes) { - const thresholdKey = alertThresholdKeyMap[type]; - // Iterate over each alert type to see if any need to be decremented if (alerts[type] === true) { - const nextValue = monitor[thresholdKey] - 1; - monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered - - if (monitor[thresholdKey] <= 0) { - // If threshold drops below 0, reset and send notification - monitor[thresholdKey] = 100; - - const formatAlert = { - cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, - memory: () => - `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, - disk: () => - `Your current disk usage: ${disk - .map((d, idx) => `(Disk${idx}: ${(d?.usage_percent ?? 0 * 100).toFixed(0)}%)`) - .join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, - }; - alertsToSend.push(formatAlert[type]()); - discordEmbeds.push(formatDiscordAlert[type]()); - } + const formatAlert = { + cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, + memory: () => + `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, + disk: () => + `Your current disk usage: ${disk.map((d, idx) => `(Disk${idx}: ${(d?.usage_percent ?? 0 * 100).toFixed(0)}%)`).join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, + temp: () => `Your current temperature (${maxTemp.toFixed(0)}°C) is above your threshold (${tempThreshold.toFixed(0)}°C)`, + }; + alertsToSend.push(formatAlert[type]()); + discordEmbeds.push(formatDiscordAlert[type]()); } } + const discordPayload = discordEmbeds.length ? { embeds: discordEmbeds } : null; return { alertsToSend, discordPayload }; }; @@ -125,34 +128,6 @@ export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): st return content; }; -export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: MonitorStatusResponse): boolean => { - // Thresholds are stored as percentages (0-100), convert to decimal (0-1) for comparison - const cpuThreshold = monitor.cpuAlertThreshold !== undefined ? monitor.cpuAlertThreshold / 100 : -1; - const memoryThreshold = monitor.memoryAlertThreshold !== undefined ? monitor.memoryAlertThreshold / 100 : -1; - const diskThreshold = monitor.diskAlertThreshold !== undefined ? monitor.diskAlertThreshold / 100 : -1; - - const payload = networkResponse?.payload as HardwareStatusPayload; - const metrics = payload.data || {}; - const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; - - const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold; - if (cpuBreach && monitor.cpuAlertThreshold - 1 <= 0) { - return true; - } - - const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold; - if (memoryBreach && monitor.memoryAlertThreshold - 1 <= 0) { - return true; - } - - const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold); - if (diskBreach && monitor.diskAlertThreshold - 1 <= 0) { - return true; - } - - return false; -}; - export const buildWebhookBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { const { status, code } = monitorStatusResponse; const { localTimeZone, localTime, utcTime } = getTime(); diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index b2ae59b7c..6174dceb3 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -1,7 +1,7 @@ import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification, MonitorStatus } from "@/types/index.js"; -import { shouldSendHardwareAlert } from "@/service/infrastructure/notificationProviders/utils.js"; import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; @@ -9,12 +9,7 @@ export interface INotificationsService { findNotificationsByTeamId: (teamId: string) => Promise; updateById(id: string, teamId: string, updateData: Partial): Promise; deleteById: (id: string, teamId: string) => Promise; - handleNotifications: ( - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - prevStatus: MonitorStatus, - statusChanged: boolean - ) => Promise; + handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise; sendTestNotification: (notification: Notification) => Promise; testAllNotifications: (notificationIds: string[]) => Promise; @@ -229,36 +224,13 @@ export class NotificationsService implements INotificationsService { return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus); }; - handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, prevStatus: MonitorStatus, statusChanged: boolean) => { - const { type } = monitor; - const payload = monitorStatusResponse.payload as HardwareStatusPayload; - // If this is a non-hardeware type monitor and status did not change, we're done - if (type !== "hardware" && statusChanged === false) return false; - // if prevStatus is undefined, monitor is resuming, we're done - if (type !== "hardware" && prevStatus === undefined) return false; - - // Deal with hardware thresholds - if (type === "hardware") { - // Check if any thresholds are set - const hasThresholds = - monitor.cpuAlertThreshold !== undefined || - monitor.memoryAlertThreshold !== undefined || - monitor.diskAlertThreshold !== undefined || - monitor.tempAlertThreshold !== undefined; - - if (!hasThresholds) return false; // No thresholds set, we're done - const metrics = payload?.data ?? null; - if (metrics === null) return false; // No metrics, we're done - - // We should send a notificaiton - - const shouldSend = shouldSendHardwareAlert(monitor, monitorStatusResponse); - if (shouldSend === false) return false; - - return await this.sendNotifications(monitor, monitorStatusResponse); + handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + // Early return if no notification should be sent + if (!decision.shouldSendNotification) { + return false; } - // We should send a notification for non-hardware monitor status change + // Send notifications based on decision return await this.sendNotifications(monitor, monitorStatusResponse); }; From be8580ee996cba398b5c5a428d1681dadb88b2b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 15 Feb 2026 18:35:54 +0000 Subject: [PATCH 322/392] update providers --- .../INotificationProvider.ts | 8 ++++- .../notificationProviders/discord.ts | 12 +++++-- .../notificationProviders/email.ts | 30 +++++++++++++--- .../notificationProviders/matrix.ts | 19 +++++++--- .../notificationProviders/pagerduty.ts | 24 ++++++++++--- .../notificationProviders/slack.ts | 24 ++++++++++--- .../notificationProviders/utils.ts | 4 +-- .../notificationProviders/webhook.ts | 19 +++++++--- .../infrastructure/notificationsService.ts | 35 ++++++++++++++----- 9 files changed, 140 insertions(+), 35 deletions(-) diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 2d363d6b3..0a1dbf876 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -1,6 +1,12 @@ import type { Monitor, Notification, Alert, MonitorStatusResponse } from "@/types/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; export interface INotificationProvider { - sendAlert: (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => Promise; + sendAlert: ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => Promise; sendTestAlert(notification: Notification): Promise; } diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index ad1f2f28a..200b8af2c 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "DiscordProvider"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, buildDiscordBody, getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; import got from "got"; @@ -10,15 +11,20 @@ export class DiscordProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + // For status changes (recovery), use standard format + if (decision.notificationReason === "status_change") { + return buildDiscordBody(monitor, monitorStatusResponse); + } + // For threshold breaches, use hardware alert format const { discordPayload } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); return discordPayload; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent(monitor, monitorStatusResponse, decision); } else { body = buildDiscordBody(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index 32ccf1859..802c44b80 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "EmailProvider"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, buildHardwareEmail, buildEmail, buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js"; export class EmailProvider implements INotificationProvider { @@ -12,21 +13,42 @@ export class EmailProvider implements INotificationProvider { this.logger = logger; } - private buildHardwareEmail = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private buildHardwareEmail = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + // For status changes (recovery), use standard email format + if (decision.notificationReason === "status_change") { + return await buildEmail(this.emailService, monitor); + } + // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); const html = buildHardwareEmail(this.emailService, monitor, alertsToSend); return html; }; - async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + async sendAlert( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise { // For grouped notifications (identified by ":" in name), customize subject to indicate multiple services. // Example: "2 services: Service A, Service B" becomes "Alert: 2 services are down" const isGroupedNotification = monitor.name.includes(":"); - const subject = isGroupedNotification ? `Alert: ${monitor.name} are down` : `Monitor ${monitor.name} is down`; + + // Build subject based on notification reason and monitor status + let subject: string; + if (isGroupedNotification) { + subject = `Alert: ${monitor.name} are down`; + } else if (decision.notificationReason === "threshold_breach") { + subject = `Monitor ${monitor.name} threshold breached`; + } else if (monitor.status === "up") { + subject = `Monitor ${monitor.name} is back up`; + } else { + subject = `Monitor ${monitor.name} is down`; + } let html; if (monitor.type === "hardware") { - html = this.buildHardwareEmail(monitor, monitorStatusResponse); + html = await this.buildHardwareEmail(monitor, monitorStatusResponse, decision); } else { html = await buildEmail(this.emailService, monitor); } diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index 8914dbf80..5a65a7909 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "MatrixProvider"; import got from "got"; import type { INotificationProvider } from "@/service/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js"; import { buildHardwareAlerts, @@ -15,9 +16,19 @@ export class MatrixProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private getHardwareContent = ( + clientHost: string, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => { + // For status changes (recovery), use standard format + if (decision.notificationReason === "status_change") { + return buildWebhookBody(monitor, monitorStatusResponse); + } + // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,12 +37,12 @@ export class MatrixProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { const { homeserverUrl, accessToken, roomId } = notification; let content; if (monitor.type === "hardware") { - content = this.getHardwareContent(monitor, monitorStatusResponse); + content = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { content = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index f65e50c68..8ed4ccc68 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "PagerDutyProvider"; import got from "got"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { INotificationProvider } from "@/service/index.js"; import { buildHardwareAlerts, @@ -15,9 +16,19 @@ export class PagerDutyProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private getHardwareContent = ( + clientHost: string, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => { + // For status changes (recovery), use standard format + if (decision.notificationReason === "status_change") { + return buildWebhookBody(monitor, monitorStatusResponse); + } + // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,10 +37,15 @@ export class PagerDutyProvider implements INotificationProvider { return body; }; - async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + async sendAlert( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index 2aeb442eb..0e7710b3f 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "SlackProvider"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, buildHardwareWebhookBody, @@ -15,9 +16,19 @@ export class SlackProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private getHardwareContent = ( + clientHost: string, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => { + // For status changes (recovery), use standard format + if (decision.notificationReason === "status_change") { + return buildWebhookBody(monitor, monitorStatusResponse); + } + // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,10 +37,15 @@ export class SlackProvider implements INotificationProvider { return body; }; - async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + async sendAlert( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index b258ccd52..bb1c928ca 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -123,8 +123,8 @@ export const buildHardwareNotificationMessage = (clientHost: string, alerts: any return alertText.map((alert) => alert).join("\n"); }; -export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): string => { - const content = alerts.map((alert) => alert).join("\n"); +export const buildHardwareWebhookBody = (clientHost: string, alerts: string[], monitor: Monitor): string => { + const content = buildHardwareNotificationMessage(clientHost, alerts, monitor); return content; }; diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 9e743ae53..dfc5f4763 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "WebhookProvider"; import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, buildHardwareWebhookBody, @@ -15,9 +16,19 @@ export class WebhookProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private getHardwareContent = ( + clientHost: string, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => { + // For status changes (recovery), use standard format + if (decision.notificationReason === "status_change") { + return buildWebhookBody(monitor, monitorStatusResponse); + } + // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,10 +37,10 @@ export class WebhookProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 6174dceb3..f43402f30 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -41,6 +41,7 @@ export class NotificationsService implements INotificationsService { createdAt: number; } >; + private currentDecision?: MonitorActionDecision; constructor( notificationsRepository: INotificationsRepository, @@ -83,17 +84,17 @@ export class NotificationsService implements INotificationsService { private send = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise => { switch (notification.type) { case "email": - return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "slack": - return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "discord": - return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "pager_duty": - return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "matrix": - return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "webhook": - return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); default: return false; } @@ -157,7 +158,15 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups.delete(key); try { - await this.flushEmailGroup(notification, group.monitors, group.statusResponses); + // For grouped notifications (always DOWN), create a decision + const groupedDecision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: "status_down", + notificationReason: "status_change", + }; + await this.flushEmailGroup(notification, group.monitors, group.statusResponses, groupedDecision); } catch (error: any) { this.logger.error({ message: error?.message, @@ -196,7 +205,12 @@ export class NotificationsService implements INotificationsService { * @param statusResponses Array of status responses (parallel to monitors) * @returns true if email was sent successfully, false otherwise */ - private flushEmailGroup = async (notification: Notification, monitors: Monitor[], statusResponses: MonitorStatusResponse[]): Promise => { + private flushEmailGroup = async ( + notification: Notification, + monitors: Monitor[], + statusResponses: MonitorStatusResponse[], + decision: MonitorActionDecision + ): Promise => { if (!monitors.length || !statusResponses.length) { return false; } @@ -221,7 +235,7 @@ export class NotificationsService implements INotificationsService { }; // Reuse existing email provider to send grouped notification. - return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus); + return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision); }; handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { @@ -230,6 +244,9 @@ export class NotificationsService implements INotificationsService { return false; } + // Store decision for use in send method + this.currentDecision = decision; + // Send notifications based on decision return await this.sendNotifications(monitor, monitorStatusResponse); }; From 412a3b7cc9d0801687cdc98c411a6e4f1bdad9e7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 00:17:51 +0000 Subject: [PATCH 323/392] remove redundant method --- .../infrastructure/notificationProviders/matrix.ts | 4 ++-- .../infrastructure/notificationProviders/pagerduty.ts | 4 ++-- .../infrastructure/notificationProviders/slack.ts | 4 ++-- .../infrastructure/notificationProviders/utils.ts | 9 ++------- .../infrastructure/notificationProviders/webhook.ts | 4 ++-- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index 5a65a7909..afca6da95 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -5,7 +5,7 @@ import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimple import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class MatrixProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 8ed4ccc68..0634f46b4 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -5,7 +5,7 @@ import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimple import { INotificationProvider } from "@/service/index.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class PagerDutyProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index 0e7710b3f..da7338a75 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -4,7 +4,7 @@ import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class SlackProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index bb1c928ca..72797c1d0 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -116,16 +116,11 @@ export const buildHardwareAlerts = ( return { alertsToSend, discordPayload }; }; -export const buildHardwareNotificationMessage = (clientHost: string, alerts: any, monitor: Monitor) => { +export const buildHardwareNotificationMessage = (clientHost: string, alerts: string[], monitor: Monitor): string => { const alertsHeader = [`Monitor: ${monitor.name}`, `URL: ${monitor.url}`]; const alertFooter = [`Go to incident: ${clientHost}/infrastructure/${monitor.id}`]; const alertText = alerts.length > 0 ? [...alertsHeader, ...alerts, ...alertFooter] : []; - return alertText.map((alert) => alert).join("\n"); -}; - -export const buildHardwareWebhookBody = (clientHost: string, alerts: string[], monitor: Monitor): string => { - const content = buildHardwareNotificationMessage(clientHost, alerts, monitor); - return content; + return alertText.join("\n"); }; export const buildWebhookBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index dfc5f4763..13a0ae8ef 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -4,7 +4,7 @@ import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class WebhookProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; From a8ecd44abf44cf80ea1fd6e4787046da6109400b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 00:39:34 +0000 Subject: [PATCH 324/392] inject settings service, extract client host --- server/src/config/services.ts | 1 + .../INotificationProvider.ts | 3 ++- .../notificationProviders/discord.ts | 19 +++++++++++--- .../notificationProviders/email.ts | 14 +++++++--- .../notificationProviders/matrix.ts | 12 ++++++--- .../notificationProviders/pagerduty.ts | 7 ++--- .../notificationProviders/slack.ts | 7 ++--- .../notificationProviders/webhook.ts | 12 ++++++--- .../infrastructure/notificationsService.ts | 26 ++++++++++++++----- 9 files changed, 73 insertions(+), 28 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index f88bc2584..67c16ff6a 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -182,6 +182,7 @@ export const initializeServices = async ({ discordProvider, pagerDutyProvider, matrixProvider, + settingsService, logger ); diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 0a1dbf876..21a221c9c 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -6,7 +6,8 @@ export interface INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + clientHost: string ) => Promise; sendTestAlert(notification: Notification): Promise; } diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index 200b8af2c..560c18aae 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -11,20 +11,31 @@ export class DiscordProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + private getHardwareContent = ( + clientHost: string, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => { // For status changes (recovery), use standard format if (decision.notificationReason === "status_change") { return buildDiscordBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { discordPayload } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { discordPayload } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); return discordPayload; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + sendAlert = async ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = buildDiscordBody(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index 802c44b80..fd15034d1 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -13,13 +13,18 @@ export class EmailProvider implements INotificationProvider { this.logger = logger; } - private buildHardwareEmail = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + private buildHardwareEmail = async ( + clientHost: string, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => { // For status changes (recovery), use standard email format if (decision.notificationReason === "status_change") { return await buildEmail(this.emailService, monitor); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const html = buildHardwareEmail(this.emailService, monitor, alertsToSend); return html; }; @@ -28,7 +33,8 @@ export class EmailProvider implements INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + clientHost: string ): Promise { // For grouped notifications (identified by ":" in name), customize subject to indicate multiple services. // Example: "2 services: Service A, Service B" becomes "Alert: 2 services are down" @@ -48,7 +54,7 @@ export class EmailProvider implements INotificationProvider { let html; if (monitor.type === "hardware") { - html = await this.buildHardwareEmail(monitor, monitorStatusResponse, decision); + html = await this.buildHardwareEmail(clientHost, monitor, monitorStatusResponse, decision); } else { html = await buildEmail(this.emailService, monitor); } diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index afca6da95..f2543886d 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -27,7 +27,7 @@ export class MatrixProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -37,12 +37,18 @@ export class MatrixProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + sendAlert = async ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ) => { const { homeserverUrl, accessToken, roomId } = notification; let content; if (monitor.type === "hardware") { - content = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + content = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { content = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 0634f46b4..291cd7de5 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -27,7 +27,7 @@ export class PagerDutyProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -41,11 +41,12 @@ export class PagerDutyProvider implements INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + clientHost: string ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index da7338a75..f99af48d4 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -27,7 +27,7 @@ export class SlackProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -41,11 +41,12 @@ export class SlackProvider implements INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + clientHost: string ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 13a0ae8ef..17d871950 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -27,7 +27,7 @@ export class WebhookProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -37,10 +37,16 @@ export class WebhookProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + sendAlert = async ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index f43402f30..e3dc36747 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -2,6 +2,7 @@ import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notificatio import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { ISettingsService } from "@/service/system/settingsService.js"; export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; @@ -29,6 +30,7 @@ export class NotificationsService implements INotificationsService { private pagerDutyProvider: INotificationProvider; private matrixProvider: INotificationProvider; private logger: any; + private settingsService: ISettingsService; // Email grouping (batching) configuration private emailGroupingWindowMs: number; @@ -52,6 +54,7 @@ export class NotificationsService implements INotificationsService { discordProvider: INotificationProvider, pagerDutyProvider: INotificationProvider, matrixProvider: INotificationProvider, + settingsService: ISettingsService, logger: any ) { this.notificationsRepository = notificationsRepository; @@ -62,6 +65,7 @@ export class NotificationsService implements INotificationsService { this.discordProvider = discordProvider; this.pagerDutyProvider = pagerDutyProvider; this.matrixProvider = matrixProvider; + this.settingsService = settingsService; this.logger = logger; // Configure email grouping window (in milliseconds). @@ -82,19 +86,21 @@ export class NotificationsService implements INotificationsService { } private send = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise => { + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; switch (notification.type) { case "email": - return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "slack": - return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "discord": - return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "pager_duty": - return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "matrix": - return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "webhook": - return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); default: return false; } @@ -215,6 +221,12 @@ export class NotificationsService implements INotificationsService { return false; } + const { clientHost } = this.settingsService.getSettings(); + if (!clientHost) { + this.logger.warn({ message: "CLIENT_HOST not configured", service: SERVICE_NAME, method: "flushEmailGroup" }); + return false; + } + // Build a combined monitor name listing all affected services. // Example: "Service A, Service B" (2 services) or "Service A" (1 service) const uniqueNames = Array.from(new Set(monitors.map((m) => m.name))); @@ -235,7 +247,7 @@ export class NotificationsService implements INotificationsService { }; // Reuse existing email provider to send grouped notification. - return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision); + return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision, clientHost); }; handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { From 93c15588a4f46b7551bdd3f5eb6980a26f848e14 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Mon, 16 Feb 2026 00:09:08 -0500 Subject: [PATCH 325/392] implement schema zod and validation on settings --- client/src/Hooks/useSettingsForm.ts | 54 ++-- .../Pages/Settings/SettingsDemoMonitors.jsx | 25 +- client/src/Pages/Settings/SettingsEmail.jsx | 194 ++++++++------ client/src/Pages/Settings/SettingsExport.jsx | 36 ++- .../Settings/SettingsGlobalThresholds.jsx | 52 ++-- .../src/Pages/Settings/SettingsPagespeed.jsx | 44 ++-- client/src/Pages/Settings/SettingsStats.jsx | 16 +- .../src/Pages/Settings/SettingsTimeZone.jsx | 25 +- client/src/Pages/Settings/SettingsUI.jsx | 20 +- client/src/Pages/Settings/SettingsURL.jsx | 30 ++- client/src/Pages/Settings/index.jsx | 237 +++++------------- client/src/Validation/settings.ts | 32 ++- 12 files changed, 343 insertions(+), 422 deletions(-) diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 6abf386be..17a67af25 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -4,32 +4,34 @@ import type { Settings } from "@/Types/Settings"; import type { SettingsFormData } from "@/Validation/settings"; interface UseSettingsFormOptions { - data?: Settings | null; + data?: Settings | null; } export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => { - return useMemo(() => { - const defaults: SettingsFormData = { - systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS || false, - systemEmailRequireTLS: data?.systemEmailRequireTLS || false, - systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized || true, - systemEmailConnectionHost: data?.systemEmailConnectionHost || "", - systemEmailSecure: data?.systemEmailSecure || true, - systemEmailPool: data?.systemEmailPool || false, - showURL: data?.showURL || false, - //timezone: data?.timezone || "UTC", - language: data?.language || "en", - //chartType: data?.chartType || "histogram", - checkTTL: data?.checkTTL || 30, - pagespeedApiKey: data?.pagespeedApiKey || "", - systemEmailHost: data?.systemEmailHost || "", - systemEmailPort: data?.systemEmailPort || "", - systemEmailAddress: data?.systemEmailAddress || "", - systemEmailUser: data?.systemEmailUser || "", - systemEmailPassword: data?.systemEmailPassword || "", - - } + return useMemo(() => { + const defaults: SettingsFormData = { + systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS || false, + systemEmailRequireTLS: data?.systemEmailRequireTLS || false, + systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized || true, + systemEmailSecure: data?.systemEmailSecure || false, + systemEmailPool: data?.systemEmailPool || false, + showURL: data?.showURL || false, + systemEmailHost: data?.systemEmailHost || "", + systemEmailUser: data?.systemEmailUser || "", + systemEmailAddress: data?.systemEmailAddress || "", + systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost", + systemEmailTLSServername: data?.systemEmailTLSServername || "", + systemEmailPort: data?.systemEmailPort || "", + globalThresholds: { + cpu: data?.globalThresholds?.cpu || "", + memory: data?.globalThresholds?.memory || "", + disk: data?.globalThresholds?.disk || "", + temperature: data?.globalThresholds?.temperature || "", + }, + checkTTL: data?.checkTTL || 30, + pagespeedApiKey: "", + systemEmailPassword: "", + }; - - - } -} \ No newline at end of file + return { schema: settingsSchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/Settings/SettingsDemoMonitors.jsx b/client/src/Pages/Settings/SettingsDemoMonitors.jsx index 47e699851..eb7f86c7a 100644 --- a/client/src/Pages/Settings/SettingsDemoMonitors.jsx +++ b/client/src/Pages/Settings/SettingsDemoMonitors.jsx @@ -8,8 +8,9 @@ import { PropTypes } from "prop-types"; import { useTranslation } from "react-i18next"; import Dialog from "@/Components/v1/Dialog/index.jsx"; import { useState } from "react"; +import { useDelete, usePost } from "@/Hooks/UseApi"; -const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) => { +const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, isLoading }) => { const { t } = useTranslation(); const theme = useTheme(); // Local state @@ -18,7 +19,8 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) = if (!isAdmin) { return null; } - + const { post: postDemoMonitors } = usePost(); + const { deleteFn: deleteAllMonitorsFn } = useDelete(); return ( <> @@ -38,13 +40,8 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) = variant="contained" color="accent" loading={isLoading} - onClick={() => { - const syntheticEvent = { - target: { - name: "demo", - }, - }; - handleChange(syntheticEvent); + onClick={async () => { + await postDemoMonitors("/monitors/demo", {}); }} sx={{ mt: theme.spacing(4) }} > @@ -81,13 +78,8 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) = title={t("settingsPage.systemResetSettings.dialogTitle")} onCancel={() => setIsOpen(false)} confirmationButtonLabel={t("settingsPage.systemResetSettings.dialogConfirm")} - onConfirm={() => { - const syntheticEvent = { - target: { - name: "deleteMonitors", - }, - }; - handleChange(syntheticEvent); + onConfirm={async () => { + await deleteAllMonitorsFn("/monitors/"); setIsOpen(false); }} isLoading={isLoading} @@ -99,7 +91,6 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) = SettingsDemoMonitors.propTypes = { isAdmin: PropTypes.bool, - handleChange: PropTypes.func, HEADER_SX: PropTypes.object, }; diff --git a/client/src/Pages/Settings/SettingsEmail.jsx b/client/src/Pages/Settings/SettingsEmail.jsx index f31373ed9..024fd5854 100644 --- a/client/src/Pages/Settings/SettingsEmail.jsx +++ b/client/src/Pages/Settings/SettingsEmail.jsx @@ -15,16 +15,18 @@ import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornment import { usePost } from "@/Hooks/UseApi"; import { useSelector } from "react-redux"; import { createToast } from "@/Utils/toastUtils.jsx"; +import { Controller } from "react-hook-form"; const SettingsEmail = ({ isAdmin, HEADER_SX, - handleChange, - settingsData, - setSettingsData, isEmailPasswordSet, emailPasswordHasBeenReset, setEmailPasswordHasBeenReset, + control, + defaults, + formValues, + setValue, }) => { // Setup const { t } = useTranslation(); @@ -44,22 +46,13 @@ const SettingsEmail = ({ systemEmailIgnoreTLS = false, systemEmailRequireTLS = false, systemEmailRejectUnauthorized = true, - } = settingsData?.settings || {}; - // Local state - const [password, setPassword] = useState(""); + } = formValues || {}; // Network const { post: sendTestEmailFn, loading: isSending } = usePost(); const user = useSelector((state) => state.auth.user); // Handlers - const handlePasswordChange = (e) => { - setPassword(e.target.value); - setSettingsData({ - ...settingsData, - settings: { ...settingsData.settings, systemEmailPassword: e.target.value }, - }); - }; /** * Handle sending test email with current form values @@ -70,7 +63,7 @@ const SettingsEmail = ({ !systemEmailHost || !systemEmailPort || !systemEmailAddress || - !(password || systemEmailPassword) + !systemEmailPassword ) { createToast({ body: t("settingsPage.emailSettings.toastEmailRequiredFieldsError"), @@ -117,52 +110,87 @@ const SettingsEmail = ({ - ( + + )} /> - ( + + )} /> - ( + + )} /> - ( + + )} /> {(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && ( - } + control={control} + defaultValue={defaults.systemEmailPassword} + render={({ field, fieldState }) => ( + } + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} /> )} @@ -172,11 +200,6 @@ const SettingsEmail = ({ {t("settingsPage.emailSettings.labelPasswordSet")} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8e18d8e2a..94d5b5642 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -901,6 +901,93 @@ "title": "A PageSpeed monitor is used to:" } }, + "settings": { + "aboutSettings": { + "labelDevelopedBy": "Developed by Bluewave Labs", + "labelVersion": "Version", + "title": "About" + }, + "demoMonitorsSettings": { + "buttonAddMonitors": "Add demo monitors", + "description": "Add sample monitors for demonstration purposes.", + "title": "Demo monitors" + }, + "emailSettings": { + "buttonSendTestEmail": "Send test e-mail", + "description": "Configure the email settings for your system. This is used to send notifications and alerts.", + "descriptionTransport": "This builds an SMTP transport for NodeMailer", + "labelAddress": "Email address - Used for authentication", + "labelConnectionHost": "Email connection host - Hostname to use in the HELO/EHLO greeting", + "labelHost": "Email host - Hostname or IP address to connect to", + "labelIgnoreTLS": "Disable STARTTLS: Don't use TLS even if the server supports it", + "labelPassword": "Email password - Password for authentication", + "labelPasswordSet": "Password is set. Click Reset to change it.", + "labelPool": "Enable connection pooling: Reuse existing connections to improve performance", + "labelPort": "Email port - Port to connect to", + "labelRejectUnauthorized": "Reject invalid certificates: Reject connections with self-signed or untrusted certificates", + "labelRequireTLS": "Force STARTTLS: Require TLS upgrade, fail if not supported", + "labelSecure": "Use SSL (recommended): Encrypt the connection using SSL/TLS", + "labelTLSServername": "TLS Servername - Optional Hostname for TLS Validation when host is an IP", + "labelUser": "Email user - Username for authentication, overrides email address if specified", + "linkTransport": "See specifications here", + "placeholderUser": "Leave empty if not required", + "title": "Email", + "toastEmailRequiredFieldsError": "Email address, host, port and password are required" + }, + "globalThresholds": { + "title": "Global Thresholds", + "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." + }, + "pageSpeedSettings": { + "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", + "labelApiKeySet": "API key is set. Click Reset to change it.", + "labelApiKey": "PageSpeed API key", + "title": "Google PageSpeed API key" + }, + "saveButtonLabel": "Save", + "statsSettings": { + "clearAllStatsButton": "Clear all stats", + "clearAllStatsDescription": "Clear all stats. This is irreversible.", + "clearAllStatsDialogConfirm": "Yes, clear all stats", + "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", + "clearAllStatsDialogTitle": "Do you want to clear all stats?", + "description": "Define how long you want to retain historical data. You can also clear all existing data.", + "labelTTL": "The days you want to keep monitoring history.", + "labelTTLOptional": "0 for infinite", + "title": "Monitor history" + }, + "systemResetSettings": { + "buttonRemoveAllMonitors": "Remove all monitors", + "description": "Remove all monitors from your system.", + "dialogConfirm": "Yes, remove all monitors", + "dialogDescription": "Once removed, the monitors cannot be retrieved.", + "dialogTitle": "Do you want to remove all monitors?", + "title": "System reset" + }, + "timezoneSettings": { + "description": "Select the timezone used to display dates and times throughout the application.", + "label": "Display timezone", + "title": "Display timezone" + }, + "title": "Settings", + "uiSettings": { + "description": "Switch between light and dark mode, or change user interface language.", + "labelLanguage": "Language", + "labelTheme": "Theme mode", + "labelChartType": "Chart type", + "chartTypeHistogram": "Histogram", + "chartTypeHeatmap": "Heatmap", + "title": "Appearance" + }, + "urlSettings": { + "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", + "label": "Display IP/URL on status page", + "selectDisabled": "Disabled", + "selectEnabled": "Enabled", + "title": "Monitor IP/URL on Status Page" + } + }, + "statusPages": { "deleteSuccess": "Status page deleted successfully", "fallback": { From 55885ea0079139cba17d867ecdb14cc741bba641 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 18:25:35 +0000 Subject: [PATCH 327/392] remove global decision --- .../infrastructure/notificationsService.ts | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index e3dc36747..93a436df9 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -39,11 +39,11 @@ export class NotificationsService implements INotificationsService { { monitors: Monitor[]; statusResponses: MonitorStatusResponse[]; + decisions: MonitorActionDecision[]; timer: ReturnType; createdAt: number; } >; - private currentDecision?: MonitorActionDecision; constructor( notificationsRepository: INotificationsRepository, @@ -85,39 +85,45 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups = new Map(); } - private send = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise => { + private send = async ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise => { const settings = this.settingsService.getSettings(); const clientHost = settings.clientHost || "Host not defined"; + switch (notification.type) { case "email": - return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "slack": - return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "discord": - return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "pager_duty": - return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "matrix": - return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "webhook": - return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); default: return false; } }; - private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { const notificationIds = monitor.notifications ?? []; const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); const tasks = notifications.map((notification) => { // Only group emails, only for DOWN transitions, and only if a window is configured. if (notification.type === "email" && this.emailGroupingWindowMs > 0 && monitorStatusResponse.status === false) { - return this.queueGroupedEmailNotification(notification, monitor, monitorStatusResponse); + return this.queueGroupedEmailNotification(notification, monitor, monitorStatusResponse, decision); } // For all other cases (UP notifications or non-email channels), send immediately. - return this.send(notification, monitor, monitorStatusResponse); + return this.send(notification, monitor, monitorStatusResponse, decision); }); const outcomes = await Promise.all(tasks); @@ -144,11 +150,12 @@ export class NotificationsService implements INotificationsService { private queueGroupedEmailNotification = async ( notification: Notification, monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision ): Promise => { // If grouping is disabled, fallback to immediate send. if (this.emailGroupingWindowMs <= 0) { - return await this.send(notification, monitor, monitorStatusResponse); + return await this.send(notification, monitor, monitorStatusResponse, decision); } const key = notification.id; @@ -164,15 +171,9 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups.delete(key); try { - // For grouped notifications (always DOWN), create a decision - const groupedDecision: MonitorActionDecision = { - shouldCreateIncident: false, - shouldResolveIncident: false, - shouldSendNotification: true, - incidentReason: "status_down", - notificationReason: "status_change", - }; - await this.flushEmailGroup(notification, group.monitors, group.statusResponses, groupedDecision); + // Use the first decision from the group (they should all be similar for grouped notifications) + const firstDecision = group.decisions[0]!; + await this.flushEmailGroup(notification, group.monitors, group.statusResponses, firstDecision); } catch (error: any) { this.logger.error({ message: error?.message, @@ -186,6 +187,7 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups.set(key, { monitors: [monitor], statusResponses: [monitorStatusResponse], + decisions: [decision], timer, createdAt: now, }); @@ -193,6 +195,7 @@ export class NotificationsService implements INotificationsService { // Append to existing group. existingGroup.monitors.push(monitor); existingGroup.statusResponses.push(monitorStatusResponse); + existingGroup.decisions.push(decision); } // Consider queueing as "succeeded" from the caller's perspective. @@ -256,11 +259,8 @@ export class NotificationsService implements INotificationsService { return false; } - // Store decision for use in send method - this.currentDecision = decision; - // Send notifications based on decision - return await this.sendNotifications(monitor, monitorStatusResponse); + return await this.sendNotifications(monitor, monitorStatusResponse, decision); }; sendTestNotification = async (notification: Notification) => { From e7a7c0e60383eebdbf88f9954cb4a67271adec65 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 18:39:41 +0000 Subject: [PATCH 328/392] remove grouping --- .../infrastructure/notificationsService.ts | 156 +----------------- 1 file changed, 3 insertions(+), 153 deletions(-) diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 93a436df9..7fe825da0 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -32,19 +32,6 @@ export class NotificationsService implements INotificationsService { private logger: any; private settingsService: ISettingsService; - // Email grouping (batching) configuration - private emailGroupingWindowMs: number; - private pendingEmailGroups: Map< - string, - { - monitors: Monitor[]; - statusResponses: MonitorStatusResponse[]; - decisions: MonitorActionDecision[]; - timer: ReturnType; - createdAt: number; - } - >; - constructor( notificationsRepository: INotificationsRepository, monitorsRepository: IMonitorsRepository, @@ -67,22 +54,6 @@ export class NotificationsService implements INotificationsService { this.matrixProvider = matrixProvider; this.settingsService = settingsService; this.logger = logger; - - // Configure email grouping window (in milliseconds). - // When > 0, multiple DOWN events for monitors that share the same - // email notification within this window will be batched into a single email. - const rawGroupingWindow = process.env.NOTIFICATION_GROUP_WINDOW_MS ?? process.env.NOTIFICATION_GROUP_WINDOW_SECONDS; - let groupingWindowMs = 0; - if (rawGroupingWindow) { - const parsed = Number(rawGroupingWindow); - if (!Number.isNaN(parsed) && parsed > 0) { - // If value looks like seconds (small number), convert to ms. - // This allows either milliseconds (e.g. 60000) or seconds (e.g. 60). - groupingWindowMs = parsed <= 300 ? parsed * 1000 : parsed; - } - } - this.emailGroupingWindowMs = groupingWindowMs; - this.pendingEmailGroups = new Map(); } private send = async ( @@ -116,15 +87,7 @@ export class NotificationsService implements INotificationsService { const notificationIds = monitor.notifications ?? []; const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); - const tasks = notifications.map((notification) => { - // Only group emails, only for DOWN transitions, and only if a window is configured. - if (notification.type === "email" && this.emailGroupingWindowMs > 0 && monitorStatusResponse.status === false) { - return this.queueGroupedEmailNotification(notification, monitor, monitorStatusResponse, decision); - } - - // For all other cases (UP notifications or non-email channels), send immediately. - return this.send(notification, monitor, monitorStatusResponse, decision); - }); + const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision)); const outcomes = await Promise.all(tasks); const succeeded = outcomes.filter(Boolean).length; @@ -133,126 +96,13 @@ export class NotificationsService implements INotificationsService { this.logger.warn({ message: `Notification send completed with ${succeeded} success, ${failed} failure(s)`, service: SERVICE_NAME, - method: "getMonitorJob", + method: "sendNotifications", }); } - // Return true if all notificaitons succeeded + // Return true if all notifications succeeded return succeeded === notifications.length; }; - /** - * Queue a DOWN email notification to be potentially grouped with other - * DOWN events for the same email notification within the configured window. - * - * This method returns immediately; the actual email is sent asynchronously - * when the grouping window expires. - */ - private queueGroupedEmailNotification = async ( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ): Promise => { - // If grouping is disabled, fallback to immediate send. - if (this.emailGroupingWindowMs <= 0) { - return await this.send(notification, monitor, monitorStatusResponse, decision); - } - - const key = notification.id; - const now = Date.now(); - const existingGroup = this.pendingEmailGroups.get(key); - - if (!existingGroup) { - // Create a new group and schedule a flush after the window expires. - const timer = setTimeout(async () => { - const group = this.pendingEmailGroups.get(key); - if (!group) return; - - this.pendingEmailGroups.delete(key); - - try { - // Use the first decision from the group (they should all be similar for grouped notifications) - const firstDecision = group.decisions[0]!; - await this.flushEmailGroup(notification, group.monitors, group.statusResponses, firstDecision); - } catch (error: any) { - this.logger.error({ - message: error?.message, - service: SERVICE_NAME, - method: "flushEmailGroup", - stack: error?.stack, - }); - } - }, this.emailGroupingWindowMs); - - this.pendingEmailGroups.set(key, { - monitors: [monitor], - statusResponses: [monitorStatusResponse], - decisions: [decision], - timer, - createdAt: now, - }); - } else { - // Append to existing group. - existingGroup.monitors.push(monitor); - existingGroup.statusResponses.push(monitorStatusResponse); - existingGroup.decisions.push(decision); - } - - // Consider queueing as "succeeded" from the caller's perspective. - return true; - }; - - /** - * Flush a grouped set of DOWN events into a single email. - * - * To avoid changing email templates, we construct a synthetic Monitor - * whose name concisely lists all affected services. The existing - * `serverIsDownTemplate` is then reused. - * - * @param notification The email notification to send to - * @param monitors Array of monitors that went down - * @param statusResponses Array of status responses (parallel to monitors) - * @returns true if email was sent successfully, false otherwise - */ - private flushEmailGroup = async ( - notification: Notification, - monitors: Monitor[], - statusResponses: MonitorStatusResponse[], - decision: MonitorActionDecision - ): Promise => { - if (!monitors.length || !statusResponses.length) { - return false; - } - - const { clientHost } = this.settingsService.getSettings(); - if (!clientHost) { - this.logger.warn({ message: "CLIENT_HOST not configured", service: SERVICE_NAME, method: "flushEmailGroup" }); - return false; - } - - // Build a combined monitor name listing all affected services. - // Example: "Service A, Service B" (2 services) or "Service A" (1 service) - const uniqueNames = Array.from(new Set(monitors.map((m) => m.name))); - const servicesCount = uniqueNames.length; - const servicesList = uniqueNames.join(", "); - - const combinedName = servicesCount === 1 ? servicesList : `${servicesCount} services: ${servicesList}`; - - // Use the first monitor as a base for URL and other fields. - const baseMonitor = monitors[0]!; - const baseStatus = statusResponses[0]!; - - // Create a shallow clone so we don't mutate the original entity. - // This preserves monitor properties while overriding the name for grouped display. - const syntheticMonitor: Monitor = { - ...baseMonitor, - name: combinedName, - }; - - // Reuse existing email provider to send grouped notification. - return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision, clientHost); - }; - handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { // Early return if no notification should be sent if (!decision.shouldSendNotification) { From 27f3def9bdd6cf9457c6851c72226a3a6b8defb3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 18:43:03 +0000 Subject: [PATCH 329/392] correct logger type --- server/src/service/infrastructure/notificationsService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 7fe825da0..5f057f0c5 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -3,6 +3,7 @@ import { IMonitorsRepository, INotificationsRepository } from "@/repositories/in import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { ISettingsService } from "@/service/system/settingsService.js"; +import { ILogger } from "@/utils/logger.js"; export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; @@ -29,7 +30,7 @@ export class NotificationsService implements INotificationsService { private discordProvider: INotificationProvider; private pagerDutyProvider: INotificationProvider; private matrixProvider: INotificationProvider; - private logger: any; + private logger: ILogger; private settingsService: ISettingsService; constructor( From 302ac6f4f123ebee28075aa8612b2e48c724a1d6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 19:32:28 +0000 Subject: [PATCH 330/392] update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f6e292bc9..f411b5c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ *.sh mongo node_modules/ -docs/architecture \ No newline at end of file +docs/architecture +docs/reviews \ No newline at end of file From a65515fc086e17ee71e301b1cd6b7e0071b8436b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 19:42:10 +0000 Subject: [PATCH 331/392] logger type --- server/src/service/infrastructure/notificationsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 5f057f0c5..ae1f0f3af 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -43,7 +43,7 @@ export class NotificationsService implements INotificationsService { pagerDutyProvider: INotificationProvider, matrixProvider: INotificationProvider, settingsService: ISettingsService, - logger: any + logger: ILogger ) { this.notificationsRepository = notificationsRepository; this.monitorsRepository = monitorsRepository; From d2d75cd035b565992d97108da015197b997d22ab Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 20:18:25 +0000 Subject: [PATCH 332/392] initial commit --- server/src/config/services.ts | 8 +- .../notificationMessageBuilder.ts | 281 ++++++++++++++++++ .../infrastructure/notificationsService.ts | 11 +- server/src/types/notificationMessage.ts | 53 ++++ 4 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 server/src/service/infrastructure/notificationMessageBuilder.ts create mode 100644 server/src/types/notificationMessage.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 67c16ff6a..8707bc643 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -23,6 +23,7 @@ import MaintenanceWindowService from "../service/business/maintenanceWindowServi import { MonitorService } from "@/service/index.js"; import { StatusPageService, IStatusPageService } from "../service/business/statusPageService.js"; import IncidentService from "../service/business/incidentService.js"; +import { NotificationMessageBuilder, INotificationMessageBuilder } from "../service/infrastructure/notificationMessageBuilder.js"; import axios from "axios"; import got from "got"; import ping from "ping"; @@ -88,6 +89,7 @@ export type InitializedServices = { logger: any; notificationsService: INotificationsService; statusPageService: IStatusPageService; + notificationMessageBuilder: INotificationMessageBuilder; // Repositories monitorsRepository: IMonitorsRepository; @@ -173,6 +175,8 @@ export const initializeServices = async ({ const pagerDutyProvider = new PagerDutyProvider(logger); const matrixProvider = new MatrixProvider(logger); + const notificationMessageBuilder = new NotificationMessageBuilder(); + const notificationsService = new NotificationsService( notificationsRepository, monitorsRepository, @@ -183,7 +187,8 @@ export const initializeServices = async ({ pagerDutyProvider, matrixProvider, settingsService, - logger + logger, + notificationMessageBuilder ); const superSimpleQueueHelper = new SuperSimpleQueueHelper({ @@ -262,6 +267,7 @@ export const initializeServices = async ({ logger, notificationsService, statusPageService, + notificationMessageBuilder, // Repositories monitorsRepository, diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts new file mode 100644 index 000000000..6fd1b29c1 --- /dev/null +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -0,0 +1,281 @@ +import type { Monitor, MonitorStatusResponse } from "@/types/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { + NotificationMessage, + NotificationType, + NotificationSeverity, + ThresholdBreach, + NotificationContent, +} from "@/types/notificationMessage.js"; + +export interface INotificationMessageBuilder { + buildMessage( + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ): NotificationMessage; +} + +const SERVICE_NAME = "NotificationMessageBuilder"; + +export class NotificationMessageBuilder implements INotificationMessageBuilder { + static SERVICE_NAME = SERVICE_NAME; + + buildMessage( + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ): NotificationMessage { + const type = this.determineNotificationType(decision, monitor); + const severity = this.determineSeverity(type, monitor.status); + const content = this.buildContent(type, monitor, monitorStatusResponse, decision, clientHost); + + return { + type, + severity, + monitor: { + id: monitor.id, + name: monitor.name, + url: monitor.url, + type: monitor.type, + status: monitor.status, + }, + content, + clientHost, + metadata: { + teamId: monitor.teamId, + notificationReason: decision.notificationReason || "status_change", + }, + }; + } + + private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType { + // Down status has highest priority (critical) + if (monitor.status === "down") { + return "monitor_down"; + } + + // Threshold breach (only if not down) + if (decision.notificationReason === "threshold_breach") { + return "threshold_breach"; + } + + // Recovery from threshold breach + if (decision.notificationReason === "status_change" && monitor.status === "up") { + return "threshold_resolved"; + } + + // Standard recovery (up) + if (monitor.status === "up") { + return "monitor_up"; + } + + // Default to monitor_up for any other case + return "monitor_up"; + } + + private determineSeverity(type: NotificationType, monitorStatus: string): NotificationSeverity { + switch (type) { + case "monitor_down": + return "critical"; + case "threshold_breach": + return "warning"; + case "monitor_up": + case "threshold_resolved": + return "success"; + case "test": + return "info"; + default: + return "info"; + } + } + + private buildContent( + type: NotificationType, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ): NotificationContent { + switch (type) { + case "monitor_down": + return this.buildMonitorDownContent(monitor, monitorStatusResponse, clientHost); + case "monitor_up": + return this.buildMonitorUpContent(monitor, clientHost); + case "threshold_breach": + return this.buildThresholdBreachContent(monitor, monitorStatusResponse, decision, clientHost); + case "threshold_resolved": + return this.buildThresholdResolvedContent(monitor, clientHost); + default: + return this.buildDefaultContent(monitor, clientHost); + } + } + + private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, clientHost: string): NotificationContent { + const title = `Monitor Down: ${monitor.name}`; + const summary = `Monitor "${monitor.name}" is currently down and unreachable.`; + const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`]; + + // Add response code if available + if (monitorStatusResponse.code) { + details.push(`Response Code: ${monitorStatusResponse.code}`); + } + + // Add error message if available + if (monitorStatusResponse.message) { + details.push(`Error: ${monitorStatusResponse.message}`); + } + + return { + title, + summary, + details, + timestamp: new Date(), + }; + } + + private buildMonitorUpContent(monitor: Monitor, clientHost: string): NotificationContent { + const title = `Monitor Recovered: ${monitor.name}`; + const summary = `Monitor "${monitor.name}" is back up and operational.`; + const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`]; + + return { + title, + summary, + details, + timestamp: new Date(), + }; + } + + private buildThresholdBreachContent( + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ): NotificationContent { + const title = `Threshold Breach: ${monitor.name}`; + const summary = `Hardware monitor "${monitor.name}" has breached one or more thresholds.`; + const details = [`URL: ${monitor.url}`, `Status: Breached`, `Type: ${monitor.type}`]; + + const thresholds = this.extractThresholdBreaches(monitor, monitorStatusResponse); + + return { + title, + summary, + details, + thresholds, + timestamp: new Date(), + }; + } + + private buildThresholdResolvedContent(monitor: Monitor, clientHost: string): NotificationContent { + const title = `Thresholds Resolved: ${monitor.name}`; + const summary = `Hardware monitor "${monitor.name}" thresholds have returned to normal.`; + const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`]; + + return { + title, + summary, + details, + timestamp: new Date(), + }; + } + + private buildDefaultContent(monitor: Monitor, clientHost: string): NotificationContent { + return { + title: `Monitor: ${monitor.name}`, + summary: `Status update for monitor "${monitor.name}".`, + details: [`URL: ${monitor.url}`, `Status: ${monitor.status}`, `Type: ${monitor.type}`], + timestamp: new Date(), + }; + } + + private extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] { + const breaches: ThresholdBreach[] = []; + + // Check if this is a hardware monitor with threshold data + if (monitor.type !== "hardware" || !monitorStatusResponse.payload) { + return breaches; + } + + // Cast to HardwareStatusPayload type + const payload = monitorStatusResponse.payload as { data?: any }; + const hardware = payload.data; + + if (!hardware) { + return breaches; + } + + // CPU threshold breach + if (monitor.cpuAlertThreshold && hardware.cpu?.usage_percent !== undefined) { + const cpuPercent = hardware.cpu.usage_percent; + const threshold = monitor.cpuAlertThreshold; + if (cpuPercent >= threshold) { + breaches.push({ + metric: "cpu", + currentValue: cpuPercent, + threshold, + unit: "%", + formattedValue: `${cpuPercent.toFixed(1)}%`, + }); + } + } + + // Memory threshold breach + if (monitor.memoryAlertThreshold && hardware.memory?.usage_percent !== undefined) { + const memoryPercent = hardware.memory.usage_percent; + const threshold = monitor.memoryAlertThreshold; + if (memoryPercent >= threshold) { + breaches.push({ + metric: "memory", + currentValue: memoryPercent, + threshold, + unit: "%", + formattedValue: `${memoryPercent.toFixed(1)}%`, + }); + } + } + + // Disk threshold breach + if (monitor.diskAlertThreshold && Array.isArray(hardware.disk)) { + // Find the highest disk usage + let maxDiskUsage = 0; + for (const disk of hardware.disk) { + if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsage) { + maxDiskUsage = disk.usage_percent; + } + } + const threshold = monitor.diskAlertThreshold; + if (maxDiskUsage >= threshold) { + breaches.push({ + metric: "disk", + currentValue: maxDiskUsage, + threshold, + unit: "%", + formattedValue: `${maxDiskUsage.toFixed(1)}%`, + }); + } + } + + // Temperature threshold breach + if (monitor.tempAlertThreshold && hardware.cpu?.temperature) { + // Temperature is an array in cpu.temperature + const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature]; + const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t))); + const threshold = monitor.tempAlertThreshold; + if (maxTemp >= threshold) { + breaches.push({ + metric: "temp", + currentValue: maxTemp, + threshold, + unit: "°C", + formattedValue: `${maxTemp.toFixed(1)}°C`, + }); + } + } + + return breaches; + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index ae1f0f3af..18a82c4a9 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -4,6 +4,7 @@ import { INotificationProvider } from "./notificationProviders/INotificationProv import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; +import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; @@ -32,6 +33,7 @@ export class NotificationsService implements INotificationsService { private matrixProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; + private notificationMessageBuilder: INotificationMessageBuilder; constructor( notificationsRepository: INotificationsRepository, @@ -43,7 +45,8 @@ export class NotificationsService implements INotificationsService { pagerDutyProvider: INotificationProvider, matrixProvider: INotificationProvider, settingsService: ISettingsService, - logger: ILogger + logger: ILogger, + notificationMessageBuilder: INotificationMessageBuilder ) { this.notificationsRepository = notificationsRepository; this.monitorsRepository = monitorsRepository; @@ -55,6 +58,7 @@ export class NotificationsService implements INotificationsService { this.matrixProvider = matrixProvider; this.settingsService = settingsService; this.logger = logger; + this.notificationMessageBuilder = notificationMessageBuilder; } private send = async ( @@ -105,11 +109,14 @@ export class NotificationsService implements INotificationsService { }; handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { - // Early return if no notification should be sent if (!decision.shouldSendNotification) { return false; } + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; + const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost); + // Send notifications based on decision return await this.sendNotifications(monitor, monitorStatusResponse, decision); }; diff --git a/server/src/types/notificationMessage.ts b/server/src/types/notificationMessage.ts new file mode 100644 index 000000000..f06ff1bd9 --- /dev/null +++ b/server/src/types/notificationMessage.ts @@ -0,0 +1,53 @@ +/** + * Unified notification message types for cross-provider consistency + * Part of notification system unification effort + */ + +export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "test"; + +export type NotificationSeverity = "critical" | "warning" | "info" | "success"; + +export interface MonitorInfo { + id: string; + name: string; + url: string; + type: string; + status: string; +} + +export interface ThresholdBreach { + metric: "cpu" | "memory" | "disk" | "temp"; + currentValue: number; + threshold: number; + unit: string; + formattedValue: string; // e.g., "85%" or "72°C" +} + +export interface IncidentInfo { + id: string; + url: string; + createdAt: Date; + resolvedAt?: Date; + duration?: string; +} + +export interface NotificationContent { + title: string; + summary: string; + details?: string[]; + thresholds?: ThresholdBreach[]; + incident?: IncidentInfo; + timestamp: Date; +} + +export interface NotificationMessage { + type: NotificationType; + severity: NotificationSeverity; + monitor: MonitorInfo; + content: NotificationContent; + clientHost: string; + metadata: { + teamId: string; + notificationReason: string; + }; +} From e18681606c6ad7b1c0c13c5cdb997a39b709e320 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 20:55:00 +0000 Subject: [PATCH 333/392] add webhook --- .../notificationMessageBuilder.ts | 33 ++++--- .../INotificationProvider.ts | 2 + .../notificationProviders/webhook.ts | 93 +++++++++++++++++++ .../infrastructure/notificationsService.ts | 25 +++-- 4 files changed, 132 insertions(+), 21 deletions(-) diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index 6fd1b29c1..eff890638 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -208,11 +208,12 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { return breaches; } - // CPU threshold breach - if (monitor.cpuAlertThreshold && hardware.cpu?.usage_percent !== undefined) { - const cpuPercent = hardware.cpu.usage_percent; + // Note: usage_percent values in hardware payload are decimals (0-1) + if (monitor.cpuAlertThreshold !== undefined && monitor.cpuAlertThreshold !== null && hardware.cpu?.usage_percent !== undefined) { + const cpuUsageDecimal = hardware.cpu.usage_percent; + const cpuPercent = cpuUsageDecimal * 100; const threshold = monitor.cpuAlertThreshold; - if (cpuPercent >= threshold) { + if (cpuPercent > threshold) { breaches.push({ metric: "cpu", currentValue: cpuPercent, @@ -224,10 +225,11 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { } // Memory threshold breach - if (monitor.memoryAlertThreshold && hardware.memory?.usage_percent !== undefined) { - const memoryPercent = hardware.memory.usage_percent; + if (monitor.memoryAlertThreshold !== undefined && monitor.memoryAlertThreshold !== null && hardware.memory?.usage_percent !== undefined) { + const memoryUsageDecimal = hardware.memory.usage_percent; + const memoryPercent = memoryUsageDecimal * 100; const threshold = monitor.memoryAlertThreshold; - if (memoryPercent >= threshold) { + if (memoryPercent > threshold) { breaches.push({ metric: "memory", currentValue: memoryPercent, @@ -239,28 +241,29 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { } // Disk threshold breach - if (monitor.diskAlertThreshold && Array.isArray(hardware.disk)) { + if (monitor.diskAlertThreshold !== undefined && monitor.diskAlertThreshold !== null && Array.isArray(hardware.disk)) { // Find the highest disk usage - let maxDiskUsage = 0; + let maxDiskUsageDecimal = 0; for (const disk of hardware.disk) { - if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsage) { - maxDiskUsage = disk.usage_percent; + if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsageDecimal) { + maxDiskUsageDecimal = disk.usage_percent; } } + const maxDiskPercent = maxDiskUsageDecimal * 100; const threshold = monitor.diskAlertThreshold; - if (maxDiskUsage >= threshold) { + if (maxDiskPercent > threshold) { breaches.push({ metric: "disk", - currentValue: maxDiskUsage, + currentValue: maxDiskPercent, threshold, unit: "%", - formattedValue: `${maxDiskUsage.toFixed(1)}%`, + formattedValue: `${maxDiskPercent.toFixed(1)}%`, }); } } // Temperature threshold breach - if (monitor.tempAlertThreshold && hardware.cpu?.temperature) { + if (monitor.tempAlertThreshold !== undefined && monitor.tempAlertThreshold !== null && hardware.cpu?.temperature) { // Temperature is an array in cpu.temperature const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature]; const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t))); diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 21a221c9c..73043fdb1 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -1,5 +1,6 @@ import type { Monitor, Notification, Alert, MonitorStatusResponse } from "@/types/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; export interface INotificationProvider { sendAlert: ( @@ -9,5 +10,6 @@ export interface INotificationProvider { decision: MonitorActionDecision, clientHost: string ) => Promise; + sendMessage?: (notification: Notification, message: NotificationMessage) => Promise; sendTestAlert(notification: Notification): Promise; } diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 17d871950..c4b637bef 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -2,6 +2,7 @@ const SERVICE_NAME = "WebhookProvider"; import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; import { buildHardwareAlerts, buildHardwareNotificationMessage, @@ -75,6 +76,98 @@ export class WebhookProvider implements INotificationProvider { } }; + /** + * New unified message format - builds webhook payload from NotificationMessage + */ + sendMessage = async (notification: Notification, message: NotificationMessage): Promise => { + if (!notification.address) { + return false; + } + + // Build webhook payload from unified message + const payload = this.buildWebhookPayload(message); + + try { + await got.post(notification.address, { + json: payload, + headers: { + "Content-Type": "application/json", + }, + }); + this.logger.info({ + message: "[NEW] Webhook notification sent via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "[NEW] Webhook alert failed via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + }; + + /** + * Build webhook payload from NotificationMessage + * Format: { text: string, severity: string, monitor: object, details: object } + */ + private buildWebhookPayload(message: NotificationMessage): object { + const lines: string[] = []; + + // Title and summary + lines.push(`**${message.content.title}**`); + lines.push(message.content.summary); + lines.push(""); + + // Monitor information + lines.push("**Monitor Details:**"); + lines.push(`- Name: ${message.monitor.name}`); + lines.push(`- URL: ${message.monitor.url}`); + lines.push(`- Type: ${message.monitor.type}`); + lines.push(`- Status: ${message.monitor.status}`); + lines.push(""); + + // Additional details + if (message.content.details && message.content.details.length > 0) { + lines.push("**Additional Information:**"); + message.content.details.forEach((detail) => lines.push(`- ${detail}`)); + lines.push(""); + } + + // Threshold breaches (for hardware monitors) + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push("**Threshold Breaches:**"); + message.content.thresholds.forEach((breach) => { + lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + }); + lines.push(""); + } + + // Incident link + if (message.content.incident) { + lines.push(`[View Incident](${message.clientHost}/infrastructure/${message.monitor.id})`); + } + + // Return webhook payload with both text and structured data + return { + text: lines.join("\n"), + severity: message.severity, + type: message.type, + monitor: { + id: message.monitor.id, + name: message.monitor.name, + url: message.monitor.url, + status: message.monitor.status, + }, + timestamp: message.content.timestamp, + }; + } + sendTestAlert = async (notification: Notification) => { if (!notification.address) { return false; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 18a82c4a9..71d678bf8 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -65,11 +65,23 @@ export class NotificationsService implements INotificationsService { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + notificationMessage?: any ): Promise => { const settings = this.settingsService.getSettings(); const clientHost = settings.clientHost || "Host not defined"; + // For webhook notifications, try new sendMessage method if available + if (notification.type === "webhook" && this.webhookProvider.sendMessage && notificationMessage) { + this.logger.info({ + message: "[NEW] Using sendMessage for webhook", + service: SERVICE_NAME, + method: "send", + }); + return await this.webhookProvider.sendMessage(notification, notificationMessage); + } + + // Fallback to existing sendAlert for all providers switch (notification.type) { case "email": return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); @@ -92,7 +104,12 @@ export class NotificationsService implements INotificationsService { const notificationIds = monitor.notifications ?? []; const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); - const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision)); + // Build notification message once for all notifications + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; + const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost); + + const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision, notificationMessage)); const outcomes = await Promise.all(tasks); const succeeded = outcomes.filter(Boolean).length; @@ -113,10 +130,6 @@ export class NotificationsService implements INotificationsService { return false; } - const settings = this.settingsService.getSettings(); - const clientHost = settings.clientHost || "Host not defined"; - const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost); - // Send notifications based on decision return await this.sendNotifications(monitor, monitorStatusResponse, decision); }; From 0e1dc3e3febc0191b1c479abdf21ec50b4418b07 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 21:48:03 +0000 Subject: [PATCH 334/392] slack --- .../notificationMessageBuilder.ts | 8 +- .../notificationProviders/slack.ts | 159 ++++++++++++++++++ .../infrastructure/notificationsService.ts | 11 +- 3 files changed, 173 insertions(+), 5 deletions(-) diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index eff890638..95e0da2d7 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -62,8 +62,8 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { return "threshold_breach"; } - // Recovery from threshold breach - if (decision.notificationReason === "status_change" && monitor.status === "up") { + // Recovery from threshold breach (only for hardware monitors) + if (decision.notificationReason === "status_change" && monitor.status === "up" && monitor.type === "hardware") { return "threshold_resolved"; } @@ -156,7 +156,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { clientHost: string ): NotificationContent { const title = `Threshold Breach: ${monitor.name}`; - const summary = `Hardware monitor "${monitor.name}" has breached one or more thresholds.`; + const summary = `Monitor "${monitor.name}" has breached one or more thresholds.`; const details = [`URL: ${monitor.url}`, `Status: Breached`, `Type: ${monitor.type}`]; const thresholds = this.extractThresholdBreaches(monitor, monitorStatusResponse); @@ -172,7 +172,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { private buildThresholdResolvedContent(monitor: Monitor, clientHost: string): NotificationContent { const title = `Thresholds Resolved: ${monitor.name}`; - const summary = `Hardware monitor "${monitor.name}" thresholds have returned to normal.`; + const summary = `Monitor "${monitor.name}" thresholds have returned to normal.`; const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`]; return { diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index f99af48d4..ede6b8d40 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -2,6 +2,7 @@ const SERVICE_NAME = "SlackProvider"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; import { buildHardwareAlerts, buildHardwareNotificationMessage, @@ -99,4 +100,162 @@ export class SlackProvider implements INotificationProvider { return false; } } + + /** + * New unified message format - builds Slack Block Kit payload from NotificationMessage + */ + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + const payload = this.buildSlackPayload(message); + + try { + await got.post(notification.address, { + json: payload, + headers: { + "Content-Type": "application/json", + }, + }); + this.logger.info({ + message: "[NEW] Slack notification sent via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "[NEW] Slack alert failed via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + /** + * Build Slack Block Kit payload from NotificationMessage + * Uses Slack's rich formatting with sections, fields, and context blocks + */ + private buildSlackPayload(message: NotificationMessage): object { + const blocks: any[] = []; + + // Determine color based on severity + const colorMap = { + critical: "#FF0000", // Red + warning: "#FFA500", // Orange + success: "#00FF00", // Green + info: "#0000FF", // Blue + }; + const color = colorMap[message.severity] || "#808080"; + + // Header block with title + blocks.push({ + type: "header", + text: { + type: "plain_text", + text: message.content.title, + emoji: true, + }, + }); + + // Summary section + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: message.content.summary, + }, + }); + + // Monitor details as fields + const monitorFields = [ + { type: "mrkdwn", text: `*Name:*\n${message.monitor.name}` }, + { type: "mrkdwn", text: `*Type:*\n${message.monitor.type}` }, + { type: "mrkdwn", text: `*Status:*\n${message.monitor.status}` }, + { type: "mrkdwn", text: `*URL:*\n${message.monitor.url}` }, + ]; + + blocks.push({ + type: "section", + fields: monitorFields, + }); + + // Divider + blocks.push({ type: "divider" }); + + // Threshold breaches (if any) + if (message.content.thresholds && message.content.thresholds.length > 0) { + const thresholdText = message.content.thresholds + .map((breach) => `• *${breach.metric.toUpperCase()}:* ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`) + .join("\n"); + + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `*Threshold Breaches:*\n${thresholdText}`, + }, + }); + + blocks.push({ type: "divider" }); + } + + // Additional details (if any) + if (message.content.details && message.content.details.length > 0) { + const detailsText = message.content.details.map((detail) => `• ${detail}`).join("\n"); + + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `*Additional Information:*\n${detailsText}`, + }, + }); + } + + // Incident link button (if incident exists) + if (message.content.incident) { + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Incident", + emoji: true, + }, + url: `${message.clientHost}/infrastructure/${message.monitor.id}`, + style: "primary", + }, + ], + }); + } + + // Context footer with timestamp + blocks.push({ + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`, + }, + ], + }); + + // Return Slack payload with blocks and attachment color + return { + blocks, + attachments: [ + { + color, + blocks: [], // Empty blocks in attachment for color bar + }, + ], + }; + } } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 71d678bf8..253102f4e 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -71,7 +71,7 @@ export class NotificationsService implements INotificationsService { const settings = this.settingsService.getSettings(); const clientHost = settings.clientHost || "Host not defined"; - // For webhook notifications, try new sendMessage method if available + // Route to new sendMessage method if provider supports it if (notification.type === "webhook" && this.webhookProvider.sendMessage && notificationMessage) { this.logger.info({ message: "[NEW] Using sendMessage for webhook", @@ -81,6 +81,15 @@ export class NotificationsService implements INotificationsService { return await this.webhookProvider.sendMessage(notification, notificationMessage); } + if (notification.type === "slack" && this.slackProvider.sendMessage && notificationMessage) { + this.logger.info({ + message: "[NEW] Using sendMessage for slack", + service: SERVICE_NAME, + method: "send", + }); + return await this.slackProvider.sendMessage(notification, notificationMessage); + } + // Fallback to existing sendAlert for all providers switch (notification.type) { case "email": From 6f55e4934fac6c65660ea886f1d94cc4c7b61874 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 21:49:42 +0000 Subject: [PATCH 335/392] matrix --- .../notificationProviders/matrix.ts | 148 ++++++++++++++++++ .../infrastructure/notificationsService.ts | 9 ++ 2 files changed, 157 insertions(+) diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index f2543886d..63b24d7b7 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -3,6 +3,7 @@ import got from "got"; import type { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; import { buildHardwareAlerts, buildHardwareNotificationMessage, @@ -113,4 +114,151 @@ export class MatrixProvider implements INotificationProvider { return false; } }; + + /** + * New unified message format - builds Matrix HTML message from NotificationMessage + */ + sendMessage = async (notification: Notification, message: NotificationMessage): Promise => { + const { homeserverUrl, accessToken, roomId } = notification; + + if (!homeserverUrl || !accessToken || !roomId) { + return false; + } + + const { plainText, htmlText } = this.buildMatrixMessage(message); + + const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`; + const body = { + msgtype: "m.text", + body: plainText, + format: "org.matrix.custom.html", + formatted_body: htmlText, + }; + + try { + await got.post(url, { + json: body, + headers: { + "Content-Type": "application/json", + }, + }); + this.logger.info({ + message: "[NEW] Matrix notification sent via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "[NEW] Matrix alert failed via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + }; + + /** + * Build Matrix message from NotificationMessage + * Returns both plain text and HTML formatted versions + * Matrix supports HTML subset for rich formatting + */ + private buildMatrixMessage(message: NotificationMessage): { plainText: string; htmlText: string } { + const plainLines: string[] = []; + const htmlLines: string[] = []; + + // Determine color based on severity + const colorMap = { + critical: "#FF0000", // Red + warning: "#FFA500", // Orange + success: "#00FF00", // Green + info: "#0000FF", // Blue + }; + const color = colorMap[message.severity] || "#808080"; + + // Title + plainLines.push(`# ${message.content.title}`); + htmlLines.push(`

${this.escapeHtml(message.content.title)}

`); + + // Summary + plainLines.push(""); + plainLines.push(message.content.summary); + htmlLines.push(`

${this.escapeHtml(message.content.summary)}

`); + + // Monitor details + plainLines.push(""); + plainLines.push("## Monitor Details"); + plainLines.push(`- Name: ${message.monitor.name}`); + plainLines.push(`- URL: ${message.monitor.url}`); + plainLines.push(`- Type: ${message.monitor.type}`); + plainLines.push(`- Status: ${message.monitor.status}`); + + htmlLines.push(`

Monitor Details

`); + htmlLines.push(`
    `); + htmlLines.push(`
  • Name: ${this.escapeHtml(message.monitor.name)}
  • `); + htmlLines.push(`
  • URL: ${this.escapeHtml(message.monitor.url)}
  • `); + htmlLines.push(`
  • Type: ${this.escapeHtml(message.monitor.type)}
  • `); + htmlLines.push(`
  • Status: ${this.escapeHtml(message.monitor.status)}
  • `); + htmlLines.push(`
`); + + // Threshold breaches (if any) + if (message.content.thresholds && message.content.thresholds.length > 0) { + plainLines.push(""); + plainLines.push("## Threshold Breaches"); + htmlLines.push(`

Threshold Breaches

`); + htmlLines.push(`
    `); + + message.content.thresholds.forEach((breach) => { + plainLines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + htmlLines.push( + `
  • ${this.escapeHtml(breach.metric.toUpperCase())}: ${this.escapeHtml(breach.formattedValue)} (threshold: ${breach.threshold}${this.escapeHtml(breach.unit)})
  • ` + ); + }); + + htmlLines.push(`
`); + } + + // Additional details (if any) + if (message.content.details && message.content.details.length > 0) { + plainLines.push(""); + plainLines.push("## Additional Information"); + htmlLines.push(`

Additional Information

`); + htmlLines.push(`
    `); + + message.content.details.forEach((detail) => { + plainLines.push(`- ${detail}`); + htmlLines.push(`
  • ${this.escapeHtml(detail)}
  • `); + }); + + htmlLines.push(`
`); + } + + // Incident link (if incident exists) + if (message.content.incident) { + const incidentUrl = `${message.clientHost}/infrastructure/${message.monitor.id}`; + plainLines.push(""); + plainLines.push(`View Incident: ${incidentUrl}`); + htmlLines.push(`

View Incident

`); + } + + // Footer with timestamp + plainLines.push(""); + plainLines.push(`Checkmate | ${new Date(message.content.timestamp).toUTCString()}`); + htmlLines.push(`
`); + htmlLines.push(`

Checkmate | ${new Date(message.content.timestamp).toUTCString()}

`); + + return { + plainText: plainLines.join("\n"), + htmlText: htmlLines.join(""), + }; + } + + /** + * Escape HTML special characters for safe rendering + */ + private escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + } } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 253102f4e..0c6918510 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -90,6 +90,15 @@ export class NotificationsService implements INotificationsService { return await this.slackProvider.sendMessage(notification, notificationMessage); } + if (notification.type === "matrix" && this.matrixProvider.sendMessage && notificationMessage) { + this.logger.info({ + message: "[NEW] Using sendMessage for matrix", + service: SERVICE_NAME, + method: "send", + }); + return await this.matrixProvider.sendMessage(notification, notificationMessage); + } + // Fallback to existing sendAlert for all providers switch (notification.type) { case "email": From 2cd2a6d917e55f7ab30b01ec49975cea5cef73b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 21:58:58 +0000 Subject: [PATCH 336/392] pagerduty --- .../notificationProviders/pagerduty.ts | 100 ++++++++++++++++++ .../infrastructure/notificationsService.ts | 9 ++ 2 files changed, 109 insertions(+) diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 291cd7de5..2a51eb341 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -3,6 +3,7 @@ import got from "got"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { INotificationProvider } from "@/service/index.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; import { buildHardwareAlerts, buildHardwareNotificationMessage, @@ -105,4 +106,103 @@ export class PagerDutyProvider implements INotificationProvider { return false; } } + + /** + * New unified message format implementation + */ + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + this.logger.warn({ + message: "PagerDuty notification missing routing key", + service: SERVICE_NAME, + method: "sendMessage", + }); + return false; + } + + try { + const payload = this.buildPagerDutyPayload(notification, message); + await got.post("https://events.pagerduty.com/v2/enqueue", { + json: payload, + }); + this.logger.info({ + message: "[NEW] PagerDuty notification sent via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "PagerDuty notification failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): any { + // Map our notification type to PagerDuty event_action + const eventAction = message.type === "monitor_up" || message.type === "threshold_resolved" ? "resolve" : "trigger"; + + // Map severity to PagerDuty severity levels + const severityMap: Record = { + critical: "critical", + warning: "warning", + info: "info", + success: "info", + }; + + const severity = severityMap[message.severity] || "error"; + + // Build deduplication key based on monitor ID for event grouping + const dedupKey = `checkmate-${message.monitor.id}`; + + // Build summary + let summary = `${message.content.title} - ${message.content.summary}`; + + // Add threshold details to summary if present + if (message.content.thresholds && message.content.thresholds.length > 0) { + const thresholdInfo = message.content.thresholds + .map((t) => `${t.metric.toUpperCase()}: ${t.formattedValue}/${t.threshold}${t.unit}`) + .join(", "); + summary += ` [${thresholdInfo}]`; + } + + // Build custom details + const customDetails: any = { + monitor_name: message.monitor.name, + monitor_url: message.monitor.url, + monitor_type: message.monitor.type, + monitor_status: message.monitor.status, + notification_type: message.type, + }; + + if (message.content.thresholds && message.content.thresholds.length > 0) { + customDetails.threshold_breaches = message.content.thresholds.map((t) => ({ + metric: t.metric, + current: t.formattedValue, + threshold: `${t.threshold}${t.unit}`, + })); + } + + if (message.content.details && message.content.details.length > 0) { + customDetails.details = message.content.details; + } + + return { + routing_key: notification.address, + dedup_key: dedupKey, + event_action: eventAction, + payload: { + summary, + severity, + source: message.monitor.url, + timestamp: message.content.timestamp.toISOString(), + custom_details: customDetails, + }, + }; + } } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 0c6918510..eec41a0a5 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -99,6 +99,15 @@ export class NotificationsService implements INotificationsService { return await this.matrixProvider.sendMessage(notification, notificationMessage); } + if (notification.type === "pager_duty" && this.pagerDutyProvider.sendMessage && notificationMessage) { + this.logger.info({ + message: "[NEW] Using sendMessage for pagerduty", + service: SERVICE_NAME, + method: "send", + }); + return await this.pagerDutyProvider.sendMessage(notification, notificationMessage); + } + // Fallback to existing sendAlert for all providers switch (notification.type) { case "email": From e0a75c78db774c760af94043e4a7711a67a94765 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 01:16:00 +0000 Subject: [PATCH 337/392] refactor discord --- .../notificationProviders/discord.ts | 112 ++++++++++++++++++ .../infrastructure/notificationsService.ts | 9 ++ 2 files changed, 121 insertions(+) diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index 560c18aae..dcf8927be 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -3,6 +3,7 @@ import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, buildDiscordBody, getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; import got from "got"; export class DiscordProvider implements INotificationProvider { @@ -86,4 +87,115 @@ export class DiscordProvider implements INotificationProvider { return false; } }; + + /** + * New unified message format implementation + */ + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + this.logger.warn({ + message: "Discord notification missing webhook URL", + service: SERVICE_NAME, + method: "sendMessage", + }); + return false; + } + + const embed = this.buildDiscordEmbed(message); + + try { + await got.post(notification.address, { + json: { embeds: [embed] }, + headers: { + "Content-Type": "application/json", + }, + }); + this.logger.info({ + message: "[NEW] Discord notification sent via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Discord notification failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildDiscordEmbed(message: NotificationMessage): any { + // Map severity to Discord embed colors + const colorMap: Record = { + critical: 0xdc2626, // red-600 + warning: 0xf59e0b, // amber-500 + info: 0x3b82f6, // blue-500 + success: 0x10b981, // green-500 + }; + + const color = colorMap[message.severity] || colorMap.info; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + // Add monitor details + fields.push({ + name: "Monitor", + value: message.monitor.name, + inline: true, + }); + + fields.push({ + name: "Type", + value: message.monitor.type.toUpperCase(), + inline: true, + }); + + fields.push({ + name: "Status", + value: message.monitor.status.charAt(0).toUpperCase() + message.monitor.status.slice(1), + inline: true, + }); + + // Add monitor URL + fields.push({ + name: "URL", + value: message.monitor.url, + inline: false, + }); + + // Add threshold breaches if present + if (message.content.thresholds && message.content.thresholds.length > 0) { + const thresholdLines = message.content.thresholds + .map((t) => `• **${t.metric.toUpperCase()}**: ${t.formattedValue} (threshold: ${t.threshold}${t.unit})`) + .join("\n"); + + fields.push({ + name: "Threshold Breaches", + value: thresholdLines, + inline: false, + }); + } + + // Add details if present + if (message.content.details && message.content.details.length > 0) { + const detailsText = message.content.details.join("\n"); + fields.push({ + name: "Details", + value: detailsText, + inline: false, + }); + } + + return { + title: message.content.title, + description: message.content.summary, + color, + fields, + timestamp: message.content.timestamp.toISOString(), + }; + } } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index eec41a0a5..a26a39d33 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -108,6 +108,15 @@ export class NotificationsService implements INotificationsService { return await this.pagerDutyProvider.sendMessage(notification, notificationMessage); } + if (notification.type === "discord" && this.discordProvider.sendMessage && notificationMessage) { + this.logger.info({ + message: "[NEW] Using sendMessage for discord", + service: SERVICE_NAME, + method: "send", + }); + return await this.discordProvider.sendMessage(notification, notificationMessage); + } + // Fallback to existing sendAlert for all providers switch (notification.type) { case "email": From f983863c72def96bb4c0bd9faa445d0380f1f7ff Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 01:25:30 +0000 Subject: [PATCH 338/392] new template --- server/src/templates/unifiedNotification.mjml | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 server/src/templates/unifiedNotification.mjml diff --git a/server/src/templates/unifiedNotification.mjml b/server/src/templates/unifiedNotification.mjml new file mode 100644 index 000000000..96b8b614c --- /dev/null +++ b/server/src/templates/unifiedNotification.mjml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + Message from Checkmate Service + + + + + {{ title }} + + + + + + + +

Hello!

+

{{ summary }}

+

Monitor name: {{ monitorName }}

+

URL: {{ monitorUrl }}

+

Type: {{ monitorType }}

+

Status: {{ monitorStatus }}

+ {{#if thresholds}} +

Threshold Breaches:

+
    + {{#each thresholds}} +
  • + {{ metric }}: {{ formattedValue }} (threshold: {{ threshold }}{{ unit }}) +
  • + {{/each}} +
+ {{/if}} + {{#if details}} +

Additional Details:

+
    + {{#each details}} +
  • {{ this }}
  • + {{/each}} +
+ {{/if}} +
+
+ + + {{#if incidentUrl}} + + View incident details + + {{/if}} + +

This email was sent by Checkmate.

+
+
+
+
+
From fbb0e940c644e51f69553cebe4895e8673bb80d6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 01:47:57 +0000 Subject: [PATCH 339/392] emails --- .../service/infrastructure/emailService.ts | 1 + .../notificationProviders/email.ts | 83 +++++++++++++++++++ .../infrastructure/notificationsService.ts | 9 ++ 3 files changed, 93 insertions(+) diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 1f46a080a..48653340a 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -65,6 +65,7 @@ class EmailService { passwordResetTemplate: this.loadTemplate("passwordReset"), hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"), testEmailTemplate: this.loadTemplate("testEmailTemplate"), + unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"), }; }; diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index fd15034d1..3c8ba5527 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -3,6 +3,7 @@ import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, buildHardwareEmail, buildEmail, buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; export class EmailProvider implements INotificationProvider { private emailService: any; @@ -90,4 +91,86 @@ export class EmailProvider implements INotificationProvider { } return true; } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + const subject = this.buildSubject(message); + const html = await this.buildEmailFromMessage(message); + + const messageId = await this.emailService.sendEmail(notification.address, subject, html); + if (!messageId) { + this.logger.warn({ + message: "Email notification failed via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return false; + } + return true; + } + + private buildSubject(message: NotificationMessage): string { + switch (message.type) { + case "monitor_down": + return `Monitor ${message.monitor.name} is down`; + case "monitor_up": + return `Monitor ${message.monitor.name} is back up`; + case "threshold_breach": + return `Monitor ${message.monitor.name} threshold breached`; + case "threshold_resolved": + return `Monitor ${message.monitor.name} thresholds resolved`; + default: + return `Alert: ${message.monitor.name}`; + } + } + + private async buildEmailFromMessage(message: NotificationMessage): Promise { + const context = { + title: message.content.title, + summary: message.content.summary, + monitorName: message.monitor.name, + monitorUrl: message.monitor.url, + monitorType: message.monitor.type, + monitorStatus: message.monitor.status, + headerColor: this.getColorForSeverity(message.severity), + thresholds: message.content.thresholds, + details: message.content.details, + incidentUrl: message.content.incident?.url, + }; + + this.logger.info({ + message: "[DEBUG] Building email from message", + service: SERVICE_NAME, + method: "buildEmailFromMessage", + details: { context }, + }); + + return await this.emailService.buildEmail("unifiedNotificationTemplate", context); + const html = await this.emailService.buildEmail("unifiedNotificationTemplate", context); + + this.logger.info({ + message: "[DEBUG] Email HTML generated", + service: SERVICE_NAME, + method: "buildEmailFromMessage", + details: { + htmlLength: html?.length || 0, + htmlPreview: html?.substring(0, 200), + }, + }); + + return html; + } + + private getColorForSeverity(severity: string): string { + const colorMap: Record = { + critical: "red", + warning: "#f59e0b", + info: "#3b82f6", + success: "green", + }; + return colorMap[severity] ?? "#3b82f6"; + } } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index a26a39d33..046560d61 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -117,6 +117,15 @@ export class NotificationsService implements INotificationsService { return await this.discordProvider.sendMessage(notification, notificationMessage); } + if (notification.type === "email" && this.emailProvider.sendMessage && notificationMessage) { + this.logger.info({ + message: "[NEW] Using sendMessage for email", + service: SERVICE_NAME, + method: "send", + }); + return await this.emailProvider.sendMessage(notification, notificationMessage); + } + // Fallback to existing sendAlert for all providers switch (notification.type) { case "email": From 7d66bb5668688a8702c44719c79af02737bc8985 Mon Sep 17 00:00:00 2001 From: mannilakash Date: Tue, 17 Feb 2026 10:27:22 -0500 Subject: [PATCH 340/392] fix: unnecessary custom styling and originalResponseTime usage --- .../common/charts/HistogramResponseTime.tsx | 74 ++++++------------- 1 file changed, 24 insertions(+), 50 deletions(-) diff --git a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx index 75ae25001..1ef400daf 100644 --- a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/v2/common/charts/HistogramResponseTime.tsx @@ -17,31 +17,23 @@ interface HistogramResponseTimeProps { } interface ResponseTimeStats { - max: number; - avg: number; + max: number | string; + avg: number | string; } const DEFAULT_HEIGHT = 50; const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => { if (!Array.isArray(checks) || checks.length === 0) { - return { max: 0, avg: 0 }; + return { max: "-", avg: "-" }; } - const validChecks = checks.filter( - (check) => - (check as any).status !== "placeholder" && - (check.originalResponseTime != null || check.responseTime != null) - ); - + const validChecks = checks.filter((check) => check.originalResponseTime != null); if (validChecks.length === 0) { - return { max: 0, avg: 0 }; + return { max: "-", avg: "-" }; } - const responseTimes = validChecks.map( - (check) => check.originalResponseTime ?? check.responseTime ?? 0 - ); - + const responseTimes = validChecks.map((check) => check.originalResponseTime); const max = Math.max(...responseTimes); const avg = Math.round( responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length @@ -72,48 +64,30 @@ export const HistogramResponseTime = ({ const chartHeight = typeof height === "number" ? `${height}px` : height; const gridGap = gap ?? theme.spacing(0.5); - const statsContent = showStats && (stats.max > 0 || stats.avg > 0) && ( - - - {t("common.charts.histogram.avg", { value: stats.avg })} - - - {t("common.charts.histogram.max", { value: stats.max })} - - - ); + + {t("common.charts.histogram.avg", { value: stats.avg })} + + + {t("common.charts.histogram.max", { value: stats.max })} + + + ); return ( - {statsPosition === "left" && statsContent} - {statsPosition === "right" && statsContent} + {statsContent} ); }; From 232be7a40534f58e83c714a4f4008e08a736abc0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 17:28:43 +0000 Subject: [PATCH 341/392] cleanup --- .../service/infrastructure/emailService.ts | 3 - .../INotificationProvider.ts | 12 +- .../notificationProviders/discord.ts | 61 +------ .../notificationProviders/email.ts | 67 +------ .../notificationProviders/matrix.ts | 84 +-------- .../notificationProviders/pagerduty.ts | 68 +------ .../notificationProviders/slack.ts | 65 +------ .../notificationProviders/utils.ts | 167 +----------------- .../notificationProviders/webhook.ts | 72 +------- .../infrastructure/notificationsService.ts | 89 +++------- server/src/templates/hardwareIncident.mjml | 69 -------- server/src/templates/serverIsDown.mjml | 68 ------- server/src/templates/serverIsUp.mjml | 68 ------- 13 files changed, 38 insertions(+), 855 deletions(-) delete mode 100755 server/src/templates/hardwareIncident.mjml delete mode 100755 server/src/templates/serverIsDown.mjml delete mode 100755 server/src/templates/serverIsUp.mjml diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 48653340a..26e3e3e6d 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -60,10 +60,7 @@ class EmailService { welcomeEmailTemplate: this.loadTemplate("welcomeEmail"), employeeActivationTemplate: this.loadTemplate("employeeActivation"), noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"), - serverIsDownTemplate: this.loadTemplate("serverIsDown"), - serverIsUpTemplate: this.loadTemplate("serverIsUp"), passwordResetTemplate: this.loadTemplate("passwordReset"), - hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"), testEmailTemplate: this.loadTemplate("testEmailTemplate"), unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"), }; diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 73043fdb1..87056ef7a 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -1,15 +1,7 @@ -import type { Monitor, Notification, Alert, MonitorStatusResponse } from "@/types/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { Notification } from "@/types/index.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; export interface INotificationProvider { - sendAlert: ( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - clientHost: string - ) => Promise; - sendMessage?: (notification: Notification, message: NotificationMessage) => Promise; + sendMessage: (notification: Notification, message: NotificationMessage) => Promise; sendTestAlert(notification: Notification): Promise; } diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index dcf8927be..2f0b07fe8 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -1,8 +1,7 @@ const SERVICE_NAME = "DiscordProvider"; -import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; +import type { Notification } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; -import { buildHardwareAlerts, buildDiscordBody, getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; import got from "got"; @@ -12,58 +11,7 @@ export class DiscordProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = ( - clientHost: string, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ) => { - // For status changes (recovery), use standard format - if (decision.notificationReason === "status_change") { - return buildDiscordBody(monitor, monitorStatusResponse); - } - // For threshold breaches, use hardware alert format - const { discordPayload } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); - return discordPayload; - }; - sendAlert = async ( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - clientHost: string - ) => { - let body; - if (monitor.type === "hardware") { - body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); - } else { - body = buildDiscordBody(monitor, monitorStatusResponse); - } - - if (!notification.address) { - return false; - } - - try { - await got.post(notification.address, { - json: body, - headers: { - "Content-Type": "application/json", - }, - }); - return true; - } catch (error) { - const err = error as Error; - this.logger.warn({ - message: "Discord alert failed", - service: SERVICE_NAME, - method: "sendAlert", - stack: err?.stack, - }); - return false; - } - }; sendTestAlert = async (notification: Notification) => { if (!notification.address) { return false; @@ -110,11 +58,6 @@ export class DiscordProvider implements INotificationProvider { "Content-Type": "application/json", }, }); - this.logger.info({ - message: "[NEW] Discord notification sent via sendMessage", - service: SERVICE_NAME, - method: "sendMessage", - }); return true; } catch (error) { const err = error as Error; diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index 3c8ba5527..effd94946 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -2,7 +2,7 @@ const SERVICE_NAME = "EmailProvider"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; -import { buildHardwareAlerts, buildHardwareEmail, buildEmail, buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js"; +import { buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; export class EmailProvider implements INotificationProvider { @@ -14,68 +14,6 @@ export class EmailProvider implements INotificationProvider { this.logger = logger; } - private buildHardwareEmail = async ( - clientHost: string, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ) => { - // For status changes (recovery), use standard email format - if (decision.notificationReason === "status_change") { - return await buildEmail(this.emailService, monitor); - } - // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); - const html = buildHardwareEmail(this.emailService, monitor, alertsToSend); - return html; - }; - - async sendAlert( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - clientHost: string - ): Promise { - // For grouped notifications (identified by ":" in name), customize subject to indicate multiple services. - // Example: "2 services: Service A, Service B" becomes "Alert: 2 services are down" - const isGroupedNotification = monitor.name.includes(":"); - - // Build subject based on notification reason and monitor status - let subject: string; - if (isGroupedNotification) { - subject = `Alert: ${monitor.name} are down`; - } else if (decision.notificationReason === "threshold_breach") { - subject = `Monitor ${monitor.name} threshold breached`; - } else if (monitor.status === "up") { - subject = `Monitor ${monitor.name} is back up`; - } else { - subject = `Monitor ${monitor.name} is down`; - } - - let html; - if (monitor.type === "hardware") { - html = await this.buildHardwareEmail(clientHost, monitor, monitorStatusResponse, decision); - } else { - html = await buildEmail(this.emailService, monitor); - } - - if (!notification.address) { - return false; - } - - const messageId = await this.emailService.sendEmail(notification.address, subject, html); - if (!messageId) { - this.logger.warn({ - message: "Email alert failed", - service: SERVICE_NAME, - method: "sendAlert", - }); - return false; - } - return true; - } - async sendTestAlert(notification: Notification): Promise { const subject = "Test notification"; const html = await buildTestEmail(this.emailService); @@ -103,7 +41,7 @@ export class EmailProvider implements INotificationProvider { const messageId = await this.emailService.sendEmail(notification.address, subject, html); if (!messageId) { this.logger.warn({ - message: "Email notification failed via sendMessage", + message: "Email notification failed", service: SERVICE_NAME, method: "sendMessage", }); @@ -148,7 +86,6 @@ export class EmailProvider implements INotificationProvider { details: { context }, }); - return await this.emailService.buildEmail("unifiedNotificationTemplate", context); const html = await this.emailService.buildEmail("unifiedNotificationTemplate", context); this.logger.info({ diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index 63b24d7b7..8d58e6ead 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -1,15 +1,9 @@ const SERVICE_NAME = "MatrixProvider"; import got from "got"; import type { INotificationProvider } from "@/service/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; -import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js"; +import type { Notification } from "@/types/index.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { - buildHardwareAlerts, - buildHardwareNotificationMessage, - buildWebhookBody, - getTestMessage, -} from "@/service/infrastructure/notificationProviders/utils.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; export class MatrixProvider implements INotificationProvider { private logger: any; @@ -17,73 +11,6 @@ export class MatrixProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = ( - clientHost: string, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ) => { - // For status changes (recovery), use standard format - if (decision.notificationReason === "status_change") { - return buildWebhookBody(monitor, monitorStatusResponse); - } - // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); - const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); - return body; - }; - - private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { - const body = buildWebhookBody(monitor, monitorStatusResponse); - return body; - }; - - sendAlert = async ( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - clientHost: string - ) => { - const { homeserverUrl, accessToken, roomId } = notification; - - let content; - if (monitor.type === "hardware") { - content = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); - } else { - content = this.getContent(monitor, monitorStatusResponse); - } - - const title = `Checkmate status for ${monitor.name}`; - - const formattedMessage = `## ${title}\n${content}`; - - const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`; - const body = { - msgtype: "m.text", - body: formattedMessage, - format: "org.matrix.custom.html", - formatted_body: formattedMessage, - }; - try { - await got.post(url, { - json: body, - headers: { - "Content-Type": "application/json", - }, - }); - return true; - } catch (error) { - const err = error as Error; - this.logger.warn({ - message: "Matrix alert failed", - service: SERVICE_NAME, - method: "sendAlert", - stack: err?.stack, - }); - return false; - } - }; sendTestAlert = async (notification: Notification) => { const { homeserverUrl, accessToken, roomId } = notification; @@ -142,16 +69,11 @@ export class MatrixProvider implements INotificationProvider { "Content-Type": "application/json", }, }); - this.logger.info({ - message: "[NEW] Matrix notification sent via sendMessage", - service: SERVICE_NAME, - method: "sendMessage", - }); return true; } catch (error) { const err = error as Error; this.logger.warn({ - message: "[NEW] Matrix alert failed via sendMessage", + message: "Matrix notification failed", service: SERVICE_NAME, method: "sendMessage", stack: err?.stack, diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 2a51eb341..f96ea6cc5 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -4,12 +4,7 @@ import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { INotificationProvider } from "@/service/index.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { - buildHardwareAlerts, - buildHardwareNotificationMessage, - buildWebhookBody, - getTestMessage, -} from "@/service/infrastructure/notificationProviders/utils.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; export class PagerDutyProvider implements INotificationProvider { private logger: any; @@ -17,67 +12,6 @@ export class PagerDutyProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = ( - clientHost: string, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ) => { - // For status changes (recovery), use standard format - if (decision.notificationReason === "status_change") { - return buildWebhookBody(monitor, monitorStatusResponse); - } - // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); - const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); - return body; - }; - - private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { - const body = buildWebhookBody(monitor, monitorStatusResponse); - return body; - }; - - async sendAlert( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - clientHost: string - ): Promise { - let body; - if (monitor.type === "hardware") { - body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); - } else { - body = this.getContent(monitor, monitorStatusResponse); - } - - try { - await got.post("https://events.pagerduty.com/v2/enqueue", { - json: { - routing_key: notification.address, - event_action: "trigger", - payload: { - summary: body, - severity: "critical", - source: monitor.url, - timestamp: new Date().toISOString(), - }, - }, - }); - - return true; - } catch (error) { - const err = error as Error; - this.logger.warn({ - message: "PagerDuty alert failed", - service: SERVICE_NAME, - method: "sendAlert", - stack: err?.stack, - }); - return false; - } - } async sendTestAlert(notification: Notification): Promise { try { diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index ede6b8d40..a4cf0ed26 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -3,12 +3,7 @@ import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { - buildHardwareAlerts, - buildHardwareNotificationMessage, - buildWebhookBody, - getTestMessage, -} from "@/service/infrastructure/notificationProviders/utils.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; import got, { HTTPError } from "got"; export class SlackProvider implements INotificationProvider { @@ -17,64 +12,6 @@ export class SlackProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = ( - clientHost: string, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ) => { - // For status changes (recovery), use standard format - if (decision.notificationReason === "status_change") { - return buildWebhookBody(monitor, monitorStatusResponse); - } - // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); - const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); - return body; - }; - - private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { - const body = buildWebhookBody(monitor, monitorStatusResponse); - return body; - }; - - async sendAlert( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - clientHost: string - ): Promise { - let body; - if (monitor.type === "hardware") { - body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); - } else { - body = this.getContent(monitor, monitorStatusResponse); - } - - if (!notification.address) { - return false; - } - - try { - await got.post(notification.address, { - json: { text: body }, - headers: { - "Content-Type": "application/json", - }, - }); - return true; - } catch (error) { - const err = error as Error; - this.logger.warn({ - message: "Slack alert failed", - service: SERVICE_NAME, - method: "sendAlert", - stack: err?.stack, - }); - return false; - } - } async sendTestAlert(notification: Notification): Promise { if (!notification.address) { diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index 72797c1d0..874152413 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -1,170 +1,5 @@ import { Monitor, HardwareStatusPayload, MonitorStatusResponse } from "@/types/index.js"; - -const getTime = (): { localTimeZone: string; localTime: string; utcTime: string } => { - const now = new Date(); - const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const localTime = new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - timeZone: localTimeZone, - timeZoneName: "short", - }).format(now); - const utcTime = now.toUTCString(); - return { localTimeZone, localTime, utcTime }; -}; - -export const buildHardwareAlerts = ( - clientHost: string, - monitor: Monitor, - networkResponse: MonitorStatusResponse -): { alertsToSend: string[]; discordPayload: any } => { - // Thresholds are stored as percentages (0-100), convert to decimal (0-1) for comparison - const cpuThreshold = monitor.cpuAlertThreshold !== undefined ? monitor.cpuAlertThreshold / 100 : -1; - const memoryThreshold = monitor.memoryAlertThreshold !== undefined ? monitor.memoryAlertThreshold / 100 : -1; - const diskThreshold = monitor.diskAlertThreshold !== undefined ? monitor.diskAlertThreshold / 100 : -1; - const tempThreshold = monitor.tempAlertThreshold !== undefined ? monitor.tempAlertThreshold : -1; - - const payload = networkResponse?.payload as HardwareStatusPayload; - const metrics = payload.data || {}; - const { cpu = {}, memory = {}, disk = [] } = metrics; - const cpuUsage = cpu.usage_percent ?? -1; - const memoryUsage = memory.usage_percent ?? -1; - // Get max temperature from CPU temperature sensors array - const temps = cpu.temperature ?? []; - const maxTemp = temps.length > 0 ? Math.max(...temps) : -1; - - const alerts: Record = { - cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false, - memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false, - disk: disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold) ?? false, - temp: tempThreshold !== -1 && maxTemp > tempThreshold ? true : false, - }; - - const alertsToSend = []; - const discordEmbeds = []; - const monitorInfoFields = [ - { name: "Monitor", value: monitor.name, inline: true }, - { name: "URL", value: monitor.url, inline: false }, - ]; - const goToIncidentField = { name: `Go to incident`, value: `${clientHost}/infrastructure/${monitor.id}` }; - - const formatDiscordAlert = { - cpu: () => ({ - title: "CPU alert", - description: `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, - color: 15548997, - fields: [...monitorInfoFields, goToIncidentField], - footer: { text: "Checkmate" }, - }), - - memory: () => ({ - title: "Memory alert", - description: `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, - color: 15548997, - fields: [...monitorInfoFields, goToIncidentField], - footer: { text: "Checkmate" }, - }), - - disk: () => ({ - title: "Disk alert", - description: `Your current disk usage is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, - color: 15548997, - footer: { text: "Checkmate" }, - fields: [ - ...monitorInfoFields, - ...(Array.isArray(disk) ? disk : []).map((d: any, idx: number) => ({ - name: `Disk ${idx}`, - value: `${(d?.usage_percent * 100).toFixed(0)}%`, - inline: true, - })), - goToIncidentField, - ], - }), - - temp: () => ({ - title: "Temperature alert", - description: `Your current temperature (${maxTemp.toFixed(0)}°C) is above your threshold (${tempThreshold.toFixed(0)}°C)`, - color: 15548997, - fields: [...monitorInfoFields, goToIncidentField], - footer: { text: "Checkmate" }, - }), - }; - - const alertTypes = ["cpu", "memory", "disk", "temp"] as const; - - for (const type of alertTypes) { - if (alerts[type] === true) { - const formatAlert = { - cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, - memory: () => - `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, - disk: () => - `Your current disk usage: ${disk.map((d, idx) => `(Disk${idx}: ${(d?.usage_percent ?? 0 * 100).toFixed(0)}%)`).join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, - temp: () => `Your current temperature (${maxTemp.toFixed(0)}°C) is above your threshold (${tempThreshold.toFixed(0)}°C)`, - }; - alertsToSend.push(formatAlert[type]()); - discordEmbeds.push(formatDiscordAlert[type]()); - } - } - - const discordPayload = discordEmbeds.length ? { embeds: discordEmbeds } : null; - return { alertsToSend, discordPayload }; -}; - -export const buildHardwareNotificationMessage = (clientHost: string, alerts: string[], monitor: Monitor): string => { - const alertsHeader = [`Monitor: ${monitor.name}`, `URL: ${monitor.url}`]; - const alertFooter = [`Go to incident: ${clientHost}/infrastructure/${monitor.id}`]; - const alertText = alerts.length > 0 ? [...alertsHeader, ...alerts, ...alertFooter] : []; - return alertText.join("\n"); -}; - -export const buildWebhookBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { - const { status, code } = monitorStatusResponse; - const { localTimeZone, localTime, utcTime } = getTime(); - return `Monitor: ${monitor.name}\nLocal Time (${localTimeZone}): ${localTime}\nUTC Time: ${utcTime}\nStatus: ${status ? "UP" : "DOWN"}\nStatus Code: ${code}\n\u200B\n`; -}; - -export const buildHardwareEmail = async (emailService: any, monitor: Monitor, alerts: string[]) => { - const template = "hardwareIncidentTemplate"; - const context = { monitor: monitor.name, url: monitor.url, alerts }; - const html = await emailService.buildEmail(template, context); - return html; -}; - -export const buildEmail = async (emailService: any, monitor: Monitor): Promise => { - const template = monitor.status === "up" ? "serverIsUpTemplate" : "serverIsDownTemplate"; - const context = { monitor: monitor.name, url: monitor.url }; - const html = await emailService.buildEmail(template, context); - return html; -}; - -export const buildDiscordBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { - const { localTime, utcTime } = getTime(); - let body = { - embeds: [ - { - title: `Monitor name: ${monitor.name}`, - color: monitorStatusResponse.status ? 5763719 : 15548997, - - fields: [ - { name: "Monitor", value: monitor.name, inline: false }, - { name: "Status", value: monitorStatusResponse.status ? "Up" : "Down", inline: false }, - { name: "Status Code", value: String(monitorStatusResponse.code), inline: false }, - { name: "Time", value: `${localTime} (Local) / ${utcTime} (UTC)`, inline: false }, - { name: "URL", value: monitor.url, inline: false }, - ], - footer: { text: "Checkmate" }, - }, - ], - }; - return body; -}; - +// Test notification helpers - used by all providers for test alerts export const buildTestEmail = async (emailService: any) => { const context = { testName: "Monitoring System" }; const html = await emailService.buildEmail("testEmailTemplate", context); diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index c4b637bef..532714827 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -3,12 +3,7 @@ import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/type import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { - buildHardwareAlerts, - buildHardwareNotificationMessage, - buildWebhookBody, - getTestMessage, -} from "@/service/infrastructure/notificationProviders/utils.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; import got from "got"; export class WebhookProvider implements INotificationProvider { @@ -17,68 +12,7 @@ export class WebhookProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = ( - clientHost: string, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ) => { - // For status changes (recovery), use standard format - if (decision.notificationReason === "status_change") { - return buildWebhookBody(monitor, monitorStatusResponse); - } - // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); - const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); - return body; - }; - private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): string => { - const body = buildWebhookBody(monitor, monitorStatusResponse); - return body; - }; - - sendAlert = async ( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - clientHost: string - ) => { - let body; - if (monitor.type === "hardware") { - body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); - } else { - body = this.getContent(monitor, monitorStatusResponse); - } - - if (!notification.address) { - return false; - } - - try { - await got.post(notification.address, { - json: { text: body }, - headers: { - "Content-Type": "application/json", - }, - }); - return true; - } catch (error) { - const err = error as Error; - this.logger.warn({ - message: "Webhook alert failed", - service: SERVICE_NAME, - method: "sendAlert", - stack: err?.stack, - }); - return false; - } - }; - - /** - * New unified message format - builds webhook payload from NotificationMessage - */ sendMessage = async (notification: Notification, message: NotificationMessage): Promise => { if (!notification.address) { return false; @@ -95,7 +29,7 @@ export class WebhookProvider implements INotificationProvider { }, }); this.logger.info({ - message: "[NEW] Webhook notification sent via sendMessage", + message: "Webhook notification sent", service: SERVICE_NAME, method: "sendMessage", }); @@ -103,7 +37,7 @@ export class WebhookProvider implements INotificationProvider { } catch (error) { const err = error as Error; this.logger.warn({ - message: "[NEW] Webhook alert failed via sendMessage", + message: "Webhook alert failed", service: SERVICE_NAME, method: "sendMessage", stack: err?.stack, diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 046560d61..4331c5ff0 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -1,4 +1,5 @@ import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification, MonitorStatus } from "@/types/index.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; @@ -66,81 +67,37 @@ export class NotificationsService implements INotificationsService { monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision, - notificationMessage?: any + notificationMessage: NotificationMessage | undefined ): Promise => { - const settings = this.settingsService.getSettings(); - const clientHost = settings.clientHost || "Host not defined"; - - // Route to new sendMessage method if provider supports it - if (notification.type === "webhook" && this.webhookProvider.sendMessage && notificationMessage) { - this.logger.info({ - message: "[NEW] Using sendMessage for webhook", + if (!notificationMessage) { + this.logger.warn({ + message: "Notification message not provided", service: SERVICE_NAME, method: "send", }); - return await this.webhookProvider.sendMessage(notification, notificationMessage); + return false; } - if (notification.type === "slack" && this.slackProvider.sendMessage && notificationMessage) { - this.logger.info({ - message: "[NEW] Using sendMessage for slack", - service: SERVICE_NAME, - method: "send", - }); - return await this.slackProvider.sendMessage(notification, notificationMessage); - } - - if (notification.type === "matrix" && this.matrixProvider.sendMessage && notificationMessage) { - this.logger.info({ - message: "[NEW] Using sendMessage for matrix", - service: SERVICE_NAME, - method: "send", - }); - return await this.matrixProvider.sendMessage(notification, notificationMessage); - } - - if (notification.type === "pager_duty" && this.pagerDutyProvider.sendMessage && notificationMessage) { - this.logger.info({ - message: "[NEW] Using sendMessage for pagerduty", - service: SERVICE_NAME, - method: "send", - }); - return await this.pagerDutyProvider.sendMessage(notification, notificationMessage); - } - - if (notification.type === "discord" && this.discordProvider.sendMessage && notificationMessage) { - this.logger.info({ - message: "[NEW] Using sendMessage for discord", - service: SERVICE_NAME, - method: "send", - }); - return await this.discordProvider.sendMessage(notification, notificationMessage); - } - - if (notification.type === "email" && this.emailProvider.sendMessage && notificationMessage) { - this.logger.info({ - message: "[NEW] Using sendMessage for email", - service: SERVICE_NAME, - method: "send", - }); - return await this.emailProvider.sendMessage(notification, notificationMessage); - } - - // Fallback to existing sendAlert for all providers + // Route to provider based on notification type switch (notification.type) { - case "email": - return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); - case "slack": - return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); - case "discord": - return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); - case "pager_duty": - return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); - case "matrix": - return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "webhook": - return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); + return await this.webhookProvider.sendMessage!(notification, notificationMessage); + case "slack": + return await this.slackProvider.sendMessage!(notification, notificationMessage); + case "matrix": + return await this.matrixProvider.sendMessage!(notification, notificationMessage); + case "pager_duty": + return await this.pagerDutyProvider.sendMessage!(notification, notificationMessage); + case "discord": + return await this.discordProvider.sendMessage!(notification, notificationMessage); + case "email": + return await this.emailProvider.sendMessage!(notification, notificationMessage); default: + this.logger.warn({ + message: `Unknown notification type: ${notification.type}`, + service: SERVICE_NAME, + method: "send", + }); return false; } }; diff --git a/server/src/templates/hardwareIncident.mjml b/server/src/templates/hardwareIncident.mjml deleted file mode 100755 index 3f50a3d58..000000000 --- a/server/src/templates/hardwareIncident.mjml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - Message from BlueWave Infrastructure Monitoring - - - - - Infrastructure Alerts - - - - - - - -

Hello {{ name }}!

-

{{ monitor }} at {{ url }} has the following infrastructure alerts:

- {{#each alerts}} -

• {{ this }}

- {{/each}} -
-
- - - View Infrastructure Details - -

This email was sent by BlueWave Infrastructure Monitoring.

-
-
-
-
-
diff --git a/server/src/templates/serverIsDown.mjml b/server/src/templates/serverIsDown.mjml deleted file mode 100755 index bf6424a6b..000000000 --- a/server/src/templates/serverIsDown.mjml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - Message from Checkmate Service - - - - - {{ monitor }} is down - - - - - - - -

Hello {{ name }}!

-

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

-

Monitor name: {{ monitor }}

-

URL: {{ url }}

-
-
- - - View incident details - -

This email was sent by Checkmate.

-
-
-
-
-
diff --git a/server/src/templates/serverIsUp.mjml b/server/src/templates/serverIsUp.mjml deleted file mode 100755 index 074c5e3dd..000000000 --- a/server/src/templates/serverIsUp.mjml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - Message from Checkmate Service - - - - - {{ monitor }} is up - - - - - - - -

Hello {{ name }}!

-

Your latest incident is resolved and your monitored service is up again.

-

Monitor name: {{ monitor }}

-

URL: {{ url }}

-
-
- - - View incident details - -

This email was sent by Checkmate.

-
-
-
-
-
From 584e4633d3ba69d4e378013e69b442a4ff0be79f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 17:42:11 +0000 Subject: [PATCH 342/392] incident details --- .../src/service/business/incidentService.ts | 86 ++++++++++++++++++- .../SuperSimpleQueueHelper.ts | 2 +- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index bcb17d3a5..67431f0ae 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -1,5 +1,6 @@ const SERVICE_NAME = "incidentService"; import type { Monitor } from "@/types/monitor.js"; +import type { MonitorStatusResponse } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ParseBoolean } from "@/utils/utils.js"; import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; @@ -44,7 +45,12 @@ class IncidentService { return IncidentService.SERVICE_NAME; } - handleIncident = async (monitor: Monitor, code: number, decision: MonitorActionDecision): Promise => { + handleIncident = async ( + monitor: Monitor, + code: number, + decision: MonitorActionDecision, + monitorStatusResponse?: MonitorStatusResponse + ): Promise => { if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) { return null; } @@ -55,12 +61,22 @@ class IncidentService { if (activeIncident) { return activeIncident; } else { + let statusCode = code; + let message: string | undefined; + + // For threshold breaches, use 9999 status code and build descriptive message + if (decision.incidentReason === "threshold_breach") { + statusCode = 9999; + message = this.buildThresholdBreachMessage(monitor, monitorStatusResponse); + } + const incident = { monitorId: monitor.id, teamId: monitor.teamId, startTime: Date.now().toString(), status: true, - statusCode: code, + statusCode, + message, }; return await this.incidentsRepository.create(incident); } @@ -79,6 +95,72 @@ class IncidentService { return null; }; + private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string { + const breaches: string[] = []; + + if (!monitorStatusResponse?.payload || monitor.type !== "hardware") { + return "Threshold breach detected"; + } + + const payload = monitorStatusResponse.payload as { data?: any }; + const hardware = payload.data; + + if (!hardware) { + return "Threshold breach detected"; + } + + // Check CPU breach + if (monitor.cpuAlertThreshold !== undefined && monitor.cpuAlertThreshold !== null && hardware.cpu?.usage_percent !== undefined) { + const cpuPercent = hardware.cpu.usage_percent * 100; + if (cpuPercent > monitor.cpuAlertThreshold) { + breaches.push(`CPU: ${cpuPercent.toFixed(1)}% (threshold: ${monitor.cpuAlertThreshold}%)`); + } + } + + // Check Memory breach + if (monitor.memoryAlertThreshold !== undefined && monitor.memoryAlertThreshold !== null && hardware.memory?.usage_percent !== undefined) { + const memoryPercent = hardware.memory.usage_percent * 100; + if (memoryPercent > monitor.memoryAlertThreshold) { + breaches.push(`Memory: ${memoryPercent.toFixed(1)}% (threshold: ${monitor.memoryAlertThreshold}%)`); + } + } + + // Check Disk breach + if (monitor.diskAlertThreshold !== undefined && monitor.diskAlertThreshold !== null && Array.isArray(hardware.disk)) { + let maxDiskPercent = 0; + for (const disk of hardware.disk) { + if (disk.usage_percent !== undefined) { + const diskPercent = disk.usage_percent * 100; + if (diskPercent > maxDiskPercent) { + maxDiskPercent = diskPercent; + } + } + } + if (maxDiskPercent > monitor.diskAlertThreshold) { + breaches.push(`Disk: ${maxDiskPercent.toFixed(1)}% (threshold: ${monitor.diskAlertThreshold}%)`); + } + } + + // Check Temperature breach + if (monitor.tempAlertThreshold !== undefined && monitor.tempAlertThreshold !== null && Array.isArray(hardware.cpu?.temperature)) { + let maxTemp = 0; + for (const temp of hardware.cpu.temperature) { + if (temp.current !== undefined && temp.current > maxTemp) { + maxTemp = temp.current; + } + } + if (maxTemp > monitor.tempAlertThreshold) { + breaches.push(`Temperature: ${maxTemp.toFixed(1)}°C (threshold: ${monitor.tempAlertThreshold}°C)`); + } + } + + if (breaches.length === 0) { + return "Threshold breach detected"; + } + + return breaches.join(", "); + } + resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => { try { if (!incidentId) { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 274318ad7..d59f3722b 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -132,7 +132,7 @@ class SuperSimpleQueueHelper { } // Step 7. Handle incidents (best effort, don't wait) - this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision).catch((error: any) => { + this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => { this.logger.warn({ message: error.message, service: SERVICE_NAME, From 73f05ab78ea2bf092101f3257be8e335d267a0e7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 17:49:56 +0000 Subject: [PATCH 343/392] refactor to remove duplication --- server/src/config/services.ts | 5 +- .../src/service/business/incidentService.ts | 63 +++---------------- .../notificationMessageBuilder.ts | 3 +- 3 files changed, 13 insertions(+), 58 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 8707bc643..a3fdcdad3 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -151,11 +151,14 @@ export const initializeServices = async ({ }); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); + const notificationMessageBuilder = new NotificationMessageBuilder(); + const incidentService = new IncidentService({ logger, incidentsRepository, monitorsRepository, usersRepository, + notificationMessageBuilder, }); const checkService = new CheckService({ @@ -175,8 +178,6 @@ export const initializeServices = async ({ const pagerDutyProvider = new PagerDutyProvider(logger); const matrixProvider = new MatrixProvider(logger); - const notificationMessageBuilder = new NotificationMessageBuilder(); - const notificationsService = new NotificationsService( notificationsRepository, monitorsRepository, diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 67431f0ae..9b3344f11 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -6,6 +6,7 @@ import { ParseBoolean } from "@/utils/utils.js"; import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; import type { Incident } from "@/types/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; const dateRangeLookup: Record = { recent: new Date(new Date().setHours(new Date().getHours() - 2)), @@ -23,22 +24,26 @@ class IncidentService { private incidentsRepository: IIncidentsRepository; private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; + private notificationMessageBuilder: INotificationMessageBuilder; constructor({ logger, incidentsRepository, monitorsRepository, usersRepository, + notificationMessageBuilder, }: { logger: any; incidentsRepository: IIncidentsRepository; monitorsRepository: IMonitorsRepository; usersRepository: IUsersRepository; + notificationMessageBuilder: INotificationMessageBuilder; }) { this.logger = logger; this.incidentsRepository = incidentsRepository; this.monitorsRepository = monitorsRepository; this.usersRepository = usersRepository; + this.notificationMessageBuilder = notificationMessageBuilder; } get serviceName() { @@ -96,69 +101,17 @@ class IncidentService { }; private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string { - const breaches: string[] = []; - - if (!monitorStatusResponse?.payload || monitor.type !== "hardware") { + if (!monitorStatusResponse) { return "Threshold breach detected"; } - const payload = monitorStatusResponse.payload as { data?: any }; - const hardware = payload.data; - - if (!hardware) { - return "Threshold breach detected"; - } - - // Check CPU breach - if (monitor.cpuAlertThreshold !== undefined && monitor.cpuAlertThreshold !== null && hardware.cpu?.usage_percent !== undefined) { - const cpuPercent = hardware.cpu.usage_percent * 100; - if (cpuPercent > monitor.cpuAlertThreshold) { - breaches.push(`CPU: ${cpuPercent.toFixed(1)}% (threshold: ${monitor.cpuAlertThreshold}%)`); - } - } - - // Check Memory breach - if (monitor.memoryAlertThreshold !== undefined && monitor.memoryAlertThreshold !== null && hardware.memory?.usage_percent !== undefined) { - const memoryPercent = hardware.memory.usage_percent * 100; - if (memoryPercent > monitor.memoryAlertThreshold) { - breaches.push(`Memory: ${memoryPercent.toFixed(1)}% (threshold: ${monitor.memoryAlertThreshold}%)`); - } - } - - // Check Disk breach - if (monitor.diskAlertThreshold !== undefined && monitor.diskAlertThreshold !== null && Array.isArray(hardware.disk)) { - let maxDiskPercent = 0; - for (const disk of hardware.disk) { - if (disk.usage_percent !== undefined) { - const diskPercent = disk.usage_percent * 100; - if (diskPercent > maxDiskPercent) { - maxDiskPercent = diskPercent; - } - } - } - if (maxDiskPercent > monitor.diskAlertThreshold) { - breaches.push(`Disk: ${maxDiskPercent.toFixed(1)}% (threshold: ${monitor.diskAlertThreshold}%)`); - } - } - - // Check Temperature breach - if (monitor.tempAlertThreshold !== undefined && monitor.tempAlertThreshold !== null && Array.isArray(hardware.cpu?.temperature)) { - let maxTemp = 0; - for (const temp of hardware.cpu.temperature) { - if (temp.current !== undefined && temp.current > maxTemp) { - maxTemp = temp.current; - } - } - if (maxTemp > monitor.tempAlertThreshold) { - breaches.push(`Temperature: ${maxTemp.toFixed(1)}°C (threshold: ${monitor.tempAlertThreshold}°C)`); - } - } + const breaches = this.notificationMessageBuilder.extractThresholdBreaches(monitor, monitorStatusResponse); if (breaches.length === 0) { return "Threshold breach detected"; } - return breaches.join(", "); + return breaches.map((b) => `${b.metric.toUpperCase()}: ${b.formattedValue} (threshold: ${b.threshold}${b.unit})`).join(", "); } resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => { diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index 95e0da2d7..d8235e263 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -15,6 +15,7 @@ export interface INotificationMessageBuilder { decision: MonitorActionDecision, clientHost: string ): NotificationMessage; + extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[]; } const SERVICE_NAME = "NotificationMessageBuilder"; @@ -192,7 +193,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { }; } - private extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] { + public extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] { const breaches: ThresholdBreach[] = []; // Check if this is a hardware monitor with threshold data From 7bc03c976b21929c8b82e2d47b6593c8863a9092 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 17:53:00 +0000 Subject: [PATCH 344/392] remove comment --- .../infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index d59f3722b..72a0b03f9 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -195,7 +195,6 @@ class SuperSimpleQueueHelper { notificationReason: null, }; - // Simplified logic: Just check status changes if (!statusChanged) { return decision; } From 847904a27489561511d6821543243c8212f299f2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 18:15:30 +0000 Subject: [PATCH 345/392] add missing pluralization strings --- client/src/locales/en.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 14081a445..abcf84ce1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -603,9 +603,10 @@ }, "summaryCard": { "activeIncidents": { - "active_zero": "No active incidents", "title": "Active Incidents", - "active": "active" + "active_zero": "No active incidents", + "active_one": "{{count}} active incident", + "active_other": "{{count}} active incidents" }, "incidentStats": { "avgResolutionTime": "Avg Resolution Time", From 97750da889bb8b1685376b361105219951862d44 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 18:30:22 +0000 Subject: [PATCH 346/392] remove incidents on delete --- server/src/config/services.ts | 1 + .../incidents/IIncidentsRepository.ts | 1 + .../incidents/MongoIncidentRepository.ts | 8 +++++++ server/src/service/business/monitorService.ts | 21 ++++++++++++++++++- .../SuperSimpleQueueHelper.ts | 19 +++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index a3fdcdad3..6843fe64b 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -245,6 +245,7 @@ export const initializeServices = async ({ checksRepository, monitorStatsRepository, statusPagesRepository, + incidentsRepository, }); const statusPageService = new StatusPageService(statusPagesRepository); diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/repositories/incidents/IIncidentsRepository.ts index 3c136bf43..42948a9ee 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/repositories/incidents/IIncidentsRepository.ts @@ -23,5 +23,6 @@ export interface IIncidentsRepository { // update updateById(incidentId: string, teamId: string, updateData: Partial): Promise; // delete + deleteByMonitorId(monitorId: string, teamId: string): Promise; // other } diff --git a/server/src/repositories/incidents/MongoIncidentRepository.ts b/server/src/repositories/incidents/MongoIncidentRepository.ts index 9746471a9..b34af4ba7 100644 --- a/server/src/repositories/incidents/MongoIncidentRepository.ts +++ b/server/src/repositories/incidents/MongoIncidentRepository.ts @@ -273,5 +273,13 @@ class MongoIncidentRepository implements IIncidentsRepository { })), }; }; + + deleteByMonitorId = async (monitorId: string, teamId: string) => { + const result = await IncidentModel.deleteMany({ + monitorId: new mongoose.Types.ObjectId(monitorId), + teamId: new mongoose.Types.ObjectId(teamId), + }); + return result.deletedCount || 0; + }; } export default MongoIncidentRepository; diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 2360b6617..8ea83c509 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -8,7 +8,13 @@ import type { PageSpeedDetailsResult, GamesMap, } from "@/types/monitor.js"; -import type { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository, IStatusPagesRepository } from "@/repositories/index.js"; +import type { + IChecksRepository, + IIncidentsRepository, + IMonitorsRepository, + IMonitorStatsRepository, + IStatusPagesRepository, +} from "@/repositories/index.js"; import fs from "fs"; import { fileURLToPath } from "url"; import path from "path"; @@ -79,6 +85,7 @@ export class MonitorService implements IMonitorService { private checksRepository: IChecksRepository; private monitorStatsRepository: IMonitorStatsRepository; private statusPagesRepository: IStatusPagesRepository; + private incidentsRepository: IIncidentsRepository; constructor({ jobQueue, @@ -89,6 +96,7 @@ export class MonitorService implements IMonitorService { checksRepository, monitorStatsRepository, statusPagesRepository, + incidentsRepository, }: { jobQueue: ISuperSimpleQueue; emailService: any; @@ -98,6 +106,7 @@ export class MonitorService implements IMonitorService { checksRepository: IChecksRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; + incidentsRepository: IIncidentsRepository; }) { this.jobQueue = jobQueue; this.emailService = emailService; @@ -107,6 +116,7 @@ export class MonitorService implements IMonitorService { this.checksRepository = checksRepository; this.monitorStatsRepository = monitorStatsRepository; this.statusPagesRepository = statusPagesRepository; + this.incidentsRepository = incidentsRepository; } get serviceName(): string { @@ -418,6 +428,15 @@ export class MonitorService implements IMonitorService { stack: err.stack, }); }); + + await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId).catch((err: any) => { + this.logger.warn({ + message: `Error deleting incidents for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); + await this.jobQueue.deleteJob(monitor); return monitor; }; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 72a0b03f9..08bc5e257 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -153,6 +153,25 @@ class SuperSimpleQueueHelper { }; }; + getCleanupOrphanedJob = () => { + return async () => { + try { + // Remove orphaned monitors + // remove orphaned monitorStats + // Remove orphaned checks + // Remove orphaned incidents + } catch (error: any) { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + stack: error.stack, + }); + throw error; + } + }; + }; + async isInMaintenanceWindow(monitorId: string, teamId: string) { const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); // Check for active maintenance window: From 6bc9194e8f69145bf5df8ef55db70e0f2faf8886 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 19:03:17 +0000 Subject: [PATCH 347/392] initial commit --- server/src/config/services.ts | 2 + .../incidents/IIncidentsRepository.ts | 1 + .../monitor-stats/IMonitorStatsRepository.ts | 2 + .../MongoMonitorStatsRepository.ts | 12 ++++ .../monitors/IMonitorsRepository.ts | 2 + .../monitors/MongoMonitorsRepository.ts | 11 ++++ .../repositories/teams/ITeamsRepository.ts | 1 + .../teams/MongoTeamsRepository.ts | 5 ++ .../SuperSimpleQueueHelper.ts | 59 ++++++++++++++++++- 9 files changed, 92 insertions(+), 3 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 6843fe64b..aa563fe85 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -202,6 +202,8 @@ export const initializeServices = async ({ incidentService, maintenanceWindowsRepository, monitorsRepository, + teamsRepository, + monitorStatsRepository, }); const superSimpleQueue = await SuperSimpleQueue.create({ diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/repositories/incidents/IIncidentsRepository.ts index 42948a9ee..c4fcef2ae 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/repositories/incidents/IIncidentsRepository.ts @@ -24,5 +24,6 @@ export interface IIncidentsRepository { updateById(incidentId: string, teamId: string, updateData: Partial): Promise; // delete deleteByMonitorId(monitorId: string, teamId: string): Promise; + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; // other } diff --git a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts index c718e7eb3..1ad05596e 100644 --- a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts @@ -7,5 +7,7 @@ export interface IMonitorStatsRepository { // update // delete deleteByMonitorId(monitorId: string): Promise; + deleteByMonitorIds(monitorIds: string[]): Promise; + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; // other } diff --git a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts index 7211c7d89..0a4905fc1 100644 --- a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts @@ -52,6 +52,18 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository { } return this.toEntity(deleted); }; + + deleteByMonitorIds = async (monitorIds: string[]): Promise => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const result = await MonitorStatsModel.deleteMany({ monitorId: { $in: objectIds } }); + return result.deletedCount ?? 0; + }; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const result = await MonitorStatsModel.deleteMany({ monitorId: { $nin: objectIds } }); + return result.deletedCount ?? 0; + }; } export default MongoMonitorStatsRepository; diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 2fbce3406..63940f591 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -41,4 +41,6 @@ export interface IMonitorsRepository { findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; findGroupsByTeamId(teamId: string): Promise; removeNotificationFromMonitors(notificationId: string): Promise; + deleteByTeamIdsNotIn(teamIds: string[]): Promise; + findAllMonitorIds(): Promise; } diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index c6f3266a6..728d2081f 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -431,6 +431,17 @@ class MongoMonitorsRepository implements IMonitorsRepository { createdAt: toDateString(doc.createdAt), }; }; + + deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { + const objectIds = teamIds.map((id) => new mongoose.Types.ObjectId(id)); + const result = await MonitorModel.deleteMany({ teamId: { $nin: objectIds } }); + return result.deletedCount ?? 0; + }; + + findAllMonitorIds = async (): Promise => { + const monitors = await MonitorModel.find({}, { _id: 1 }).lean(); + return monitors.map((doc) => doc._id.toString()); + }; } export default MongoMonitorsRepository; diff --git a/server/src/repositories/teams/ITeamsRepository.ts b/server/src/repositories/teams/ITeamsRepository.ts index 9ccdd97fb..ba1b353cf 100644 --- a/server/src/repositories/teams/ITeamsRepository.ts +++ b/server/src/repositories/teams/ITeamsRepository.ts @@ -6,4 +6,5 @@ export interface ITeamsRepository { // update // delete // other + findAllTeamIds(): Promise; } diff --git a/server/src/repositories/teams/MongoTeamsRepository.ts b/server/src/repositories/teams/MongoTeamsRepository.ts index 93a306787..04e2be6f4 100644 --- a/server/src/repositories/teams/MongoTeamsRepository.ts +++ b/server/src/repositories/teams/MongoTeamsRepository.ts @@ -31,6 +31,11 @@ class MongoTeamsRepository implements ITeamsRepository { const team = await TeamModel.create({ email }); return this.toEntity(team); }; + + findAllTeamIds = async (): Promise => { + const teams = await TeamModel.find({}, { _id: 1 }).lean(); + return teams.map((team) => this.toStringId(team._id)); + }; } export default MongoTeamsRepository; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 08bc5e257..4d91088a1 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -4,7 +4,7 @@ import { AppError } from "@/utils/AppError.js"; import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js"; import IncidentService from "@/service/business/incidentService.js"; -import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js"; +import { IMaintenanceWindowsRepository, IMonitorsRepository, ITeamsRepository, IMonitorStatsRepository } from "@/repositories/index.js"; export interface MonitorActionDecision { shouldCreateIncident: boolean; @@ -32,6 +32,8 @@ class SuperSimpleQueueHelper { private incidentService: IncidentService; private maintenanceWindowsRepository: IMaintenanceWindowsRepository; private monitorsRepository: IMonitorsRepository; + private teamsRepository: ITeamsRepository; + private monitorStatsRepository: IMonitorStatsRepository; constructor({ logger, @@ -43,6 +45,8 @@ class SuperSimpleQueueHelper { incidentService, maintenanceWindowsRepository, monitorsRepository, + teamsRepository, + monitorStatsRepository, }: { logger: any; networkService: INetworkService; @@ -53,6 +57,8 @@ class SuperSimpleQueueHelper { incidentService: IncidentService; maintenanceWindowsRepository: IMaintenanceWindowsRepository; monitorsRepository: IMonitorsRepository; + teamsRepository: ITeamsRepository; + monitorStatsRepository: IMonitorStatsRepository; }) { this.logger = logger; this.networkService = networkService; @@ -63,6 +69,8 @@ class SuperSimpleQueueHelper { this.incidentService = incidentService; this.maintenanceWindowsRepository = maintenanceWindowsRepository; this.monitorsRepository = monitorsRepository; + this.teamsRepository = teamsRepository; + this.monitorStatsRepository = monitorStatsRepository; } get serviceName() { @@ -156,10 +164,55 @@ class SuperSimpleQueueHelper { getCleanupOrphanedJob = () => { return async () => { try { - // Remove orphaned monitors - // remove orphaned monitorStats + this.logger.info({ + message: "Starting cleanup of orphaned data", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Get all valid team IDs + const validTeamIds = await this.teamsRepository.findAllTeamIds(); + this.logger.debug({ + message: `Found ${validTeamIds.length} valid teams`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Remove orphaned monitors (monitors without a valid team) + const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds); + if (deletedMonitorCount > 0) { + this.logger.info({ + message: `Deleted ${deletedMonitorCount} orphaned monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned monitorStats (stats without a valid monitor) + const allMonitorIds = await this.monitorsRepository.findAllMonitorIds(); + this.logger.debug({ + message: `Found ${allMonitorIds.length} valid monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedStatsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedStatsCount} orphaned monitor stats`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + // Remove orphaned checks // Remove orphaned incidents + + this.logger.info({ + message: "Cleanup of orphaned data completed", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); } catch (error: any) { this.logger.warn({ message: error.message, From 0032765bd113bced6f70c48caa5adf49cf6e9859 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 19:20:08 +0000 Subject: [PATCH 348/392] remove orphaned checks --- server/src/config/services.ts | 1 + .../repositories/checks/IChecksRepository.ts | 1 + .../checks/MongoChecksRepistory.ts | 6 ++++++ .../incidents/IIncidentsRepository.ts | 1 - .../SuperSimpleQueueHelper.ts | 21 ++++++++++++++++++- 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index aa563fe85..0dc47278d 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -204,6 +204,7 @@ export const initializeServices = async ({ monitorsRepository, teamsRepository, monitorStatsRepository, + checksRepository, }); const superSimpleQueue = await SuperSimpleQueue.create({ diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/repositories/checks/IChecksRepository.ts index b75b18f13..31aac0ec9 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/repositories/checks/IChecksRepository.ts @@ -39,4 +39,5 @@ export interface IChecksRepository { //delete deleteByMonitorId(monitorId: string): Promise; deleteByTeamId(teamId: string): Promise; + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; } diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index c9cdbffd6..97a81f565 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -417,6 +417,12 @@ class MongoChecksRepository implements IChecksRepository { return deleteResult.deletedCount; }; + deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const result = await CheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } }); + return result.deletedCount ?? 0; + }; + private findUptimeDateRangeChecks = async ( monitorType: Exclude, monitorObjectId: mongoose.Types.ObjectId, diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/repositories/incidents/IIncidentsRepository.ts index c4fcef2ae..42948a9ee 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/repositories/incidents/IIncidentsRepository.ts @@ -24,6 +24,5 @@ export interface IIncidentsRepository { updateById(incidentId: string, teamId: string, updateData: Partial): Promise; // delete deleteByMonitorId(monitorId: string, teamId: string): Promise; - deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; // other } diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 4d91088a1..05876cc45 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -4,7 +4,13 @@ import { AppError } from "@/utils/AppError.js"; import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js"; import IncidentService from "@/service/business/incidentService.js"; -import { IMaintenanceWindowsRepository, IMonitorsRepository, ITeamsRepository, IMonitorStatsRepository } from "@/repositories/index.js"; +import { + IMaintenanceWindowsRepository, + IMonitorsRepository, + ITeamsRepository, + IMonitorStatsRepository, + IChecksRepository, +} from "@/repositories/index.js"; export interface MonitorActionDecision { shouldCreateIncident: boolean; @@ -34,6 +40,7 @@ class SuperSimpleQueueHelper { private monitorsRepository: IMonitorsRepository; private teamsRepository: ITeamsRepository; private monitorStatsRepository: IMonitorStatsRepository; + private checksRepository: IChecksRepository; constructor({ logger, @@ -47,6 +54,7 @@ class SuperSimpleQueueHelper { monitorsRepository, teamsRepository, monitorStatsRepository, + checksRepository, }: { logger: any; networkService: INetworkService; @@ -59,6 +67,7 @@ class SuperSimpleQueueHelper { monitorsRepository: IMonitorsRepository; teamsRepository: ITeamsRepository; monitorStatsRepository: IMonitorStatsRepository; + checksRepository: IChecksRepository; }) { this.logger = logger; this.networkService = networkService; @@ -71,6 +80,7 @@ class SuperSimpleQueueHelper { this.monitorsRepository = monitorsRepository; this.teamsRepository = teamsRepository; this.monitorStatsRepository = monitorStatsRepository; + this.checksRepository = checksRepository; } get serviceName() { @@ -206,6 +216,15 @@ class SuperSimpleQueueHelper { } // Remove orphaned checks + const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedChecksCount} orphaned checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + // Remove orphaned incidents this.logger.info({ From cddf8b32e6275fe9f53fcc35c91a61903166c08c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 19:39:24 +0000 Subject: [PATCH 349/392] cleanup incidents --- server/src/config/services.ts | 1 + .../src/repositories/checks/MongoChecksRepistory.ts | 2 +- .../repositories/incidents/IIncidentsRepository.ts | 1 + .../incidents/MongoIncidentRepository.ts | 6 ++++++ .../SuperSimpleQueue/SuperSimpleQueue.ts | 3 +++ .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 13 +++++++++++++ 6 files changed, 25 insertions(+), 1 deletion(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 0dc47278d..ecb49ab40 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -205,6 +205,7 @@ export const initializeServices = async ({ teamsRepository, monitorStatsRepository, checksRepository, + incidentsRepository, }); const superSimpleQueue = await SuperSimpleQueue.create({ diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index 97a81f565..e89bc1c95 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -13,7 +13,7 @@ import type { GotTimings, MonitorType, } from "@/types/index.js"; -import { CheckModel, type CheckDocument } from "@/db/models/index.js"; +import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js"; import mongoose from "mongoose"; const SERVICE_NAME = "StatusService"; diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/repositories/incidents/IIncidentsRepository.ts index 42948a9ee..c4fcef2ae 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/repositories/incidents/IIncidentsRepository.ts @@ -24,5 +24,6 @@ export interface IIncidentsRepository { updateById(incidentId: string, teamId: string, updateData: Partial): Promise; // delete deleteByMonitorId(monitorId: string, teamId: string): Promise; + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; // other } diff --git a/server/src/repositories/incidents/MongoIncidentRepository.ts b/server/src/repositories/incidents/MongoIncidentRepository.ts index b34af4ba7..93ecac248 100644 --- a/server/src/repositories/incidents/MongoIncidentRepository.ts +++ b/server/src/repositories/incidents/MongoIncidentRepository.ts @@ -281,5 +281,11 @@ class MongoIncidentRepository implements IIncidentsRepository { }); return result.deletedCount || 0; }; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const result = await IncidentModel.deleteMany({ monitorId: { $nin: objectIds } }); + return result.deletedCount ?? 0; + }; } export default MongoIncidentRepository; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 8fd55ff70..1947549c9 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -97,6 +97,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue { this.scheduler.start(); this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); + this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); const monitors = await this.monitorsRepository.findAll(); if (!monitors) { return true; @@ -108,6 +109,8 @@ class SuperSimpleQueue implements ISuperSimpleQueue { }, randomOffset); } + this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true }); + return true; } catch (error) { this.logger.error({ diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 05876cc45..d4035d112 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -10,6 +10,7 @@ import { ITeamsRepository, IMonitorStatsRepository, IChecksRepository, + IIncidentsRepository, } from "@/repositories/index.js"; export interface MonitorActionDecision { @@ -41,6 +42,7 @@ class SuperSimpleQueueHelper { private teamsRepository: ITeamsRepository; private monitorStatsRepository: IMonitorStatsRepository; private checksRepository: IChecksRepository; + private incidentsRepository: IIncidentsRepository; constructor({ logger, @@ -55,6 +57,7 @@ class SuperSimpleQueueHelper { teamsRepository, monitorStatsRepository, checksRepository, + incidentsRepository, }: { logger: any; networkService: INetworkService; @@ -68,6 +71,7 @@ class SuperSimpleQueueHelper { teamsRepository: ITeamsRepository; monitorStatsRepository: IMonitorStatsRepository; checksRepository: IChecksRepository; + incidentsRepository: IIncidentsRepository; }) { this.logger = logger; this.networkService = networkService; @@ -81,6 +85,7 @@ class SuperSimpleQueueHelper { this.teamsRepository = teamsRepository; this.monitorStatsRepository = monitorStatsRepository; this.checksRepository = checksRepository; + this.incidentsRepository = incidentsRepository; } get serviceName() { @@ -226,6 +231,14 @@ class SuperSimpleQueueHelper { } // Remove orphaned incidents + const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedIncidentsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedIncidentsCount} orphaned incidents`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } this.logger.info({ message: "Cleanup of orphaned data completed", From edbd8f2abb785a38fec11eb98bd820c482cac104 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 22:27:52 +0000 Subject: [PATCH 350/392] fix check bug --- .../repositories/checks/MongoChecksRepistory.ts | 17 ++++++----------- .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 2 +- .../src/service/infrastructure/bufferService.ts | 6 +++--- .../src/service/infrastructure/statusService.ts | 2 +- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index e89bc1c95..a0426dc17 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -210,19 +210,14 @@ class MongoChecksRepository implements IChecksRepository { private toDocument = (check: Partial): CheckDocument => { // Map id to _id for MongoDB storage const { id, metadata, ...rest } = check; + return { _id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(), - metadata: metadata - ? { - monitorId: new mongoose.Types.ObjectId(metadata.monitorId), - teamId: new mongoose.Types.ObjectId(metadata.teamId), - type: metadata.type, - } - : { - monitorId: new mongoose.Types.ObjectId(), - teamId: new mongoose.Types.ObjectId(), - type: "http", - }, + metadata: { + monitorId: new mongoose.Types.ObjectId(metadata.monitorId), + teamId: new mongoose.Types.ObjectId(metadata.teamId), + type: metadata.type, + }, ...rest, } as unknown as CheckDocument; }; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index d4035d112..4d9876f2f 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -134,7 +134,7 @@ class SuperSimpleQueueHelper { return; } // Step 4 Add check to buffer - this.buffer.addToBuffer({ check }); + this.buffer.addToBuffer(check); // Step 4. Update monitor status const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 415c22281..c38fd52a4 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -60,9 +60,9 @@ class BufferService implements IBufferService { return check.id.toString() === checkToRemove.id.toString(); } return ( - check.monitorId?.toString() === checkToRemove.metadata.monitorId && - check.teamId?.toString() === checkToRemove.metadata.teamId && - check.type === checkToRemove.metadata.type && + check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId && + check.metadata.teamId?.toString() === checkToRemove.metadata.teamId && + check.metadata.type === checkToRemove.metadata.type && check.status === checkToRemove.status && check.statusCode === checkToRemove.statusCode && check.responseTime === checkToRemove.responseTime && diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index b01131bbe..ad112df21 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -362,7 +362,7 @@ export class StatusService implements IStatusService { }); return false; } - this.buffer.addToBuffer({ check }); + this.buffer.addToBuffer(check); return true; } catch (error: any) { this.logger.error({ From 7856317e8f06a16035d3247da621c8d278f8257f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 22:29:30 +0000 Subject: [PATCH 351/392] remvoe space --- server/src/repositories/checks/MongoChecksRepistory.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index a0426dc17..bc25420b9 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -210,7 +210,9 @@ class MongoChecksRepository implements IChecksRepository { private toDocument = (check: Partial): CheckDocument => { // Map id to _id for MongoDB storage const { id, metadata, ...rest } = check; - + if (!metadata || !metadata.monitorId || !metadata.teamId) { + throw new Error(`Check must have valid metadata with monitorId and teamId. Got: ${JSON.stringify({ id, metadata })}`); + } return { _id: id ? new mongoose.Types.ObjectId(id) : new mongoose.Types.ObjectId(), metadata: { From 3bd6ec5d28769b4502e40659ff8ba2b240fde159 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 22:47:37 +0000 Subject: [PATCH 352/392] max response time --- server/src/db/models/MonitorStats.ts | 4 ++++ .../monitor-stats/MongoMonitorStatsRepository.ts | 1 + server/src/service/infrastructure/statusService.ts | 6 ++++++ server/src/types/monitorStats.ts | 1 + 4 files changed, 12 insertions(+) diff --git a/server/src/db/models/MonitorStats.ts b/server/src/db/models/MonitorStats.ts index d1d49ca9f..fda5ccbd5 100755 --- a/server/src/db/models/MonitorStats.ts +++ b/server/src/db/models/MonitorStats.ts @@ -24,6 +24,10 @@ const MonitorStatsSchema = new Schema( type: Number, default: 0, }, + maxResponseTime: { + type: Number, + default: 0, + }, totalChecks: { type: Number, default: 0, diff --git a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts index 0a4905fc1..39c837ac8 100644 --- a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts @@ -20,6 +20,7 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository { id: toStringId(doc._id), monitorId: toStringId(doc.monitorId), avgResponseTime: doc.avgResponseTime, + maxResponseTime: doc.maxResponseTime, totalChecks: doc.totalChecks, totalUpChecks: doc.totalUpChecks, totalDownChecks: doc.totalDownChecks, diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index ad112df21..8077e68b2 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -68,6 +68,7 @@ export class StatusService implements IStatusService { stats = { monitorId, avgResponseTime: 0, + maxResponseTime: 0, totalChecks: 0, totalUpChecks: 0, totalDownChecks: 0, @@ -82,6 +83,11 @@ export class StatusService implements IStatusService { // Last response time stats.lastResponseTime = responseTime ?? 0; + // Max response time + if (responseTime && responseTime > stats.maxResponseTime) { + stats.maxResponseTime = responseTime; + } + // Avg response time: let avgResponseTime = stats.avgResponseTime; if (typeof responseTime !== "undefined" && responseTime !== null) { diff --git a/server/src/types/monitorStats.ts b/server/src/types/monitorStats.ts index 8f9603529..97c3cb28c 100644 --- a/server/src/types/monitorStats.ts +++ b/server/src/types/monitorStats.ts @@ -2,6 +2,7 @@ export interface MonitorStats { id: string; monitorId: string; avgResponseTime: number; + maxResponseTime: number; totalChecks: number; totalUpChecks: number; totalDownChecks: number; From 3d5c81fe2f2d592d227debd248caa56bb75d2623 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 22:50:44 +0000 Subject: [PATCH 353/392] Add maxResponseTime to client-side MonitorStats type --- client/src/Types/Monitor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 043076704..dab6dd734 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -85,6 +85,7 @@ export interface MonitorStats { id: string; monitorId: string; avgResponseTime: number; + maxResponseTime: number; totalChecks: number; totalUpChecks: number; totalDownChecks: number; From c92019198499a5b0e4c07a205e78c8817df0b20b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 17 Feb 2026 23:40:14 +0000 Subject: [PATCH 354/392] increment before calculate --- server/src/service/infrastructure/statusService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 8077e68b2..cf42f8840 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -79,6 +79,7 @@ export class StatusService implements IStatusService { } // Update stats + stats.totalChecks++; // Last response time stats.lastResponseTime = responseTime ?? 0; @@ -100,7 +101,6 @@ export class StatusService implements IStatusService { stats.avgResponseTime = avgResponseTime; // Total checks - stats.totalChecks++; if (status === true) { stats.totalUpChecks++; // Update the timeSinceLastFailure if needed From 31698bd002366221966344e2f7ebe2e5684ec4a8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 00:06:02 +0000 Subject: [PATCH 355/392] fix monitor stats bug --- .../0003_cleanupDuplicateMonitorStats.ts | 81 +++++++++++++++++++ server/src/db/migration/index.ts | 2 + server/src/db/models/MonitorStats.ts | 1 + .../monitor-stats/IMonitorStatsRepository.ts | 1 + .../MongoMonitorStatsRepository.ts | 8 ++ .../service/infrastructure/statusService.ts | 33 ++++++-- 6 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts diff --git a/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts b/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts new file mode 100644 index 000000000..1371ab798 --- /dev/null +++ b/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts @@ -0,0 +1,81 @@ +import { MonitorStatsModel } from "../models/MonitorStats.js"; +import { logger } from "@/utils/logger.js"; + +/** + * Cleanup duplicate MonitorStats documents + * Keeps the most recent document (by updatedAt) for each monitorId + * and deletes all older duplicates + */ +export async function cleanupDuplicateMonitorStats(): Promise { + const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStats"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting cleanup of duplicate MonitorStats" }); + + // Find all duplicate monitorIds + const duplicates = await MonitorStatsModel.aggregate([ + { + $group: { + _id: "$monitorId", + ids: { $push: "$_id" }, + updatedAts: { $push: "$updatedAt" }, + count: { $sum: 1 }, + }, + }, + { $match: { count: { $gt: 1 } } }, + ]); + + if (duplicates.length === 0) { + logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" }); + return; + } + + logger.info({ + service: SERVICE_NAME, + message: `Found ${duplicates.length} monitors with duplicate stats`, + }); + + let totalDeleted = 0; + + // For each set of duplicates, keep the newest and delete the rest + for (const duplicate of duplicates) { + const monitorId = duplicate._id; + const { ids, updatedAts } = duplicate; + + type DocPair = { id: any; updatedAt: Date }; + + // Create array of {id, updatedAt} pairs and sort by updatedAt descending + const docs: DocPair[] = ids.map((id: any, index: number) => ({ + id, + updatedAt: updatedAts[index], + })); + + docs.sort((a: DocPair, b: DocPair) => b.updatedAt.getTime() - a.updatedAt.getTime()); + + // Keep the first (newest), delete the rest + const toDelete = docs.slice(1).map((doc: DocPair) => doc.id); + + if (toDelete.length > 0) { + const result = await MonitorStatsModel.deleteMany({ + _id: { $in: toDelete }, + }); + + totalDeleted += result.deletedCount ?? 0; + + logger.debug({ + service: SERVICE_NAME, + message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`, + }); + } + } + + logger.info({ + service: SERVICE_NAME, + message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats cleanup: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index c8e9e7c75..8ea374514 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -1,5 +1,6 @@ import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js"; import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js"; +import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js"; import MigrationModel from "../models/Migration.js"; type MigrationEntry = { @@ -10,6 +11,7 @@ type MigrationEntry = { const migrations: MigrationEntry[] = [ { name: "0001_migrateStatusWindowThreshold", execute: migrateStatusWindowThreshold }, { name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries }, + { name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats }, ]; const runMigrations = async (logger?: { info: Function; error: Function }) => { diff --git a/server/src/db/models/MonitorStats.ts b/server/src/db/models/MonitorStats.ts index fda5ccbd5..7f5c94798 100755 --- a/server/src/db/models/MonitorStats.ts +++ b/server/src/db/models/MonitorStats.ts @@ -18,6 +18,7 @@ const MonitorStatsSchema = new Schema( ref: "Monitor", immutable: true, index: true, + unique: true, required: true, }, avgResponseTime: { diff --git a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts index 1ad05596e..4abdeb33f 100644 --- a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts @@ -5,6 +5,7 @@ export interface IMonitorStatsRepository { // single fetch findByMonitorId(monitorId: string): Promise; // update + updateByMonitorId(monitorId: string, data: Omit): Promise; // delete deleteByMonitorId(monitorId: string): Promise; deleteByMonitorIds(monitorIds: string[]): Promise; diff --git a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts index 39c837ac8..774405587 100644 --- a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts @@ -46,6 +46,14 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository { return this.toEntity(monitorStats); }; + updateByMonitorId = async (monitorId: string, data: Omit): Promise => { + const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: new mongoose.Types.ObjectId(monitorId) }, { $set: data }, { new: true }); + if (!updated) { + throw new AppError({ message: "Monitor stats not found", status: 404 }); + } + return this.toEntity(updated); + }; + deleteByMonitorId = async (monitorId: string) => { const deleted = await MonitorStatsModel.findOneAndDelete({ monitorId: new mongoose.Types.ObjectId(monitorId) }); if (!deleted) { diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index cf42f8840..37657cb79 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -52,8 +52,8 @@ export class StatusService implements IStatusService { try { const monitorId = monitor.id; const { responseTime, status } = networkResponse; - let stats: Omit | null = null; - stats = await this.monitorStatsRepository + let existingStats: MonitorStats | null = null; + existingStats = await this.monitorStatsRepository .findByMonitorId(monitorId) .then((result) => result) .catch(() => { @@ -64,9 +64,12 @@ export class StatusService implements IStatusService { }); return null; }); - if (!stats) { + + let stats: Omit; + + if (!existingStats) { + // Initialize new stats stats = { - monitorId, avgResponseTime: 0, maxResponseTime: 0, totalChecks: 0, @@ -76,6 +79,19 @@ export class StatusService implements IStatusService { lastResponseTime: 0, lastCheckTimestamp: 0, }; + } else { + // Use existing stats (omit id, monitorId, createdAt, updatedAt) + stats = { + avgResponseTime: existingStats.avgResponseTime, + maxResponseTime: existingStats.maxResponseTime, + totalChecks: existingStats.totalChecks, + totalUpChecks: existingStats.totalUpChecks, + totalDownChecks: existingStats.totalDownChecks, + uptimePercentage: existingStats.uptimePercentage, + lastResponseTime: existingStats.lastResponseTime, + lastCheckTimestamp: existingStats.lastCheckTimestamp, + timeOfLastFailure: existingStats.timeOfLastFailure, + }; } // Update stats @@ -123,7 +139,14 @@ export class StatusService implements IStatusService { // latest check stats.lastCheckTimestamp = new Date().getTime(); - await this.monitorStatsRepository.create(stats); + + // Create or update + if (!existingStats) { + await this.monitorStatsRepository.create({ monitorId, ...stats }); + } else { + await this.monitorStatsRepository.updateByMonitorId(monitorId, stats); + } + return true; } catch (error: any) { this.logger.error({ From 0e6680366dda04112f7707de9dda1f27586751fe Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:53:24 -0500 Subject: [PATCH 356/392] Remove Owaise from the contributing section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 30b406684..e0528d0cb 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ If you would like to sponsor an additional feature, [see this page](https://chec ## Contributing -We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers. +We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers. We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe. From e921b20fe38f3835e48517db21e49aefb5210360 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Tue, 17 Feb 2026 22:21:56 -0500 Subject: [PATCH 357/392] fix zod patterns --- client/src/Pages/Settings/SettingsEmail.jsx | 2 +- client/src/Validation/settings.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Settings/SettingsEmail.jsx b/client/src/Pages/Settings/SettingsEmail.jsx index 5757df98e..0ab8d8b2a 100644 --- a/client/src/Pages/Settings/SettingsEmail.jsx +++ b/client/src/Pages/Settings/SettingsEmail.jsx @@ -78,7 +78,7 @@ const SettingsEmail = ({ systemEmailHost, systemEmailPort, systemEmailAddress, - systemEmailPassword: password || systemEmailPassword, + systemEmailPassword, systemEmailSecure, systemEmailPool, systemEmailIgnoreTLS, diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 71f059e17..84c6b6ee6 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -4,22 +4,27 @@ export const settingsSchema = z.object({ systemEmailIgnoreTLS: z.boolean(), systemEmailRequireTLS: z.boolean(), systemEmailRejectUnauthorized: z.boolean(), - systemEmailConnectionHost: z.string().optional().or(z.literal("")), + systemEmailConnectionHost: z.string().optional(), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), showURL: z.boolean().optional(), checkTTL: z.coerce.number().int().min(1, "Please enter a value"), - pagespeedApiKey: z.string().optional().or(z.literal("")), - systemEmailHost: z.string().optional().or(z.literal("")), - systemEmailPort: z.coerce.number().nullable().optional().or(z.literal("")), + pagespeedApiKey: z.string().optional(), + systemEmailHost: z.string().optional(), + systemEmailPort: z.coerce + .number() + .int() + .min(1, "Port must be at least 1") + .optional() + .or(z.literal("")), systemEmailAddress: z .email("Please enter a valid email address") .transform((val) => val.toLowerCase().trim()) .optional() .or(z.literal("")), - systemEmailUser: z.string().optional().or(z.literal("")), - systemEmailPassword: z.string().optional().or(z.literal("")), - systemEmailTLSServername: z.string().optional().or(z.literal("")), + systemEmailUser: z.string().optional(), + systemEmailPassword: z.string().optional(), + systemEmailTLSServername: z.string().optional(), globalThresholds: z .object({ cpu: z.coerce From 59d245b42451232131adf436abc4f1f320164a6f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 17:27:59 +0000 Subject: [PATCH 358/392] remove pre tag --- client/src/Pages/Settings/SettingsEmail.jsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/client/src/Pages/Settings/SettingsEmail.jsx b/client/src/Pages/Settings/SettingsEmail.jsx index 0ab8d8b2a..180b7f2d9 100644 --- a/client/src/Pages/Settings/SettingsEmail.jsx +++ b/client/src/Pages/Settings/SettingsEmail.jsx @@ -340,14 +340,6 @@ const SettingsEmail = ({ -
-					{JSON.stringify({
-						systemEmailHost: systemEmailHost,
-						systemEmailAddress: systemEmailAddress,
-						systemEmailPassword: systemEmailPassword,
-					})}
-				
- {systemEmailHost && systemEmailPort && From 1fcf06a415638629095062dc6e87f0bc03cbdd46 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 17:43:32 +0000 Subject: [PATCH 359/392] remove unique constraint --- server/src/db/models/MonitorStats.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/db/models/MonitorStats.ts b/server/src/db/models/MonitorStats.ts index 7f5c94798..5780de476 100755 --- a/server/src/db/models/MonitorStats.ts +++ b/server/src/db/models/MonitorStats.ts @@ -16,9 +16,7 @@ const MonitorStatsSchema = new Schema( monitorId: { type: Schema.Types.ObjectId, ref: "Monitor", - immutable: true, index: true, - unique: true, required: true, }, avgResponseTime: { From 7cfbc85d9b9c76a7a33373ec48188c2fe75575b1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 18:06:00 +0000 Subject: [PATCH 360/392] fix logical errors in settings form --- client/src/Hooks/useSettingsForm.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 17a67af25..8f91db451 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -9,12 +9,12 @@ interface UseSettingsFormOptions { export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => { return useMemo(() => { const defaults: SettingsFormData = { - systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS || false, - systemEmailRequireTLS: data?.systemEmailRequireTLS || false, - systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized || true, - systemEmailSecure: data?.systemEmailSecure || false, - systemEmailPool: data?.systemEmailPool || false, - showURL: data?.showURL || false, + systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false, + systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false, + systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true, + systemEmailSecure: data?.systemEmailSecure ?? false, + systemEmailPool: data?.systemEmailPool ?? false, + showURL: data?.showURL ?? false, systemEmailHost: data?.systemEmailHost || "", systemEmailUser: data?.systemEmailUser || "", systemEmailAddress: data?.systemEmailAddress || "", From 8cbde4a705e2892bfb053aa81ccccb974ffb5455 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 18:47:58 +0000 Subject: [PATCH 361/392] initial commit --- client/src/Hooks/useSettingsForm.ts | 12 +-- client/src/Pages/Settings/SettingsStats.jsx | 1 - client/src/Pages/Settings/index.jsx | 25 +++-- client/src/Validation/settings.ts | 93 +++++++++++-------- .../settings/MongoSettingsRepository.ts | 26 ++++-- 5 files changed, 95 insertions(+), 62 deletions(-) diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 8f91db451..ee4227970 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -20,14 +20,14 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => systemEmailAddress: data?.systemEmailAddress || "", systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost", systemEmailTLSServername: data?.systemEmailTLSServername || "", - systemEmailPort: data?.systemEmailPort || "", + systemEmailPort: data?.systemEmailPort, globalThresholds: { - cpu: data?.globalThresholds?.cpu || "", - memory: data?.globalThresholds?.memory || "", - disk: data?.globalThresholds?.disk || "", - temperature: data?.globalThresholds?.temperature || "", + cpu: data?.globalThresholds?.cpu, + memory: data?.globalThresholds?.memory, + disk: data?.globalThresholds?.disk, + temperature: data?.globalThresholds?.temperature, }, - checkTTL: data?.checkTTL || 30, + checkTTL: data?.checkTTL ?? 30, pagespeedApiKey: "", systemEmailPassword: "", }; diff --git a/client/src/Pages/Settings/SettingsStats.jsx b/client/src/Pages/Settings/SettingsStats.jsx index 8424019c8..45735ee4d 100644 --- a/client/src/Pages/Settings/SettingsStats.jsx +++ b/client/src/Pages/Settings/SettingsStats.jsx @@ -75,7 +75,6 @@ const SettingsStats = ({ isAdmin, HEADING_SX, errors }) => { SettingsStats.propTypes = { isAdmin: PropTypes.bool, HEADING_SX: PropTypes.object, - errors: PropTypes.object, }; export default SettingsStats; diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index 56ab44cf5..42045a86d 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -12,7 +12,6 @@ import SettingsGlobalThresholds from "./SettingsGlobalThresholds.jsx"; import SettingsExport from "./SettingsExport.jsx"; import Button from "@mui/material/Button"; // Utils -import { settingsValidation } from "@/Validation/validation.js"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useSettingsForm } from "@/Hooks/useSettingsForm.js"; @@ -31,7 +30,6 @@ const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }]; const Settings = () => { const { data: fetchedSettings, isLoading: isSettingsLoading } = useGet("/settings"); // Local state - const [errors, setErrors] = useState({}); const [isApiKeySet, setIsApiKeySet] = useState( fetchedSettings?.pagespeedKeySet ?? false ); @@ -60,7 +58,7 @@ const Settings = () => { formState: { dirtyFields }, } = form; useEffect(() => { - reset(defaults); + reset(defaults, { keepDirtyValues: false }); }, [defaults, reset]); useEffect(() => { @@ -88,7 +86,23 @@ const Settings = () => { }; const onSubmit = async (data) => { - const toSubmit = { ...data }; + // Convert undefined to empty strings for proper unsetting on backend + const toSubmit = Object.fromEntries( + Object.entries(data).map(([key, value]) => { + if (value === undefined) return [key, ""]; + if (typeof value === "object" && value !== null) { + // Handle nested objects like globalThresholds + return [ + key, + Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, v === undefined ? "" : v]) + ), + ]; + } + return [key, value]; + }) + ); + if (!form.formState.dirtyFields.systemEmailPassword) { delete toSubmit.systemEmailPassword; } @@ -135,7 +149,6 @@ const Settings = () => { { loading={ isSaving || isDeletingMonitorStats || isSettingsLoading || isDeletingMonitors } - disabled={Object.keys(errors).length > 0} + disabled={!form.formState.isValid} variant="contained" color="accent" sx={{ px: theme.spacing(12), py: theme.spacing(8) }} diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 84c6b6ee6..7fb9ddcd8 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -1,56 +1,69 @@ import { z } from "zod"; +// Helper to normalize empty strings to undefined +const optionalString = () => + z.preprocess( + (val) => + val === "" || val === null || val === undefined ? undefined : String(val).trim(), + z.string().optional() + ); + +// Helper for threshold fields +const thresholdField = (min: number, max: number, unit: string) => + z.preprocess( + (val) => (val === "" || val === null || val === undefined ? undefined : val), + z.coerce + .number() + .int() + .min(min, `Min ${min}${unit}`) + .max(max, `Max ${max}${unit}`) + .optional() + ); + export const settingsSchema = z.object({ systemEmailIgnoreTLS: z.boolean(), systemEmailRequireTLS: z.boolean(), systemEmailRejectUnauthorized: z.boolean(), - systemEmailConnectionHost: z.string().optional(), + systemEmailConnectionHost: optionalString(), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), showURL: z.boolean().optional(), - checkTTL: z.coerce.number().int().min(1, "Please enter a value"), - pagespeedApiKey: z.string().optional(), - systemEmailHost: z.string().optional(), - systemEmailPort: z.coerce + checkTTL: z.coerce .number() .int() - .min(1, "Port must be at least 1") - .optional() - .or(z.literal("")), - systemEmailAddress: z - .email("Please enter a valid email address") - .transform((val) => val.toLowerCase().trim()) - .optional() - .or(z.literal("")), - systemEmailUser: z.string().optional(), - systemEmailPassword: z.string().optional(), - systemEmailTLSServername: z.string().optional(), + .min(1, "Please enter a value") + .max(365, "Maximum 365 days"), + pagespeedApiKey: optionalString(), + systemEmailHost: z.preprocess( + (val) => + val === "" || val === null || val === undefined ? undefined : String(val).trim(), + z + .string() + .regex(/^[a-zA-Z0-9.-]+$/, "Invalid hostname or IP address") + .optional() + ), + systemEmailPort: z.preprocess( + (val) => (val === "" || val === null || val === undefined ? undefined : val), + z.coerce + .number() + .int() + .min(1, "Port must be at least 1") + .max(65535, "Port must be at most 65535") + .optional() + ), + systemEmailAddress: z.preprocess((val) => { + if (val === "" || val === null || val === undefined) return undefined; + return String(val).toLowerCase().trim(); + }, z.string().email("Please enter a valid email address").optional()), + systemEmailUser: optionalString(), + systemEmailPassword: optionalString(), + systemEmailTLSServername: optionalString(), globalThresholds: z .object({ - cpu: z.coerce - .number() - .min(1, "Min 1%") - .max(100, "Max 100%") - .optional() - .or(z.literal("")), - memory: z.coerce - .number() - .min(1, "Min 1%") - .max(100, "Max 100%") - .optional() - .or(z.literal("")), - disk: z.coerce - .number() - .min(1, "Min 1%") - .max(100, "Max 100%") - .optional() - .or(z.literal("")), - temperature: z.coerce - .number() - .min(1, "Min 1°C") - .max(150, "Max 150°C") - .optional() - .or(z.literal("")), + cpu: thresholdField(0, 100, "%"), + memory: thresholdField(0, 100, "%"), + disk: thresholdField(0, 100, "%"), + temperature: thresholdField(0, 150, "°C"), }) .optional(), }); diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts index 064993fa3..91a087423 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -2,7 +2,6 @@ import mongoose from "mongoose"; import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js"; import type { Settings } from "@/types/index.js"; import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js"; -import { SERVFAIL } from "dns"; class MongoSettingsRepository implements ISettingsRepository { private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { @@ -61,16 +60,25 @@ class MongoSettingsRepository implements ISettingsRepository { }; update = async (settings: Partial) => { - const update: Record = { $set: { ...settings } }; + const update: Record = { $set: {}, $unset: {} }; - if (settings.pagespeedApiKey === "") { - update.$unset = { pagespeedApiKey: "" }; - delete update.$set.pagespeedApiKey; + // Iterate through settings and separate into $set and $unset + Object.entries(settings).forEach(([key, value]) => { + if (value === undefined || value === "") { + // Unset undefined or empty string values + update.$unset[key] = ""; + } else { + // Set defined values + update.$set[key] = value; + } + }); + + // Clean up empty operators + if (Object.keys(update.$set).length === 0) { + delete update.$set; } - - if (settings.systemEmailPassword === "") { - update.$unset = { systemEmailPassword: "" }; - delete update.$set.systemEmailPassword; + if (Object.keys(update.$unset).length === 0) { + delete update.$unset; } await AppSettingsModel.findOneAndUpdate({}, update, { From b6765830a5100a2a2c3a6171e6a2612f773979e0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 19:13:52 +0000 Subject: [PATCH 362/392] fix client/server validation --- client/src/Pages/Settings/index.jsx | 17 +++++++++++++++-- server/src/validation/joi.ts | 12 ++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index 42045a86d..68df9a180 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -103,12 +103,25 @@ const Settings = () => { }) ); - if (!form.formState.dirtyFields.systemEmailPassword) { + // Handle password field: + // - If emailPasswordHasBeenReset is true, ALWAYS include (even if empty to unset) + // - Otherwise, only include if dirty (user typed something) + if (emailPasswordHasBeenReset) { + // Keep password field (even if empty) to allow unsetting + // toSubmit.systemEmailPassword is already in toSubmit + } else if (!form.formState.dirtyFields.systemEmailPassword) { + // Not in reset mode and not dirty -> exclude (no changes) delete toSubmit.systemEmailPassword; } - if (!form.formState.dirtyFields.pagespeedApiKey) { + + // Handle API key field (same logic) + if (apiKeyHasBeenReset) { + // Keep API key field (even if empty) to allow unsetting + } else if (!form.formState.dirtyFields.pagespeedApiKey) { + // Not in reset mode and not dirty -> exclude (no changes) delete toSubmit.pagespeedApiKey; } + saveSettings(toSubmit); }; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 63be09932..912767587 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -420,8 +420,8 @@ const updateAppSettingsBodyValidation = joi.object({ systemEmailAddress: joi.string().allow(""), systemEmailPassword: joi.string().allow(""), systemEmailUser: joi.string().allow(""), - systemEmailConnectionHost: joi.string().allow("").optional(), - systemEmailTLSServername: joi.string().allow("").optional(), + systemEmailConnectionHost: joi.string().allow(""), + systemEmailTLSServername: joi.string().allow(""), systemEmailSecure: joi.boolean(), systemEmailPool: joi.boolean(), systemEmailIgnoreTLS: joi.boolean(), @@ -430,10 +430,10 @@ const updateAppSettingsBodyValidation = joi.object({ globalThresholds: joi .object({ - cpu: joi.number().min(1).max(100).allow("").optional(), - memory: joi.number().min(1).max(100).allow("").optional(), - disk: joi.number().min(1).max(100).allow("").optional(), - temperature: joi.number().min(1).max(150).allow("").optional(), + cpu: joi.number().min(1).max(100).allow(""), + memory: joi.number().min(1).max(100).allow(""), + disk: joi.number().min(1).max(100).allow(""), + temperature: joi.number().min(1).max(150).allow(""), }) .optional(), }); From db800e22594c79e7f4738ded9302695719866159 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 19:23:03 +0000 Subject: [PATCH 363/392] simplify form --- client/src/Pages/Settings/index.jsx | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index 68df9a180..a600e7cec 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -55,7 +55,6 @@ const Settings = () => { trigger, getValues, setValue, - formState: { dirtyFields }, } = form; useEffect(() => { reset(defaults, { keepDirtyValues: false }); @@ -103,25 +102,6 @@ const Settings = () => { }) ); - // Handle password field: - // - If emailPasswordHasBeenReset is true, ALWAYS include (even if empty to unset) - // - Otherwise, only include if dirty (user typed something) - if (emailPasswordHasBeenReset) { - // Keep password field (even if empty) to allow unsetting - // toSubmit.systemEmailPassword is already in toSubmit - } else if (!form.formState.dirtyFields.systemEmailPassword) { - // Not in reset mode and not dirty -> exclude (no changes) - delete toSubmit.systemEmailPassword; - } - - // Handle API key field (same logic) - if (apiKeyHasBeenReset) { - // Keep API key field (even if empty) to allow unsetting - } else if (!form.formState.dirtyFields.pagespeedApiKey) { - // Not in reset mode and not dirty -> exclude (no changes) - delete toSubmit.pagespeedApiKey; - } - saveSettings(toSubmit); }; From 248482a406abbaba16d10dab5fa13d627026dd6c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 19:59:03 +0000 Subject: [PATCH 364/392] initial commit --- client/src/Pages/Settings/index.tsx | 136 ++++++++++++++++++ .../src/Pages/Settings/{index.jsx => old.jsx} | 0 client/src/Routes/index.tsx | 13 +- client/src/locales/en.json | 33 ++++- 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/Settings/index.tsx rename client/src/Pages/Settings/{index.jsx => old.jsx} (100%) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx new file mode 100644 index 000000000..c1b3ecdfa --- /dev/null +++ b/client/src/Pages/Settings/index.tsx @@ -0,0 +1,136 @@ +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { Autocomplete, Select } from "@/Components/v2/inputs"; +import { Stack, useTheme, MenuItem, type SelectChangeEvent } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useSelector, useDispatch } from "react-redux"; +import DummyChart from "@/Pages/Settings/DummyChart"; + +import { + setTimezone, + setMode, + setLanguage, + setChartType, + type ThemeMode, + type ChartType, +} from "@/Features/UI/uiSlice.js"; +import timezones from "@/Utils/timezones.json"; +import type { RootState } from "@/Types/state"; + +interface Timezone { + id: string; + name: string; +} + +export const SettingsPage = () => { + const theme = useTheme(); + const { t, i18n } = useTranslation(); + const dispatch = useDispatch(); + const { + timezone: selectedTimezoneId, + mode, + language = "en", + chartType = "histogram", + } = useSelector((state: RootState) => state.ui); + + // Convert timezones to match AutoComplete format (id instead of _id) + const timezoneOptions: Timezone[] = timezones.map((tz) => ({ + id: tz._id, + name: tz.name, + })); + + const selectedTimezone = + timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null; + + const handleTimezoneChange = (newValue: Timezone | null) => { + const newId = newValue?.id ?? ""; + dispatch(setTimezone({ timezone: newId })); + }; + + const handleModeChange = (e: SelectChangeEvent) => { + dispatch(setMode(e.target.value)); + }; + + const handleLanguageChange = (e: SelectChangeEvent) => { + dispatch(setLanguage(e.target.value)); + }; + + const handleChartTypeChange = (e: SelectChangeEvent) => { + dispatch(setChartType(e.target.value)); + }; + + const languages = Object.keys(i18n.options.resources || {}); + + return ( + + + option.name} + isOptionEqualToValue={(option: Timezone, value: Timezone) => + option.id === value.id + } + onChange={(_, newValue) => { + handleTimezoneChange(newValue as Timezone | null); + }} + fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} + /> + } + /> + + + + + + + } + /> + + + ); +}; + +export default SettingsPage; diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/old.jsx similarity index 100% rename from client/src/Pages/Settings/index.jsx rename to client/src/Pages/Settings/old.jsx diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index e3c0b0667..582461d81 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -44,6 +44,7 @@ import CreateNotifications from "@/Pages/Notifications/create"; import Account from "@/Pages/Account"; import EditUser from "@/Pages/Account/EditUser"; import Settings from "@/Pages/Settings"; +import OldSettings from "@/Pages/Settings/old.jsx"; import Maintenance from "@/Pages/Maintenance"; import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; @@ -319,7 +320,17 @@ const Routes = () => { /> } + element={ + <> + + + + + } + /> + } /> Date: Wed, 18 Feb 2026 20:04:53 +0000 Subject: [PATCH 365/392] fix state type --- client/src/Types/state.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/Types/state.ts b/client/src/Types/state.ts index b5bfcfdb8..d8d291fc7 100644 --- a/client/src/Types/state.ts +++ b/client/src/Types/state.ts @@ -1,3 +1,4 @@ +import type { ChartType } from "@/Features/UI/uiSlice"; import type { User } from "./User"; export interface AuthState { @@ -37,7 +38,7 @@ export interface UIState { distributedUptimeEnabled: boolean; language: string; starPromptOpen: boolean; - chartType: string; + chartType: ChartType; } export interface RootState { From cdbf5fd0c0b24b62cefdddeb5c4345a0df3a52e1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 20:14:35 +0000 Subject: [PATCH 366/392] simplify form --- client/src/Pages/Settings/index.tsx | 25 +++++++- client/src/Validation/settings.ts | 92 ++++++++++++----------------- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index c1b3ecdfa..c389c6159 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,9 +1,15 @@ import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; import { Autocomplete, Select } from "@/Components/v2/inputs"; import { Stack, useTheme, MenuItem, type SelectChangeEvent } from "@mui/material"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; import DummyChart from "@/Pages/Settings/DummyChart"; +import { useGet } from "@/Hooks/UseApi"; +import { useSettingsForm } from "@/Hooks/useSettingsForm"; +import type { SettingsFormData } from "@/Validation/settings"; import { setTimezone, @@ -25,6 +31,23 @@ export const SettingsPage = () => { const theme = useTheme(); const { t, i18n } = useTranslation(); const dispatch = useDispatch(); + + // Fetch settings data from API + const { data: fetchedSettings } = useGet("/settings"); + + // Initialize form with schema and defaults + const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + // Reset form when defaults change + useEffect(() => { + form.reset(defaults); + }, [defaults, form]); + const { timezone: selectedTimezoneId, mode, @@ -61,7 +84,7 @@ export const SettingsPage = () => { const languages = Object.keys(i18n.options.resources || {}); return ( - + - z.preprocess( - (val) => - val === "" || val === null || val === undefined ? undefined : String(val).trim(), - z.string().optional() - ); - -// Helper for threshold fields -const thresholdField = (min: number, max: number, unit: string) => - z.preprocess( - (val) => (val === "" || val === null || val === undefined ? undefined : val), - z.coerce - .number() - .int() - .min(min, `Min ${min}${unit}`) - .max(max, `Max ${max}${unit}`) - .optional() - ); - export const settingsSchema = z.object({ systemEmailIgnoreTLS: z.boolean(), systemEmailRequireTLS: z.boolean(), systemEmailRejectUnauthorized: z.boolean(), - systemEmailConnectionHost: optionalString(), + systemEmailConnectionHost: z + .string() + .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .optional(), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), showURL: z.boolean().optional(), - checkTTL: z.coerce - .number() - .int() - .min(1, "Please enter a value") - .max(365, "Maximum 365 days"), - pagespeedApiKey: optionalString(), - systemEmailHost: z.preprocess( - (val) => - val === "" || val === null || val === undefined ? undefined : String(val).trim(), - z - .string() - .regex(/^[a-zA-Z0-9.-]+$/, "Invalid hostname or IP address") - .optional() - ), - systemEmailPort: z.preprocess( - (val) => (val === "" || val === null || val === undefined ? undefined : val), - z.coerce - .number() - .int() - .min(1, "Port must be at least 1") - .max(65535, "Port must be at most 65535") - .optional() - ), - systemEmailAddress: z.preprocess((val) => { - if (val === "" || val === null || val === undefined) return undefined; - return String(val).toLowerCase().trim(); - }, z.string().email("Please enter a valid email address").optional()), - systemEmailUser: optionalString(), - systemEmailPassword: optionalString(), - systemEmailTLSServername: optionalString(), + checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"), + pagespeedApiKey: z + .string() + .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .optional(), + systemEmailHost: z + .string() + .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") + .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .optional(), + systemEmailPort: z.number().int().min(1).max(65535).optional(), + systemEmailAddress: z + .string() + .email("Please enter a valid email address") + .or(z.literal("")) + .transform((val) => (val === "" ? undefined : val.toLowerCase().trim())) + .optional(), + systemEmailUser: z + .string() + .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .optional(), + systemEmailPassword: z + .string() + .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .optional(), + systemEmailTLSServername: z + .string() + .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .optional(), globalThresholds: z .object({ - cpu: thresholdField(0, 100, "%"), - memory: thresholdField(0, 100, "%"), - disk: thresholdField(0, 100, "%"), - temperature: thresholdField(0, 150, "°C"), + cpu: z.number().int().min(0).max(100).optional(), + memory: z.number().int().min(0).max(100).optional(), + disk: z.number().int().min(0).max(100).optional(), + temperature: z.number().int().min(0).max(150).optional(), }) .optional(), }); From d8e7f9fc0f6d31d25f756f466fda804dedd318ab Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 20:20:45 +0000 Subject: [PATCH 367/392] add pagespeed config --- client/src/Pages/Settings/index.tsx | 91 ++++++++++++++++++++++++++++- client/src/locales/en.json | 13 ++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index c389c6159..979c013ff 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -9,7 +9,12 @@ import { useSelector, useDispatch } from "react-redux"; import DummyChart from "@/Pages/Settings/DummyChart"; import { useGet } from "@/Hooks/UseApi"; import { useSettingsForm } from "@/Hooks/useSettingsForm"; +import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData } from "@/Validation/settings"; +import { useState } from "react"; +import { Controller } from "react-hook-form"; +import { TextField, Button } from "@/Components/v2/inputs"; +import { Typography, Box } from "@mui/material"; import { setTimezone, @@ -27,13 +32,26 @@ interface Timezone { name: string; } +interface SettingsResponse { + settings: any; + pagespeedKeySet: boolean; + emailPasswordSet: boolean; +} + export const SettingsPage = () => { const theme = useTheme(); const { t, i18n } = useTranslation(); const dispatch = useDispatch(); + const isAdmin = useIsAdmin(); // Fetch settings data from API - const { data: fetchedSettings } = useGet("/settings"); + const { data: fetchedSettings } = useGet("/settings"); + + // Local state for API key reset + const [isApiKeySet, setIsApiKeySet] = useState( + fetchedSettings?.pagespeedKeySet ?? false + ); + const [apiKeyHasBeenReset, setApiKeyHasBeenReset] = useState(false); // Initialize form with schema and defaults const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); @@ -48,6 +66,13 @@ export const SettingsPage = () => { form.reset(defaults); }, [defaults, form]); + // Update isApiKeySet when fetchedSettings changes + useEffect(() => { + if (fetchedSettings) { + setIsApiKeySet(fetchedSettings.pagespeedKeySet); + } + }, [fetchedSettings]); + const { timezone: selectedTimezoneId, mode, @@ -81,6 +106,11 @@ export const SettingsPage = () => { dispatch(setChartType(e.target.value)); }; + const handleResetApiKey = () => { + form.setValue("pagespeedApiKey", ""); + setApiKeyHasBeenReset(true); + }; + const languages = Object.keys(i18n.options.resources || {}); return ( @@ -151,6 +181,65 @@ export const SettingsPage = () => { } /> + {isAdmin && ( + + {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( + ( + + )} + /> + )} + + {isApiKeySet === true && apiKeyHasBeenReset === false && ( + + + {t("pages.settings.pageSpeedSettings.labelApiKeySet")} + + + + )} + + } + /> + )} + + + ); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8d4e62a57..8bae5dcd0 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -38,7 +38,8 @@ "toggleTheme": "Toggles light & dark", "flushQueue": "Flush queue", "notFound": "Go to the main dashboard", - "resetPassword": "Reset password" + "resetPassword": "Reset password", + "reset": "Reset" }, "charts": { "labels": { @@ -936,6 +937,16 @@ "heatmap": "Heatmap" } } + }, + "pagespeed": { + "title": "Google PageSpeed API key", + "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", + "option": { + "apiKey": { + "label": "PageSpeed API key", + "placeholder": "Enter your Google PageSpeed API key" + } + } } } }, From a59f7fd830bbfc7e0dde5a6f06fcbcc1a400e895 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 20:35:46 +0000 Subject: [PATCH 368/392] pagespeed --- client/src/Pages/Settings/index.tsx | 91 +++++++++++++++++++++++------ client/src/locales/en.json | 1 + 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 979c013ff..d0ad5a6e0 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -7,13 +7,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; import DummyChart from "@/Pages/Settings/DummyChart"; -import { useGet } from "@/Hooks/UseApi"; +import { useGet, usePatch } from "@/Hooks/UseApi"; import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData } from "@/Validation/settings"; import { useState } from "react"; import { Controller } from "react-hook-form"; -import { TextField, Button } from "@/Components/v2/inputs"; +import { TextField, FieldLabel, Button } from "@/Components/v2/inputs"; import { Typography, Box } from "@mui/material"; import { @@ -46,6 +46,8 @@ export const SettingsPage = () => { // Fetch settings data from API const { data: fetchedSettings } = useGet("/settings"); + // Form submission + const { patch, loading: isSaving } = usePatch(); // Local state for API key reset const [isApiKeySet, setIsApiKeySet] = useState( @@ -111,10 +113,49 @@ export const SettingsPage = () => { setApiKeyHasBeenReset(true); }; + const onSubmit = async (data: SettingsFormData) => { + // Convert undefined to empty string for backend unsetting + const processedData = Object.entries(data).reduce((acc, [key, value]) => { + const typedKey = key as keyof SettingsFormData; + if (value === undefined) { + (acc as any)[typedKey] = ""; + } else if (typeof value === "object" && value !== null) { + // Handle nested objects like globalThresholds + (acc as any)[typedKey] = Object.entries(value).reduce( + (nested, [nestedKey, nestedValue]) => ({ + ...nested, + [nestedKey]: nestedValue === undefined ? "" : nestedValue, + }), + {} + ); + } else { + (acc as any)[typedKey] = value; + } + return acc; + }, {} as Partial); + + const result = await patch("/settings", processedData as SettingsFormData); + + if (result?.success) { + // Update API key state from response + if (result.data) { + setIsApiKeySet(result.data.pagespeedKeySet); + setApiKeyHasBeenReset(false); + } + } + }; + + const onError = (errors: unknown) => { + console.log("Form validation errors:", errors); + }; + const languages = Object.keys(i18n.options.resources || {}); return ( - + { {isApiKeySet === true && apiKeyHasBeenReset === false && ( - - {t("pages.settings.pageSpeedSettings.labelApiKeySet")} - + + {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} + - + {t("common.buttons.save")} + ); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8bae5dcd0..75b4eafc3 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -944,6 +944,7 @@ "option": { "apiKey": { "label": "PageSpeed API key", + "labelSet": "API key is set. Click Reset to change it.", "placeholder": "Enter your Google PageSpeed API key" } } From d4e72ee8a6938031f92b7b2e9a39e5ba4ce038d0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 21:02:08 +0000 Subject: [PATCH 369/392] url settigns --- client/src/Pages/Settings/index.tsx | 36 +++++++++++++++++++++++++++-- client/src/locales/en.json | 22 ++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index d0ad5a6e0..b71f353d1 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -13,8 +13,8 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData } from "@/Validation/settings"; import { useState } from "react"; import { Controller } from "react-hook-form"; -import { TextField, FieldLabel, Button } from "@/Components/v2/inputs"; -import { Typography, Box } from "@mui/material"; +import { TextField, Button, FieldLabel } from "@/Components/v2/inputs"; +import { Box } from "@mui/material"; import { setTimezone, @@ -268,6 +268,38 @@ export const SettingsPage = () => { } /> )} + + {/* URL Settings */} + ( + + )} + /> + } + /> {/* Sticky Save Button */} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 75b4eafc3..c33d3dbfd 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -544,6 +544,17 @@ "label": "Temperature alert threshold (°C)" } } + }, + "url": { + "title": "Monitor IP/URL on Status Page", + "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", + "option": { + "showURL": { + "label": "Display IP/URL on status page", + "enabled": "Enabled", + "disabled": "Disabled" + } + } } } }, @@ -948,6 +959,17 @@ "placeholder": "Enter your Google PageSpeed API key" } } + }, + "url": { + "title": "Monitor IP/URL on Status Page", + "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", + "option": { + "showURL": { + "label": "Display IP/URL on status page", + "enabled": "Enabled", + "disabled": "Disabled" + } + } } } }, From b5f2a7b26cb1674cb56f5df514e1e37592eaa99a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 18 Feb 2026 21:06:29 +0000 Subject: [PATCH 370/392] Fix pagespeed key issue --- client/src/Pages/Settings/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index b71f353d1..da3142cfb 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -114,8 +114,14 @@ export const SettingsPage = () => { }; const onSubmit = async (data: SettingsFormData) => { + // Don't send pagespeedApiKey if it's already set and user hasn't clicked reset + const dataToSend = { ...data }; + if (isApiKeySet && !apiKeyHasBeenReset) { + delete (dataToSend as any).pagespeedApiKey; + } + // Convert undefined to empty string for backend unsetting - const processedData = Object.entries(data).reduce((acc, [key, value]) => { + const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => { const typedKey = key as keyof SettingsFormData; if (value === undefined) { (acc as any)[typedKey] = ""; From a3a25b2adddda0e8c18585634a2cb3f1849114a4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 19 Feb 2026 17:28:00 +0000 Subject: [PATCH 371/392] clear-stats --- client/src/Pages/Settings/index.tsx | 38 ++++++++++++++++++++++++++++- client/src/locales/en.json | 26 +++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index da3142cfb..270e0a26f 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; -import { Autocomplete, Select } from "@/Components/v2/inputs"; +import { Autocomplete, Select, Dialog } from "@/Components/v2/inputs"; import { Stack, useTheme, MenuItem, type SelectChangeEvent } from "@mui/material"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -15,6 +15,7 @@ import { useState } from "react"; import { Controller } from "react-hook-form"; import { TextField, Button, FieldLabel } from "@/Components/v2/inputs"; import { Box } from "@mui/material"; +import { useDelete } from "@/Hooks/UseApi"; import { setTimezone, @@ -54,6 +55,9 @@ export const SettingsPage = () => { fetchedSettings?.pagespeedKeySet ?? false ); const [apiKeyHasBeenReset, setApiKeyHasBeenReset] = useState(false); + // Local state for clear stats dialog + const [isStatsDialogOpen, setIsStatsDialogOpen] = useState(false); + const { deleteFn: deleteStats, loading: isDeletingStats } = useDelete(); // Initialize form with schema and defaults const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); @@ -113,6 +117,11 @@ export const SettingsPage = () => { setApiKeyHasBeenReset(true); }; + const handleClearStats = async () => { + await deleteStats("/checks/team"); + setIsStatsDialogOpen(false); + }; + const onSubmit = async (data: SettingsFormData) => { // Don't send pagespeedApiKey if it's already set and user hasn't clicked reset const dataToSend = { ...data }; @@ -306,8 +315,35 @@ export const SettingsPage = () => { /> } /> + + {/* Clear All Stats */} + {isAdmin && ( + setIsStatsDialogOpen(true)} + > + {t("common.buttons.clear")} + + } + /> + )} + {/* Clear Stats Confirmation Dialog */} + setIsStatsDialogOpen(false)} + onConfirm={handleClearStats} + loading={isDeletingStats} + /> + {/* Sticky Save Button */} Date: Thu, 19 Feb 2026 17:33:28 +0000 Subject: [PATCH 372/392] add global thresholds configbox --- client/src/Pages/Settings/index.tsx | 85 ++++++++++++++++++++++++++++- client/src/locales/en.json | 22 ++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 270e0a26f..b5d93b12e 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -327,11 +327,93 @@ export const SettingsPage = () => { color="error" onClick={() => setIsStatsDialogOpen(true)} > - {t("common.buttons.clear")} + {t("pages.settings.form.stats.buttonText")} } /> )} + + {/* Global Thresholds */} + {isAdmin && ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {/* Clear Stats Confirmation Dialog */} @@ -370,6 +452,7 @@ export const SettingsPage = () => { {t("common.buttons.save")} +
{JSON.stringify(form.formState.isValid, null, 2)}
); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2af79c1f7..ef7ea8591 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -994,6 +994,28 @@ "title": "Clear all monitoring history?", "description": "This cannot be undone" } + }, + "thresholds": { + "title": "Global Thresholds", + "description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.", + "option": { + "cpu": { + "label": "CPU Threshold (%)", + "placeholder": "0 - 100" + }, + "memory": { + "label": "Memory Threshold (%)", + "placeholder": "0 - 100" + }, + "disk": { + "label": "Disk Threshold (%)", + "placeholder": "0 - 100" + }, + "temperature": { + "label": "Temperature Threshold (°C)", + "placeholder": "0 - 150" + } + } } } }, From f2779160a1c1a0ad7397b59ecf313f17029c6b93 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 19 Feb 2026 17:55:55 +0000 Subject: [PATCH 373/392] port --- client/src/Hooks/useSettingsForm.ts | 8 +++---- client/src/Pages/Settings/index.tsx | 36 ++++++++++++++++++++++++++--- client/src/Validation/settings.ts | 8 +++---- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index ee4227970..ce04a81e2 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -22,10 +22,10 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => systemEmailTLSServername: data?.systemEmailTLSServername || "", systemEmailPort: data?.systemEmailPort, globalThresholds: { - cpu: data?.globalThresholds?.cpu, - memory: data?.globalThresholds?.memory, - disk: data?.globalThresholds?.disk, - temperature: data?.globalThresholds?.temperature, + cpu: data?.globalThresholds?.cpu ?? 0, + memory: data?.globalThresholds?.memory ?? 0, + disk: data?.globalThresholds?.disk ?? 0, + temperature: data?.globalThresholds?.temperature ?? 0, }, checkTTL: data?.checkTTL ?? 30, pagespeedApiKey: "", diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index b5d93b12e..66fb65f8d 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -65,6 +65,7 @@ export const SettingsPage = () => { const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, + mode: "onChange", }); // Reset form when defaults change @@ -136,10 +137,12 @@ export const SettingsPage = () => { (acc as any)[typedKey] = ""; } else if (typeof value === "object" && value !== null) { // Handle nested objects like globalThresholds + // Convert 0 to "" for unsetting thresholds (acc as any)[typedKey] = Object.entries(value).reduce( (nested, [nestedKey, nestedValue]) => ({ ...nested, - [nestedKey]: nestedValue === undefined ? "" : nestedValue, + [nestedKey]: + nestedValue === undefined || nestedValue === 0 ? "" : nestedValue, }), {} ); @@ -327,7 +330,7 @@ export const SettingsPage = () => { color="error" onClick={() => setIsStatsDialogOpen(true)} > - {t("pages.settings.form.stats.buttonText")} + {t("common.buttons.clear")} } /> @@ -347,6 +350,13 @@ export const SettingsPage = () => { render={({ field, fieldState }) => ( { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} fieldLabel={t("pages.settings.form.thresholds.option.cpu.label")} type="number" placeholder={t( @@ -364,6 +374,13 @@ export const SettingsPage = () => { render={({ field, fieldState }) => ( { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} fieldLabel={t("pages.settings.form.thresholds.option.memory.label")} type="number" placeholder={t( @@ -381,6 +398,13 @@ export const SettingsPage = () => { render={({ field, fieldState }) => ( { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} fieldLabel={t("pages.settings.form.thresholds.option.disk.label")} type="number" placeholder={t( @@ -398,6 +422,13 @@ export const SettingsPage = () => { render={({ field, fieldState }) => ( { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} fieldLabel={t( "pages.settings.form.thresholds.option.temperature.label" )} @@ -452,7 +483,6 @@ export const SettingsPage = () => { {t("common.buttons.save")} -
{JSON.stringify(form.formState.isValid, null, 2)}
); }; diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 25390b241..5f6a06fd1 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -42,10 +42,10 @@ export const settingsSchema = z.object({ .optional(), globalThresholds: z .object({ - cpu: z.number().int().min(0).max(100).optional(), - memory: z.number().int().min(0).max(100).optional(), - disk: z.number().int().min(0).max(100).optional(), - temperature: z.number().int().min(0).max(150).optional(), + cpu: z.number().int().min(0).max(100), + memory: z.number().int().min(0).max(100), + disk: z.number().int().min(0).max(100), + temperature: z.number().int().min(0).max(150), }) .optional(), }); From 9283890b5fc5aa89b544e286430abe5e728dccce Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 19 Feb 2026 18:01:15 +0000 Subject: [PATCH 374/392] 0 handling --- client/src/Validation/settings.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 5f6a06fd1..f292222d0 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -42,10 +42,30 @@ export const settingsSchema = z.object({ .optional(), globalThresholds: z .object({ - cpu: z.number().int().min(0).max(100), - memory: z.number().int().min(0).max(100), - disk: z.number().int().min(0).max(100), - temperature: z.number().int().min(0).max(150), + cpu: z + .number() + .int() + .min(1, "Please enter a value") + .max(100, "Maximum value is 100") + .optional(), + memory: z + .number() + .int() + .min(1, "Please enter a value") + .max(100, "Maximum value is 100") + .optional(), + disk: z + .number() + .int() + .min(1, "Please enter a value") + .max(100, "Maximum value is 100") + .optional(), + temperature: z + .number() + .int() + .min(1, "Please enter a value") + .max(150, "Maximum value is 150") + .optional(), }) .optional(), }); From 889bb0794691a2934a7fdbdb7d14b9516dcbdbee Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 19 Feb 2026 18:04:34 +0000 Subject: [PATCH 375/392] min values --- client/src/Pages/Settings/index.tsx | 4 ++++ client/src/Validation/settings.ts | 8 ++++---- client/src/locales/en.json | 8 ++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 66fb65f8d..077755bf1 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -359,6 +359,7 @@ export const SettingsPage = () => { }} fieldLabel={t("pages.settings.form.thresholds.option.cpu.label")} type="number" + inputProps={{ min: 0 }} placeholder={t( "pages.settings.form.thresholds.option.cpu.placeholder" )} @@ -383,6 +384,7 @@ export const SettingsPage = () => { }} fieldLabel={t("pages.settings.form.thresholds.option.memory.label")} type="number" + inputProps={{ min: 0 }} placeholder={t( "pages.settings.form.thresholds.option.memory.placeholder" )} @@ -407,6 +409,7 @@ export const SettingsPage = () => { }} fieldLabel={t("pages.settings.form.thresholds.option.disk.label")} type="number" + inputProps={{ min: 0 }} placeholder={t( "pages.settings.form.thresholds.option.disk.placeholder" )} @@ -433,6 +436,7 @@ export const SettingsPage = () => { "pages.settings.form.thresholds.option.temperature.label" )} type="number" + inputProps={{ min: 0 }} placeholder={t( "pages.settings.form.thresholds.option.temperature.placeholder" )} diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index f292222d0..4c89a4c2c 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -45,25 +45,25 @@ export const settingsSchema = z.object({ cpu: z .number() .int() - .min(1, "Please enter a value") + .min(1, "Please enter a valid value") .max(100, "Maximum value is 100") .optional(), memory: z .number() .int() - .min(1, "Please enter a value") + .min(1, "Please enter a valid value") .max(100, "Maximum value is 100") .optional(), disk: z .number() .int() - .min(1, "Please enter a value") + .min(1, "Please enter a valid value") .max(100, "Maximum value is 100") .optional(), temperature: z .number() .int() - .min(1, "Please enter a value") + .min(1, "Please enter a valid value") .max(150, "Maximum value is 150") .optional(), }) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ef7ea8591..9ce42a067 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1001,19 +1001,19 @@ "option": { "cpu": { "label": "CPU Threshold (%)", - "placeholder": "0 - 100" + "placeholder": "1 - 100" }, "memory": { "label": "Memory Threshold (%)", - "placeholder": "0 - 100" + "placeholder": "1 - 100" }, "disk": { "label": "Disk Threshold (%)", - "placeholder": "0 - 100" + "placeholder": "1 - 100" }, "temperature": { "label": "Temperature Threshold (°C)", - "placeholder": "0 - 150" + "placeholder": "1 - 150" } } } From dae2709d317632d65da290cb33b2ce2818df2c13 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 19 Feb 2026 23:55:43 +0530 Subject: [PATCH 376/392] Fix: Authentication Middleware in StatusPage --- server/src/routes/statusPageRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/statusPageRoute.ts b/server/src/routes/statusPageRoute.ts index 1468f9d16..81d77fd19 100755 --- a/server/src/routes/statusPageRoute.ts +++ b/server/src/routes/statusPageRoute.ts @@ -18,7 +18,7 @@ class StatusPageRoutes { this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); - this.router.get("/:url", this.statusPageController.getStatusPageByUrl); + this.router.get("/:url", verifyJWT, this.statusPageController.getStatusPageByUrl); this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); } From 6a04ce5eeea50f4a49575d78c1b38e41603db1e6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 19 Feb 2026 18:26:40 +0000 Subject: [PATCH 377/392] demo monitors, remove monitors --- client/src/Pages/Settings/index.tsx | 60 ++++++++++++++++++++++++++++- client/src/locales/en.json | 16 +++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 077755bf1..d956fbe82 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; import DummyChart from "@/Pages/Settings/DummyChart"; -import { useGet, usePatch } from "@/Hooks/UseApi"; +import { useGet, usePatch, usePost } from "@/Hooks/UseApi"; import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData } from "@/Validation/settings"; @@ -44,6 +44,10 @@ export const SettingsPage = () => { const { t, i18n } = useTranslation(); const dispatch = useDispatch(); const isAdmin = useIsAdmin(); + // Local state for demo monitors dialog + const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false); + const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost(); + const { deleteFn: deleteAllMonitors, loading: isDeletingAllMonitors } = useDelete(); // Fetch settings data from API const { data: fetchedSettings } = useGet("/settings"); @@ -451,6 +455,47 @@ export const SettingsPage = () => { )} + {/* Demo Monitors - Admin Only */} + {isAdmin && ( + + +
+ } + /> + )} + + {/* Remove All Monitors - Admin Only */} + {isAdmin && ( + + + + } + /> + )} + {/* Clear Stats Confirmation Dialog */} { loading={isDeletingStats} /> + {/* Delete All Monitors Confirmation Dialog */} + setIsDemoMonitorsDialogOpen(false)} + onConfirm={async () => { + await deleteAllMonitors("/monitors/"); + setIsDemoMonitorsDialogOpen(false); + }} + loading={isDeletingAllMonitors} + /> + {/* Sticky Save Button */} Date: Fri, 20 Feb 2026 00:30:06 +0530 Subject: [PATCH 378/392] feat: verifyStatusPage middleware --- .../src/middleware/verifyStatusPageAccess.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 server/src/middleware/verifyStatusPageAccess.ts diff --git a/server/src/middleware/verifyStatusPageAccess.ts b/server/src/middleware/verifyStatusPageAccess.ts new file mode 100644 index 000000000..4b1627b0a --- /dev/null +++ b/server/src/middleware/verifyStatusPageAccess.ts @@ -0,0 +1,18 @@ +import { NextFunction, Request, Response } from "express"; +import { IStatusPagesRepository } from "@/repositories/index.js"; + +export const createVerifyStatusPageAccess = (statusPagesRepository: IStatusPagesRepository) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const url = req.params.url; + const statusPage = await statusPagesRepository.findByUrl(url); + if (statusPage.isPublished) { + next(); // Published — proceed to controller (no JWT) + } else { + next("route"); // Unpublished — skip to next route (which has verifyJWT) + } + } catch (error) { + next(error); + } + }; +}; From 600c937062a820fd46c1b20c3bc052434287bf9c Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Fri, 20 Feb 2026 00:31:42 +0530 Subject: [PATCH 379/392] feat: API Middleware --- server/src/routes/statusPageRoute.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/src/routes/statusPageRoute.ts b/server/src/routes/statusPageRoute.ts index 81d77fd19..ca917af2a 100755 --- a/server/src/routes/statusPageRoute.ts +++ b/server/src/routes/statusPageRoute.ts @@ -6,18 +6,21 @@ class StatusPageRoutes { private router: Router; private statusPageController: any; - constructor(statusPageController: any, verifyJWT: RequestHandler) { + constructor(statusPageController: any, verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router = Router(); this.statusPageController = statusPageController; - this.initRoutes(verifyJWT); + this.initRoutes(verifyJWT, verifyStatusPageAccess); } - initRoutes(verifyJWT: RequestHandler) { + initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + // Route 1: published pages — no JWT + this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); + // Route 2: unpublished pages — JWT required (reached via next("route")) this.router.get("/:url", verifyJWT, this.statusPageController.getStatusPageByUrl); this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); } From 0974c84b8d9143464410f361e2069c5c0c97d43e Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Fri, 20 Feb 2026 00:34:23 +0530 Subject: [PATCH 380/392] feat: fe integration of new endpoint --- client/src/Utils/ApiClient.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts index b18f2dfbc..0fef9b4a0 100644 --- a/client/src/Utils/ApiClient.ts +++ b/client/src/Utils/ApiClient.ts @@ -61,7 +61,10 @@ export const initApiClient = (store: StoreType): void => { } if (error.response?.status === 401) { - if (window.location.pathname !== "/login") { + if ( + window.location.pathname !== "/login" && + !window.location.pathname.startsWith("/status/public") + ) { window.location.href = "/login"; } } From 1a52531fafb501a5fdd2a5e0083afa1709a18177 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Fri, 20 Feb 2026 00:34:43 +0530 Subject: [PATCH 381/392] feat: new middleware --- server/src/config/routes.ts | 4 +++- server/src/controllers/statusPageController.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index d38ac69ae..ba9158cd5 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -1,4 +1,5 @@ import { createVerifyJWT } from "../middleware/verifyJWT.js"; +import { createVerifyStatusPageAccess } from "../middleware/verifyStatusPageAccess.js"; import { authApiLimiter } from "../middleware/rateLimiter.js"; import AuthRoutes from "../routes/authRoute.js"; @@ -25,7 +26,8 @@ export const setupRoutes = (app: any, controllers: Record, services const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController); const queueRoutes = new QueueRoutes(controllers.queueController); const logRoutes = new LogRoutes(controllers.logController); - const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT); + const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository); + const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT, verifyStatusPageAccess); const notificationRoutes = new NotificationRoutes(controllers.notificationController); const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController, verifyJWT); const incidentRoutes = new IncidentRoutes(controllers.incidentController); diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index f9cca161e..b936ff5ad 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -78,6 +78,14 @@ class StatusPageController { } const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); + + if (!statusPage.isPublished) { + const teamId = requireTeamId(req?.user?.teamId); + if (statusPage.teamId !== teamId) { + throw new AppError({ message: "Forbidden", status: 403 }); + } + } + const settings = await this.settingsService.getDBSettings(); const showURL = settings.showURL; From c27bb0e7f15c40188d8c82e9eb3d094c3f8d175d Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Fri, 20 Feb 2026 00:52:36 +0530 Subject: [PATCH 382/392] fix: build error --- server/src/middleware/verifyStatusPageAccess.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/middleware/verifyStatusPageAccess.ts b/server/src/middleware/verifyStatusPageAccess.ts index 4b1627b0a..6dbf9ba72 100644 --- a/server/src/middleware/verifyStatusPageAccess.ts +++ b/server/src/middleware/verifyStatusPageAccess.ts @@ -1,10 +1,14 @@ import { NextFunction, Request, Response } from "express"; import { IStatusPagesRepository } from "@/repositories/index.js"; +import { AppError } from "@/utils/AppError.js"; export const createVerifyStatusPageAccess = (statusPagesRepository: IStatusPagesRepository) => { return async (req: Request, res: Response, next: NextFunction) => { try { const url = req.params.url; + if (!url) { + throw new AppError({ message: "Status page URL is required", status: 400 }); + } const statusPage = await statusPagesRepository.findByUrl(url); if (statusPage.isPublished) { next(); // Published — proceed to controller (no JWT) From b64cf3f6b6eadaefdb3bd725fbed92cb6d65d4dc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 19 Feb 2026 19:23:40 +0000 Subject: [PATCH 383/392] email --- client/src/Pages/Settings/index.tsx | 340 +++++++++++++++++++++++++++- client/src/locales/en.json | 53 ++++- 2 files changed, 390 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index d956fbe82..d1b6df976 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; -import { Autocomplete, Select, Dialog } from "@/Components/v2/inputs"; +import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/v2/inputs"; import { Stack, useTheme, MenuItem, type SelectChangeEvent } from "@mui/material"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -14,7 +14,7 @@ import type { SettingsFormData } from "@/Validation/settings"; import { useState } from "react"; import { Controller } from "react-hook-form"; import { TextField, Button, FieldLabel } from "@/Components/v2/inputs"; -import { Box } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import { useDelete } from "@/Hooks/UseApi"; import { @@ -59,6 +59,13 @@ export const SettingsPage = () => { fetchedSettings?.pagespeedKeySet ?? false ); const [apiKeyHasBeenReset, setApiKeyHasBeenReset] = useState(false); + // Local state for email password reset + const [isEmailPasswordSet, setIsEmailPasswordSet] = useState( + fetchedSettings?.emailPasswordSet ?? false + ); + const [emailPasswordHasBeenReset, setEmailPasswordHasBeenReset] = useState(false); + // Test email functionality + const { post: sendTestEmail, loading: isSendingTestEmail } = usePost(); // Local state for clear stats dialog const [isStatsDialogOpen, setIsStatsDialogOpen] = useState(false); const { deleteFn: deleteStats, loading: isDeletingStats } = useDelete(); @@ -81,6 +88,7 @@ export const SettingsPage = () => { useEffect(() => { if (fetchedSettings) { setIsApiKeySet(fetchedSettings.pagespeedKeySet); + setIsEmailPasswordSet(fetchedSettings.emailPasswordSet); } }, [fetchedSettings]); @@ -91,6 +99,8 @@ export const SettingsPage = () => { chartType = "histogram", } = useSelector((state: RootState) => state.ui); + const user = useSelector((state: RootState) => state.auth.user); + // Convert timezones to match AutoComplete format (id instead of _id) const timezoneOptions: Timezone[] = timezones.map((tz) => ({ id: tz._id, @@ -122,6 +132,48 @@ export const SettingsPage = () => { setApiKeyHasBeenReset(true); }; + const handleResetEmailPassword = () => { + form.setValue("systemEmailPassword", ""); + setEmailPasswordHasBeenReset(true); + }; + + const handleSendTestEmail = async () => { + const formValues = form.getValues(); + if (!user) { + alert("User not authenticated"); + return; + } + if ( + !formValues.systemEmailHost || + !formValues.systemEmailPort || + !formValues.systemEmailAddress || + !formValues.systemEmailPassword + ) { + alert("Please fill in all required email fields before testing."); + return; + } + + await sendTestEmail("/settings/test-email", { + to: user.email, + systemEmailHost: formValues.systemEmailHost, + systemEmailPort: formValues.systemEmailPort, + systemEmailAddress: formValues.systemEmailAddress, + systemEmailPassword: formValues.systemEmailPassword, + systemEmailSecure: formValues.systemEmailSecure, + systemEmailPool: formValues.systemEmailPool, + systemEmailIgnoreTLS: formValues.systemEmailIgnoreTLS, + systemEmailRequireTLS: formValues.systemEmailRequireTLS, + systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized, + ...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }), + ...(formValues.systemEmailTLSServername && { + systemEmailTLSServername: formValues.systemEmailTLSServername, + }), + ...(formValues.systemEmailConnectionHost && { + systemEmailConnectionHost: formValues.systemEmailConnectionHost, + }), + }); + }; + const handleClearStats = async () => { await deleteStats("/checks/team"); setIsStatsDialogOpen(false); @@ -133,6 +185,9 @@ export const SettingsPage = () => { if (isApiKeySet && !apiKeyHasBeenReset) { delete (dataToSend as any).pagespeedApiKey; } + if (isEmailPasswordSet && !emailPasswordHasBeenReset) { + delete (dataToSend as any).systemEmailPassword; + } // Convert undefined to empty string for backend unsetting const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => { @@ -163,6 +218,8 @@ export const SettingsPage = () => { if (result.data) { setIsApiKeySet(result.data.pagespeedKeySet); setApiKeyHasBeenReset(false); + setIsEmailPasswordSet(result.data.emailPasswordSet); + setEmailPasswordHasBeenReset(false); } } }; @@ -455,6 +512,285 @@ export const SettingsPage = () => { )} + {/* Email Settings - Admin Only */} + {isAdmin && ( + + + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: + form.watch("systemEmailUser") || + form.watch("systemEmailAddress") || + "", + pass: "", + }, + name: form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} + + + } + rightContent={ + + {/* Email Host */} + ( + + )} + /> + + {/* Email Port */} + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + fieldLabel={t("pages.settings.form.email.option.port.label")} + type="number" + inputProps={{ min: 0 }} + placeholder={t("pages.settings.form.email.option.port.placeholder")} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + + {/* Email Address */} + ( + + )} + /> + + {/* Email User (Optional) */} + ( + + )} + /> + + {/* Email Password with Reset Pattern */} + {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( + + + {t("pages.settings.form.email.option.password.labelSet")} + + + + + + ) : ( + ( + + )} + /> + )} + + {/* TLS Servername (Optional) */} + ( + + )} + /> + + {/* Connection Host (Optional) */} + ( + + )} + /> + + {/* Boolean Switches */} + + {[ + { + name: "systemEmailSecure", + label: t("pages.settings.form.email.option.secure.label"), + }, + { + name: "systemEmailPool", + label: t("pages.settings.form.email.option.pool.label"), + }, + { + name: "systemEmailIgnoreTLS", + label: t("pages.settings.form.email.option.ignoreTLS.label"), + }, + { + name: "systemEmailRequireTLS", + label: t("pages.settings.form.email.option.requireTLS.label"), + }, + { + name: "systemEmailRejectUnauthorized", + label: t("pages.settings.form.email.option.rejectUnauthorized.label"), + }, + ].map(({ name, label }) => ( + ( + + {label} + ) => + field.onChange(e.target.checked) + } + /> + + )} + /> + ))} + + + {/* Test Email Button */} + + + + + } + /> + )} + {/* Demo Monitors - Admin Only */} {isAdmin && ( Date: Fri, 20 Feb 2026 17:20:43 +0000 Subject: [PATCH 384/392] add link text --- .../v2/design-elements/TextLink.tsx | 3 +- client/src/Pages/Settings/index.tsx | 84 ++++++++++--------- client/src/locales/en.json | 2 + 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/client/src/Components/v2/design-elements/TextLink.tsx b/client/src/Components/v2/design-elements/TextLink.tsx index 704b4b841..2f79b1beb 100644 --- a/client/src/Components/v2/design-elements/TextLink.tsx +++ b/client/src/Components/v2/design-elements/TextLink.tsx @@ -10,7 +10,7 @@ interface TextLinkProps extends StackProps { text: string; linkText: string; href: string; - target?: string; + target?: "_self" | "_blank" | "_parent" | "_top"; } export const TextLink = ({ @@ -34,6 +34,7 @@ export const TextLink = ({ to={href} component={RouterLink} target={target} + {...(target === "_blank" && { rel: "noopener noreferrer" })} > {linkText} diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index d1b6df976..a8549dcc4 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { BasePage, ConfigBox, TextLink } from "@/Components/v2/design-elements"; import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/v2/inputs"; import { Stack, useTheme, MenuItem, type SelectChangeEvent } from "@mui/material"; import { useEffect } from "react"; @@ -518,44 +518,52 @@ export const SettingsPage = () => { title={t("pages.settings.form.email.title")} subtitle={t("pages.settings.form.email.description")} leftContent={ - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: - form.watch("systemEmailUser") || - form.watch("systemEmailAddress") || - "", - pass: "", + + + + + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: + form.watch("systemEmailUser") || + form.watch("systemEmailAddress") || + "", + pass: "", + }, + name: form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - - + null, + 2 + )} + + + } rightContent={ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 4dfddb23f..b3d2d6bba 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1023,6 +1023,8 @@ "email": { "title": "Email settings", "description": "Configure the email settings for your system. This is used to send notifications and alerts.", + "descriptionTransport": "This builds an SMTP transport for NodeMailer", + "descriptionTransportLink": "See specifications here", "option": { "host": { "label": "Email host - Hostname or IP address to connect to", From 42fe6ade3efc747906838c1d177dd328451e0a2c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Feb 2026 17:22:20 +0000 Subject: [PATCH 385/392] target blank --- client/src/Pages/Settings/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index a8549dcc4..427eb3f73 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -523,7 +523,7 @@ export const SettingsPage = () => { text={t("pages.settings.form.email.descriptionTransport")} linkText={t("pages.settings.form.email.descriptionTransportLink")} href="https://nodemailer.com/smtp/" - target="_top" + target="_blank" /> Date: Fri, 20 Feb 2026 17:22:43 +0000 Subject: [PATCH 386/392] remove old settings strings --- client/src/locales/en.json | 86 -------------------------------------- 1 file changed, 86 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b3d2d6bba..407c47545 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1086,92 +1086,6 @@ } } }, - "oldSettings": { - "aboutSettings": { - "labelDevelopedBy": "Developed by Bluewave Labs", - "labelVersion": "Version", - "title": "About" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "Add demo monitors", - "description": "Add sample monitors for demonstration purposes.", - "title": "Demo monitors" - }, - "emailSettings": { - "buttonSendTestEmail": "Send test e-mail", - "description": "Configure the email settings for your system. This is used to send notifications and alerts.", - "descriptionTransport": "This builds an SMTP transport for NodeMailer", - "labelAddress": "Email address - Used for authentication", - "labelConnectionHost": "Email connection host - Hostname to use in the HELO/EHLO greeting", - "labelHost": "Email host - Hostname or IP address to connect to", - "labelIgnoreTLS": "Disable STARTTLS: Don't use TLS even if the server supports it", - "labelPassword": "Email password - Password for authentication", - "labelPasswordSet": "Password is set. Click Reset to change it.", - "labelPool": "Enable connection pooling: Reuse existing connections to improve performance", - "labelPort": "Email port - Port to connect to", - "labelRejectUnauthorized": "Reject invalid certificates: Reject connections with self-signed or untrusted certificates", - "labelRequireTLS": "Force STARTTLS: Require TLS upgrade, fail if not supported", - "labelSecure": "Use SSL (recommended): Encrypt the connection using SSL/TLS", - "labelTLSServername": "TLS Servername - Optional Hostname for TLS Validation when host is an IP", - "labelUser": "Email user - Username for authentication, overrides email address if specified", - "linkTransport": "See specifications here", - "placeholderUser": "Leave empty if not required", - "title": "Email", - "toastEmailRequiredFieldsError": "Email address, host, port and password are required" - }, - "globalThresholds": { - "title": "Global Thresholds", - "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." - }, - "pageSpeedSettings": { - "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", - "labelApiKeySet": "API key is set. Click Reset to change it.", - "labelApiKey": "PageSpeed API key", - "title": "Google PageSpeed API key" - }, - "saveButtonLabel": "Save", - "statsSettings": { - "clearAllStatsButton": "Clear all stats", - "clearAllStatsDescription": "Clear all stats. This is irreversible.", - "clearAllStatsDialogConfirm": "Yes, clear all stats", - "clearAllStatsDialogDescription": "Once removed, the monitoring history and stats cannot be retrieved.", - "clearAllStatsDialogTitle": "Do you want to clear all stats?", - "description": "Define how long you want to retain historical data. You can also clear all existing data.", - "labelTTL": "The days you want to keep monitoring history.", - "labelTTLOptional": "0 for infinite", - "title": "Monitor history" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "Remove all monitors", - "description": "Remove all monitors from your system.", - "dialogConfirm": "Yes, remove all monitors", - "dialogDescription": "Once removed, the monitors cannot be retrieved.", - "dialogTitle": "Do you want to remove all monitors?", - "title": "System reset" - }, - "timezoneSettings": { - "description": "Select the timezone used to display dates and times throughout the application.", - "label": "Display timezone", - "title": "Display timezone" - }, - "title": "Settings", - "uiSettings": { - "description": "Switch between light and dark mode, or change user interface language.", - "labelLanguage": "Language", - "labelTheme": "Theme mode", - "labelChartType": "Chart type", - "chartTypeHistogram": "Histogram", - "chartTypeHeatmap": "Heatmap", - "title": "Appearance" - }, - "urlSettings": { - "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", - "label": "Display IP/URL on status page", - "selectDisabled": "Disabled", - "selectEnabled": "Enabled", - "title": "Monitor IP/URL on Status Page" - } - }, "statusPages": { "deleteSuccess": "Status page deleted successfully", "fallback": { From fc6300d0571ea92434b6d04d7d87be6d83e09020 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Feb 2026 17:36:04 +0000 Subject: [PATCH 387/392] remove old, add about and export --- client/src/Pages/Settings/SettingsAbout.jsx | 39 -- .../Pages/Settings/SettingsDemoMonitors.jsx | 97 ----- client/src/Pages/Settings/SettingsEmail.jsx | 369 ------------------ client/src/Pages/Settings/SettingsExport.jsx | 81 ---- .../Settings/SettingsGlobalThresholds.jsx | 79 ---- .../src/Pages/Settings/SettingsPagespeed.jsx | 100 ----- client/src/Pages/Settings/SettingsStats.jsx | 80 ---- .../src/Pages/Settings/SettingsTimeZone.jsx | 70 ---- client/src/Pages/Settings/SettingsUI.jsx | 81 ---- client/src/Pages/Settings/SettingsURL.jsx | 56 --- client/src/Pages/Settings/index.tsx | 71 +++- client/src/Pages/Settings/old.jsx | 217 ---------- client/src/Routes/index.tsx | 6 +- client/src/Types/env.d.ts | 2 + client/src/locales/en.json | 11 +- client/vite.config.ts | 2 +- 16 files changed, 83 insertions(+), 1278 deletions(-) delete mode 100644 client/src/Pages/Settings/SettingsAbout.jsx delete mode 100644 client/src/Pages/Settings/SettingsDemoMonitors.jsx delete mode 100644 client/src/Pages/Settings/SettingsEmail.jsx delete mode 100644 client/src/Pages/Settings/SettingsExport.jsx delete mode 100644 client/src/Pages/Settings/SettingsGlobalThresholds.jsx delete mode 100644 client/src/Pages/Settings/SettingsPagespeed.jsx delete mode 100644 client/src/Pages/Settings/SettingsStats.jsx delete mode 100644 client/src/Pages/Settings/SettingsTimeZone.jsx delete mode 100644 client/src/Pages/Settings/SettingsUI.jsx delete mode 100644 client/src/Pages/Settings/SettingsURL.jsx delete mode 100644 client/src/Pages/Settings/old.jsx diff --git a/client/src/Pages/Settings/SettingsAbout.jsx b/client/src/Pages/Settings/SettingsAbout.jsx deleted file mode 100644 index 7b0a4ad2c..000000000 --- a/client/src/Pages/Settings/SettingsAbout.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import Link from "@/Components/v1/Link/index.jsx"; - -const SettingsAbout = () => { - const theme = useTheme(); - const { t } = useTranslation(); - return ( - - - - {t("pages.settings.aboutSettings.title")} - - - - - {t("common.appName")} {__APP_VERSION__} - - - {t("pages.settings.aboutSettings.labelDevelopedBy")} - - - - - ); -}; - -export default SettingsAbout; diff --git a/client/src/Pages/Settings/SettingsDemoMonitors.jsx b/client/src/Pages/Settings/SettingsDemoMonitors.jsx deleted file mode 100644 index d2e7baea0..000000000 --- a/client/src/Pages/Settings/SettingsDemoMonitors.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import { useState } from "react"; -import { useDelete, usePost } from "@/Hooks/UseApi"; - -const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, isLoading }) => { - const { t } = useTranslation(); - const theme = useTheme(); - // Local state - const [isOpen, setIsOpen] = useState(false); - - if (!isAdmin) { - return null; - } - const { post: postDemoMonitors } = usePost(); - const { deleteFn: deleteAllMonitorsFn } = useDelete(); - return ( - <> - - - - {t("pages.settings.demoMonitorsSettings.title")} - - - {t("pages.settings.demoMonitorsSettings.description")} - - - - - - - - - - {t("pages.settings.systemResetSettings.title")} - - - {t("pages.settings.systemResetSettings.description")} - - - - - - setIsOpen(false)} - confirmationButtonLabel={t("pages.settings.systemResetSettings.dialogConfirm")} - onConfirm={async () => { - await deleteAllMonitorsFn("/monitors/"); - setIsOpen(false); - }} - isLoading={isLoading} - /> - - - ); -}; - -SettingsDemoMonitors.propTypes = { - isAdmin: PropTypes.bool, - HEADER_SX: PropTypes.object, -}; - -export default SettingsDemoMonitors; diff --git a/client/src/Pages/Settings/SettingsEmail.jsx b/client/src/Pages/Settings/SettingsEmail.jsx deleted file mode 100644 index 180b7f2d9..000000000 --- a/client/src/Pages/Settings/SettingsEmail.jsx +++ /dev/null @@ -1,369 +0,0 @@ -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Button from "@mui/material/Button"; -import Stack from "@mui/material/Stack"; -import { Switch } from "@mui/material"; -import TextLink from "@/Components/v1/TextLink/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -import { usePost } from "@/Hooks/UseApi"; -import { useSelector } from "react-redux"; -import { createToast } from "@/Utils/toastUtils.jsx"; -import { Controller } from "react-hook-form"; - -const SettingsEmail = ({ - isAdmin, - HEADER_SX, - isEmailPasswordSet, - emailPasswordHasBeenReset, - setEmailPasswordHasBeenReset, - control, - defaults, - formValues, - setValue, -}) => { - // Setup - const { t } = useTranslation(); - const theme = useTheme(); - - // Destructure settings with default values - const { - systemEmailHost = "", - systemEmailPort = "", - systemEmailSecure = false, - systemEmailPool = false, - systemEmailUser = "", - systemEmailAddress = "", - systemEmailPassword = "", - systemEmailTLSServername = "", - systemEmailConnectionHost = "localhost", - systemEmailIgnoreTLS = false, - systemEmailRequireTLS = false, - systemEmailRejectUnauthorized = true, - } = formValues || {}; - - // Network - const { post: sendTestEmailFn, loading: isSending } = usePost(); - const user = useSelector((state) => state.auth.user); - - // Handlers - - /** - * Handle sending test email with current form values - */ - const handleSendTestEmail = () => { - // Basic validation - if ( - !systemEmailHost || - !systemEmailPort || - !systemEmailAddress || - !systemEmailPassword - ) { - createToast({ - body: t("pages.settings.emailSettings.toastEmailRequiredFieldsError"), - variant: "error", - }); - return; - } - - // Send test email - only include optional fields if they have values - sendTestEmailFn("/settings/test-email", { - to: user.email, - systemEmailHost, - systemEmailPort, - systemEmailAddress, - systemEmailPassword, - systemEmailSecure, - systemEmailPool, - systemEmailIgnoreTLS, - systemEmailRequireTLS, - systemEmailRejectUnauthorized, - ...(systemEmailUser && { systemEmailUser }), - ...(systemEmailTLSServername && { systemEmailTLSServername }), - ...(systemEmailConnectionHost && { systemEmailConnectionHost }), - }); - }; - - if (!isAdmin) { - return null; - } - - return ( - - - - {t("pages.settings.emailSettings.title")} - - - {t("pages.settings.emailSettings.description")} - - - - - - ( - - )} - /> - - - ( - - )} - /> - - - ( - - )} - /> - - - ( - - )} - /> - - {(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && ( - - ( - } - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - - )} - - {isEmailPasswordSet === true && emailPasswordHasBeenReset === false && ( - - {t("pages.settings.emailSettings.labelPasswordSet")} - - - )} - - ( - - )} - /> - - - ( - - )} - /> - - - {[ - [ - "pages.settings.emailSettings.labelSecure", - "systemEmailSecure", - systemEmailSecure, - ], - [ - "pages.settings.emailSettings.labelPool", - "systemEmailPool", - systemEmailPool, - ], - [ - "pages.settings.emailSettings.labelIgnoreTLS", - "systemEmailIgnoreTLS", - systemEmailIgnoreTLS, - ], - [ - "pages.settings.emailSettings.labelRequireTLS", - "systemEmailRequireTLS", - systemEmailRequireTLS, - ], - [ - "pages.settings.emailSettings.labelRejectUnauthorized", - "systemEmailRejectUnauthorized", - systemEmailRejectUnauthorized, - ], - ].map(([labelKey, name, value]) => ( - ( - - {t(labelKey)} - - - - )} - /> - ))} - - - - - {JSON.stringify( - { - host: systemEmailHost, - port: systemEmailPort, - secure: systemEmailSecure, - auth: { - user: systemEmailUser || systemEmailAddress, - pass: "", - }, - name: systemEmailConnectionHost || "localhost", - pool: systemEmailPool, - tls: { - rejectUnauthorized: systemEmailRejectUnauthorized, - ignoreTLS: systemEmailIgnoreTLS, - requireTLS: systemEmailRequireTLS, - servername: systemEmailTLSServername, - }, - }, - null, - 2 - )} - - - - - - {systemEmailHost && - systemEmailPort && - systemEmailAddress && - systemEmailPassword && ( - - )} - - - - ); -}; - -SettingsEmail.propTypes = { - isAdmin: PropTypes.bool, - HEADER_SX: PropTypes.object, - isPasswordSet: PropTypes.bool, -}; - -export default SettingsEmail; diff --git a/client/src/Pages/Settings/SettingsExport.jsx b/client/src/Pages/Settings/SettingsExport.jsx deleted file mode 100644 index b289592d1..000000000 --- a/client/src/Pages/Settings/SettingsExport.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; -import { useState } from "react"; -import { useLazyGet } from "@/Hooks/UseApi"; - -const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, isLoading }) => { - const { t } = useTranslation(); - const theme = useTheme(); - // Local state - const [isOpen, setIsOpen] = useState(false); - - if (!isAdmin) { - return null; - } - const { get: fetchJson } = useLazyGet(); - - const handleExport = async () => { - const res = await fetchJson("/monitors/export/json"); - const json = res?.data ?? []; - if (!json || json.length === 0) { - return; - } - - const blob = new Blob([JSON.stringify(json, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - - const link = document.createElement("a"); - link.href = url; - link.download = "monitors.json"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - return; - }; - - return ( - <> - - - - Export monitors to JSON - - - Export your monitors data as a JSON file for backup or transfer. - - - - - - - - ); -}; - -SettingsDemoMonitors.propTypes = { - isAdmin: PropTypes.bool, - HEADER_SX: PropTypes.object, -}; - -export default SettingsDemoMonitors; diff --git a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx deleted file mode 100644 index c2bc671f1..000000000 --- a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; - -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; -import { Controller } from "react-hook-form"; - -const SettingsGlobalThresholds = ({ - isAdmin, - HEADING_SX, - - control, - defaults, -}) => { - const { t } = useTranslation(); // For language translation - const theme = useTheme(); // MUI theme access - - // Only render this section for admins - if (!isAdmin) return null; - - return ( - - {/* Header and description */} - - - {t("pages.settings.globalThresholds.title", "Global Thresholds")} - - - {t( - "pages.settings.globalThresholds.description", - "Configure global CPU, Memory, Disk, and Temperature thresholds." - )} - - - - {/* Threshold inputs */} - - {[ - ["CPU Threshold (%)", "cpu", 1, 100], - ["Memory Threshold (%)", "memory", 1, 100], - ["Disk Threshold (%)", "disk", 1, 100], - ["Temperature Threshold (°C)", "temperature", 1, 150], - ].map(([label, name, min, max]) => ( - ( - - )} - /> - ))} - - - ); -}; - -// Prop types -SettingsGlobalThresholds.propTypes = { - isAdmin: PropTypes.bool, - HEADING_SX: PropTypes.object, -}; - -export default SettingsGlobalThresholds; diff --git a/client/src/Pages/Settings/SettingsPagespeed.jsx b/client/src/Pages/Settings/SettingsPagespeed.jsx deleted file mode 100644 index a479ff660..000000000 --- a/client/src/Pages/Settings/SettingsPagespeed.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx"; -// Utils -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Controller } from "react-hook-form"; - -const SettingsPagespeed = ({ - isAdmin, - HEADING_SX, - isApiKeySet, - apiKeyHasBeenReset, - setApiKeyHasBeenReset, - defaults, - control, - setValue, -}) => { - const { t } = useTranslation(); - const theme = useTheme(); - - // Local state - const [apiKey, setApiKey] = useState(""); - - if (!isAdmin) { - return null; - } - - return ( - - - - {t("pages.settings.pageSpeedSettings.title")} - - - {t("pages.settings.pageSpeedSettings.description")} - - - - {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( - ( - } - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - )} - - {isApiKeySet === true && apiKeyHasBeenReset === false && ( - - - {t("pages.settings.pageSpeedSettings.labelApiKeySet")} - - - - )} - - - ); -}; - -SettingsPagespeed.propTypes = { - isAdmin: PropTypes.bool, - HEADING_SX: PropTypes.object, - isApiKeySet: PropTypes.bool, - setIsApiKeySet: PropTypes.func, - apiKeyHasBeenReset: PropTypes.bool, - setApiKeyHasBeenReset: PropTypes.func, -}; - -export default SettingsPagespeed; diff --git a/client/src/Pages/Settings/SettingsStats.jsx b/client/src/Pages/Settings/SettingsStats.jsx deleted file mode 100644 index 45735ee4d..000000000 --- a/client/src/Pages/Settings/SettingsStats.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import Stack from "@mui/material/Stack"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; -import Dialog from "@/Components/v1/Dialog/index.jsx"; - -// Utils -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; -import { useState } from "react"; -import { useDelete } from "@/Hooks/UseApi"; - -const SettingsStats = ({ isAdmin, HEADING_SX, errors }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - - if (!isAdmin) { - return null; - } - const { deleteFn: deleteMonitorStatsFn } = useDelete(); - - return ( - - - - {t("pages.settings.statsSettings.title")} - - - {t("pages.settings.statsSettings.description")} - - - - - - {t("pages.settings.statsSettings.clearAllStatsDescription")} - - - - - setIsOpen(false)} - confirmationButtonLabel={t( - "pages.settings.statsSettings.clearAllStatsDialogConfirm" - )} - onConfirm={async () => { - await deleteMonitorStatsFn("/checks/team"); - - setIsOpen(false); - }} - isLoading={false} - /> - - ); -}; - -SettingsStats.propTypes = { - isAdmin: PropTypes.bool, - HEADING_SX: PropTypes.object, -}; - -export default SettingsStats; diff --git a/client/src/Pages/Settings/SettingsTimeZone.jsx b/client/src/Pages/Settings/SettingsTimeZone.jsx deleted file mode 100644 index 444088c8a..000000000 --- a/client/src/Pages/Settings/SettingsTimeZone.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import Search from "@/Components/v1/Inputs/Search/index.jsx"; -import timezones from "@/Utils/timezones.json"; - -// Utils -import { useTheme } from "@emotion/react"; -import { useSelector, useDispatch } from "react-redux"; -import { setTimezone } from "@/Features/UI/uiSlice.js"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; -import { useCallback, useMemo, useState } from "react"; -const SettingsTimeZone = ({ HEADING_SX }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const [rawInput, setRawInput] = useState(""); - const dispatch = useDispatch(); - const { timezone } = useSelector((state) => state.ui); - - const selectedTimezone = useMemo( - () => timezones.find((tz) => tz._id === timezone) ?? null, - [timezone] - ); - - const handleTimezoneChange = (newValue) => { - setRawInput(""); - const newId = newValue?._id ?? ""; - dispatch(setTimezone({ timezone: newId })); - }; - - return ( - - - - {t("pages.settings.timezoneSettings.title")} - - - - {t("pages.settings.timezoneSettings.description")} - - - - - setRawInput(val)} - handleChange={handleTimezoneChange} - isAdorned={true} - unit="timezone" - /> - - - ); -}; - -SettingsTimeZone.propTypes = { - HEADING_SX: PropTypes.object, -}; - -export default SettingsTimeZone; diff --git a/client/src/Pages/Settings/SettingsUI.jsx b/client/src/Pages/Settings/SettingsUI.jsx deleted file mode 100644 index aa58a67d9..000000000 --- a/client/src/Pages/Settings/SettingsUI.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import DummyChart from "./DummyChart"; -import { ThemeProvider } from "@mui/material/styles"; -import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; - -// Utils -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; -import { useSelector, useDispatch } from "react-redux"; -import { setMode, setLanguage, setChartType } from "@/Features/UI/uiSlice.js"; - -const SettingsUI = ({ HEADING_SX }) => { - const { - mode, - language = "en", - chartType = "histogram", - } = useSelector((state) => state.ui); - const { t, i18n } = useTranslation(); - const theme = useTheme(); - const languages = Object.keys(i18n.options.resources || {}); - const dispatch = useDispatch(); - const v2Theme = mode === "dark" ? darkTheme : lightTheme; - return ( - - - - {t("pages.settings.uiSettings.title")} - - - {t("pages.settings.uiSettings.description")} - - - - - - - - - - - - ); -}; - -SettingsUI.propTypes = { - HEADING_SX: PropTypes.object, -}; - -export default SettingsUI; diff --git a/client/src/Pages/Settings/SettingsURL.jsx b/client/src/Pages/Settings/SettingsURL.jsx deleted file mode 100644 index 11e7ea821..000000000 --- a/client/src/Pages/Settings/SettingsURL.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import { Controller } from "react-hook-form"; - -// Utils -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; - -const SettingsURL = ({ HEADING_SX, control, defaults }) => { - const { t } = useTranslation(); - const theme = useTheme(); - return ( - - - - {t("pages.settings.urlSettings.title")} - - - {t("pages.settings.urlSettings.description")} - - - - ( - - */ - -const Select = ({ - id, - label, - placeholder, - isHidden, - value, - items, - onChange, - onBlur, - sx, - error = false, - name = "", - labelControlSpacing = 6, - maxWidth, - //FieldWrapper's props - labelMb, - labelFontWeight, - labelVariant, - labelSx = {}, - fieldWrapperSx = {}, -}) => { - const theme = useTheme(); - const getItemValue = (item) => item?._id ?? item?.id; - const itemStyles = { - color: theme.palette.primary.contrastTextTertiary, - borderRadius: theme.shape.borderRadius, - margin: theme.spacing(2), - }; - - const responsiveMaxWidth = { - xs: `${maxWidth * 0.5}px`, - sm: `${maxWidth * 0.75}px`, - md: `${maxWidth * 0.9}px`, - lg: `${maxWidth}px`, - }; - - return ( - - ( - - )} - MenuProps={{ disableScrollLock: true }} - sx={{ - fontSize: 13, - minWidth: "125px", - ...(maxWidth && { maxWidth: responsiveMaxWidth }), - "& fieldset": { - borderRadius: theme.shape.borderRadius, - borderColor: theme.palette.primary.lowContrast, - }, - "&:not(.Mui-focused):hover fieldset": { - borderColor: theme.palette.primary.lowContrast, - }, - "& svg path": { - fill: theme.palette.primary.contrastTextTertiary, - }, - "& .MuiSelect-select": { - padding: "0", - minHeight: "34px", - display: "flex", - alignItems: "center", - lineHeight: 1, - }, - ...sx, - }} - renderValue={(selected) => { - const selectedItem = items.find((item) => getItemValue(item) === selected); - const displayName = selectedItem ? selectedItem.name : placeholder; - return ( - - {displayName} - - ); - }} - > - {placeholder && ( - - {placeholder} - - )} - {items - .map((item) => { - const itemValue = getItemValue(item); - if (itemValue === undefined || itemValue === null) { - return null; - } - return ( - - {item.name} - - ); - }) - .filter(Boolean)} - - - ); -}; - -Select.propTypes = { - id: PropTypes.string, - name: PropTypes.string, - label: PropTypes.string, - placeholder: PropTypes.string, - isHidden: PropTypes.bool, - error: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]) - .isRequired, - items: PropTypes.arrayOf( - PropTypes.shape({ - _id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), - - name: PropTypes.string.isRequired, - }) - ).isRequired, - onChange: PropTypes.func.isRequired, - onBlur: PropTypes.func, - sx: PropTypes.object, - labelControlSpacing: PropTypes.number, - /** - * Maximum width in pixels. Used to control text truncation and element width. - * Responsive breakpoints will be calculated as percentages of this value. - */ - maxWidth: PropTypes.number, -}; - -export default Select; diff --git a/client/src/Components/v1/Inputs/TextInput/Adornments/index.jsx b/client/src/Components/v1/Inputs/TextInput/Adornments/index.jsx deleted file mode 100644 index 2b3abab0f..000000000 --- a/client/src/Components/v1/Inputs/TextInput/Adornments/index.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Stack, Typography, InputAdornment, IconButton } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import PropTypes from "prop-types"; -import Icon from "../../../Icon"; - -export const HttpAdornment = ({ https }) => { - const theme = useTheme(); - return ( - - - {https ? "https" : "http"} - - - ); -}; - -HttpAdornment.propTypes = { - https: PropTypes.bool.isRequired, - prefix: PropTypes.string, -}; - -export const PasswordEndAdornment = ({ fieldType, setFieldType }) => { - const theme = useTheme(); - return ( - - setFieldType(fieldType === "password" ? "text" : "password")} - sx={{ - color: theme.palette.primary.lowContrast, - padding: theme.spacing(1), - "&:focus-visible": { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: `2px`, - }, - "& .MuiTouchRipple-root": { - pointerEvents: "none", - display: "none", - }, - }} - > - {fieldType === "password" ? ( - - ) : ( - - )} - - - ); -}; - -PasswordEndAdornment.propTypes = { - fieldType: PropTypes.string, - setFieldType: PropTypes.func, -}; diff --git a/client/src/Components/v1/Inputs/TextInput/index.jsx b/client/src/Components/v1/Inputs/TextInput/index.jsx deleted file mode 100644 index 7203e5f50..000000000 --- a/client/src/Components/v1/Inputs/TextInput/index.jsx +++ /dev/null @@ -1,181 +0,0 @@ -import { Stack, TextField, Typography } from "@mui/material"; -import { useTheme } from "@emotion/react"; -import { forwardRef, useState, cloneElement } from "react"; -import PropTypes from "prop-types"; -import FieldWrapper from "../FieldWrapper/index.jsx"; - -const getSx = (theme, type, maxWidth) => { - const sx = { - maxWidth: maxWidth, - - "& .MuiFormHelperText-root": { - position: "absolute", - bottom: `-${theme.spacing(24)}`, - minHeight: theme.spacing(24), - }, - }; - - if (type === "url") { - return { - ...sx, - "& .MuiInputBase-root": { padding: 0 }, - "& .MuiStack-root": { - borderTopLeftRadius: theme.shape.borderRadius, - borderBottomLeftRadius: theme.shape.borderRadius, - }, - }; - } - return sx; -}; - -const Required = () => { - const theme = useTheme(); - return ( - - * - - ); -}; - -const Optional = ({ optionalLabel }) => { - const theme = useTheme(); - return ( - - {optionalLabel || "(optional)"} - - ); -}; - -Optional.propTypes = { - optionalLabel: PropTypes.string, -}; - -const TextInput = forwardRef( - ( - { - id, - name, - type, - value, - placeholder, - isRequired, - isOptional, - optionalLabel, - onChange, - onBlur, - error = false, - helperText = null, - startAdornment = null, - endAdornment = null, - label = null, - maxWidth = "100%", - flex, - marginTop, - marginRight, - marginBottom, - marginLeft, - disabled = false, - hidden = false, - //FieldWrapper's props - gap, - labelMb, - labelFontWeight, - labelVariant, - labelSx = {}, - sx = {}, - }, - ref - ) => { - const [fieldType, setFieldType] = useState(type); - const theme = useTheme(); - const labelContent = label && ( - <> - {label} - {isRequired && } - {isOptional && } - - ); - return ( - - ); - } -); - -TextInput.displayName = "TextInput"; - -TextInput.propTypes = { - type: PropTypes.string, - id: PropTypes.string, - name: PropTypes.string, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - placeholder: PropTypes.string, - isRequired: PropTypes.bool, - isOptional: PropTypes.bool, - optionalLabel: PropTypes.string, - onChange: PropTypes.func, - onBlur: PropTypes.func, - error: PropTypes.bool, - helperText: PropTypes.string, - startAdornment: PropTypes.node, - endAdornment: PropTypes.node, - label: PropTypes.string, - maxWidth: PropTypes.string, - flex: PropTypes.number, - marginTop: PropTypes.string, - marginRight: PropTypes.string, - marginBottom: PropTypes.string, - marginLeft: PropTypes.string, - disabled: PropTypes.bool, - hidden: PropTypes.bool, -}; - -export default TextInput; diff --git a/client/src/Components/v1/Link/index.jsx b/client/src/Components/v1/Link/index.jsx deleted file mode 100644 index 987c56f37..000000000 --- a/client/src/Components/v1/Link/index.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Link as MuiLink, useTheme } from "@mui/material"; -import { Link as RouterLink } from "react-router-dom"; - -import PropTypes from "prop-types"; - -/** - * @component - * @param {Object} props - * @param {'primary' | 'secondary' | 'tertiary' | 'error'} props.level - The level of the link - * @param {string} props.label - The label of the link - * @param {string} props.url - The URL of the link - * @returns {JSX.Element} - */ - -const Link = ({ level, label, url, external = true }) => { - const theme = useTheme(); - - const levelConfig = { - primary: { - color: theme.palette.primary.contrastTextTertiary, - sx: { - ":hover": { - color: theme.palette.primary.contrastTextSecondary, - }, - }, - }, - secondary: { - color: theme.palette.primary.contrastTextSecondary, - sx: { - ":hover": { - color: theme.palette.primary.contrastTextSecondary, - }, - }, - }, - tertiary: { - color: theme.palette.primary.contrastTextTertiary, - sx: { - textDecoration: "underline", - textDecorationStyle: "dashed", - textDecorationColor: theme.palette.primary.main, - textUnderlineOffset: "1px", - ":hover": { - color: theme.palette.primary.contrastTextTertiary, - textDecorationColor: theme.palette.primary.main, - backgroundColor: theme.palette.primary.lowContrast, - }, - }, - }, - error: {}, - }; - const { sx, color } = levelConfig[level]; - return ( - - {label} - - ); -}; - -Link.propTypes = { - url: PropTypes.string.isRequired, - level: PropTypes.oneOf(["primary", "secondary", "tertiary", "error"]), - label: PropTypes.string.isRequired, - external: PropTypes.bool, -}; - -export default Link; diff --git a/client/src/Components/v1/Link/link.css b/client/src/Components/v1/Link/link.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Components/v1/TextLink/index.jsx b/client/src/Components/v1/TextLink/index.jsx deleted file mode 100644 index e226250a6..000000000 --- a/client/src/Components/v1/TextLink/index.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import Typography from "@mui/material/Typography"; -import Stack from "@mui/material/Stack"; -import Link from "@mui/material/Link"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import { Link as RouterLink } from "react-router-dom"; - -const TextLink = ({ text, linkText, href, state, target = "_self" }) => { - const theme = useTheme(); - - return ( - - {text} - - {linkText} - - - ); -}; - -TextLink.propTypes = { - text: PropTypes.string, - linkText: PropTypes.string, - href: PropTypes.string, - state: PropTypes.object, - target: PropTypes.string, -}; - -export default TextLink; diff --git a/client/src/Components/v1/Toast/body.jsx b/client/src/Components/v1/Toast/body.jsx deleted file mode 100644 index 04d5aee99..000000000 --- a/client/src/Components/v1/Toast/body.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; - -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; - -const ToastBody = ({ body }) => { - const theme = useTheme(); - - if (Array.isArray(body)) { - return ( - - {body.map((item, idx) => ( - - {item} - - ))} - - ); - } else if (typeof body === "string") { - return {body}; - } - - return null; -}; - -ToastBody.propTypes = { - body: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), -}; - -export default ToastBody; diff --git a/client/src/Components/v1/Toast/index.jsx b/client/src/Components/v1/Toast/index.jsx deleted file mode 100644 index d409cec81..000000000 --- a/client/src/Components/v1/Toast/index.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import Stack from "@mui/material/Stack"; -import IconButton from "@mui/material/IconButton"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import ToastBody from "./body.jsx"; -import Icon from "../Icon"; - -// Utils -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; - -const icons = { - info: ( - - ), - error: ( - - ), - warning: ( - - ), -}; - -const Toast = ({ variant, title, body, onClick, hasDismiss, hasIcon }) => { - const theme = useTheme(); - const icon = icons[variant]; - - return ( - - - {hasIcon && icon} - {title && ( - - {title} - - )} - {title && ( - - - - )} - - - - - {!title && ( - - - - )} - - {hasDismiss && ( - - )} - - ); -}; - -export default Toast; - -Toast.propTypes = { - variant: PropTypes.string.isRequired, - title: PropTypes.string, - body: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - hasDismiss: PropTypes.bool, - hasIcon: PropTypes.bool, - onClick: PropTypes.func, -}; diff --git a/client/src/Components/v2/design-elements/Fallback.tsx b/client/src/Components/v2/design-elements/Fallback.tsx index 16eece25a..593829ab5 100644 --- a/client/src/Components/v2/design-elements/Fallback.tsx +++ b/client/src/Components/v2/design-elements/Fallback.tsx @@ -85,12 +85,7 @@ export const EmptyFallback = ({ zIndex={1} alignItems="center" > - - {title} - + {title} { const theme = useTheme(); @@ -19,7 +19,6 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { { backgroundSize: "100% 100%", backgroundPosition: "center", backgroundRepeat: "no-repeat", - color: theme.palette.primary.contrastText, }} > diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index b73fc5bc4..3be1b30a1 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,5 +1,5 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; -import type { PaletteKey } from "@/Utils/Theme/v2Theme"; +import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/v2/design-elements/StatusLabel"; export const getMonitorPath = (type: MonitorType): string => { diff --git a/client/src/Utils/Theme/v2Palette.ts b/client/src/Utils/Theme/Palette.ts similarity index 100% rename from client/src/Utils/Theme/v2Palette.ts rename to client/src/Utils/Theme/Palette.ts diff --git a/client/src/Utils/Theme/v2Theme.ts b/client/src/Utils/Theme/Theme.ts similarity index 99% rename from client/src/Utils/Theme/v2Theme.ts rename to client/src/Utils/Theme/Theme.ts index 39ede6c7e..49c1636ec 100644 --- a/client/src/Utils/Theme/v2Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -1,5 +1,5 @@ import { createTheme } from "@mui/material"; -import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/v2Palette"; +import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/Palette"; import type { Theme } from "@mui/material/styles"; diff --git a/client/src/Utils/Theme/constants.js b/client/src/Utils/Theme/constants.js deleted file mode 100644 index 2664c6126..000000000 --- a/client/src/Utils/Theme/constants.js +++ /dev/null @@ -1,291 +0,0 @@ -import { lighten } from "@mui/material/styles"; // CAIO_REVIEW - -const typographyBase = 13; - -/* TODO -Check for px in codebase. All font sizes should be in REM and should live here. -Rest should be checked on each case -*/ -const typographyLevels = { - base: typographyBase, - xs: `${(typographyBase - 4) / 16}rem`, - s: `${(typographyBase - 2) / 16}rem`, - m: `${typographyBase / 16}rem`, - l: `${(typographyBase + 2) / 16}rem`, - xl: `${(typographyBase + 10) / 16}rem`, -}; - -/* TODO Review color palette and semantic colors */ -const paletteColors = { - white: "#FFFFFF", - gray50: "#FEFEFE", - gray60: "#FEFDFE", - gray70: "#FDFDFD", - gray80: "#FDFCFD", - gray90: "#FCFCFD", - gray100: "#F4F4F4", - gray150: "#EFEFEF", - gray200: "#E3E3E3", - gray300: "#A2A3A3", - gray500: "#838C99", - gray600: "#454546", - gray750: "#36363E", - gray800: "#2D2D33", - gray850: "#131315", - gray860: "#111113", - gray870: "#0F0F11", - gray880: "#0C0C0E", - gray890: "#09090B", - blueGray20: "#E8F0FE", - blueGray150: "#667085", - blueGray200: "#475467", - blueGray400: "#344054", - blueBlueWave: "#1570EF", - blue700: "#4E5BA6", - purple300: "#664EFF", - purple400: "#3A1BFF", - green50: "#D4F4E1", - green150: "#45BB7A", - green400: "#079455", - green500: "#07B467", - green800: "#1C4428", - green900: "#12261E", - red50: "#F9ECED", - red100: "#FBD1D1", - red200: "#F04438", - red300: "#D32F2F", - red700: "#542426", - red800: "#912018", - orange50: "#FEF8EA", - orange100: "#FFECBC", - orange300: "#FDB022", - orange400: "#FF9F00", - orange500: "#E88C30", - orange600: "#DC6803", - orange800: "#624711", -}; - -const semanticColors = { - unresolved: { - main: { - light: paletteColors.blue700, - dark: paletteColors.purple300, - }, - light: { - light: paletteColors.blueGray20, - dark: paletteColors.purple400, - }, - bg: { - light: paletteColors.gray100, - dark: paletteColors.gray100, - }, - }, -}; - -const newColors = { - offWhite: "#FEFEFE", - offBlack: "#131315", - gray0: "#FDFDFD", - gray10: "#F4F4FF", - gray50: "#F9F9F9", - gray100: "#F3F3F3", - gray200: "#EFEFEF", - gray250: "#DADADA", - gray500: "#A2A3A3", - gray900: "#1c1c1c", - blueGray50: "#E8F0FE", - blueGray500: "#475467", - blueGray600: "#344054", - blueGray800: "#1C2130", - blueGray900: "#515151", - blueBlueWave: "#1570EF", - lightBlueWave: "#CDE2FF", - green100: "#67cd78", - green200: "#4B9B77", - green400: "#079455", - green700: "#026513", - orange100: "#FD8F22", - orange200: "#D69A5D", - orange600: "#9B734B", - orange700: "#884605", - red100: "#F27C7C", - red400: "#D92020", - red600: "#9B4B4B", - red700: "#980303", -}; - -/* -Structure: -main: background color -contrastText: color for main contrast text -contrastTextSecondary: if needed -contrastTextTertiary: if needed -lowContrast: if we need some low contrast for that color (for borders, and decorative elements). This should never be usend in text - -*/ -const newSemanticColors = { - accent: { - main: { - light: newColors.blueBlueWave, - dark: newColors.blueBlueWave, - }, - light: { - light: lighten(newColors.blueBlueWave, 0.2), //CAIO_REVIEW - dark: lighten(newColors.blueBlueWave, 0.2), //CAIO_REVIEW - }, - contrastText: { - light: newColors.offWhite, - dark: newColors.offWhite, - }, - }, - primary: { - main: { - light: newColors.offWhite, - dark: newColors.offBlack, - }, - contrastText: { - light: newColors.blueGray800, - dark: newColors.blueGray50, - }, - contrastTextSecondary: { - light: newColors.blueGray600, - dark: newColors.gray200, - }, - contrastTextSecondaryDarkBg: { - light: newColors.gray200, - dark: newColors.gray200, - }, - contrastTextTertiary: { - light: newColors.blueGray500, - dark: newColors.gray500, - }, - lowContrast: { - light: newColors.gray250, - dark: newColors.blueGray600, - }, - }, - primaryBackground: { - main: { - light: newColors.gray0, - dark: "#000000", - }, - }, - secondary: { - main: { - light: newColors.gray200, - dark: "#313131" /* newColors.blueGray600 */, - }, - light: { - light: newColors.lightBlueWave, - dark: newColors.lightBlueWave, - }, - contrastText: { - light: newColors.blueGray600, - dark: newColors.gray200, - }, - }, - tertiary: { - main: { - light: newColors.gray100, - dark: newColors.blueGray800, - }, - contrastText: { - light: newColors.blueGray800, - dark: newColors.gray100, - }, - }, - success: { - main: { - light: newColors.green700, - dark: newColors.green100, - }, - contrastText: { - light: newColors.offWhite, - dark: newColors.offBlack, - }, - lowContrast: { - light: newColors.green400, - dark: newColors.green200, - }, - }, - warning: { - main: { - light: newColors.orange700, - dark: newColors.orange200, - }, - contrastText: { - light: newColors.offWhite, - dark: newColors.offBlack, - }, - lowContrast: { - light: newColors.orange100, - dark: newColors.orange600, - }, - }, - /* Custom palette for PageSpeed warning box */ - warningSecondary: { - main: { - light: paletteColors.orange50, - dark: paletteColors.orange800, - }, - contrastText: { - light: paletteColors.orange600, - dark: paletteColors.orange100, - }, - lowContrast: { - light: paletteColors.orange300, - dark: paletteColors.orange400, - }, - }, - error: { - main: { - light: newColors.red700, - dark: newColors.red100, - }, - contrastText: { - light: newColors.offWhite, - dark: newColors.offBlack, - }, - lowContrast: { - light: newColors.red400, - dark: newColors.red600, - }, - }, - - /* These are temporary, just for everything not to break */ - gradient: { - color1: { - light: paletteColors.gray90, - dark: paletteColors.gray890, - }, - color2: { - light: paletteColors.gray80, - dark: paletteColors.gray880, - }, - color3: { - light: paletteColors.gray70, - dark: paletteColors.gray870, - }, - color4: { - light: paletteColors.gray60, - dark: paletteColors.gray860, - }, - color5: { - light: paletteColors.gray50, - dark: paletteColors.gray850, - }, - }, - - alert: { - main: { - light: newColors.gray200, - dark: newColors.gray900, - }, - contrastText: { - light: newColors.blueGray600, - dark: newColors.blueGray900, - }, - }, -}; - -export { typographyLevels, semanticColors as colors, newSemanticColors }; diff --git a/client/src/Utils/Theme/darkTheme.js b/client/src/Utils/Theme/darkTheme.js deleted file mode 100644 index 1ec5a2c3a..000000000 --- a/client/src/Utils/Theme/darkTheme.js +++ /dev/null @@ -1,13 +0,0 @@ -import { createTheme } from "@mui/material"; -import { baseTheme } from "./globalTheme"; -import { /* colors, */ newSemanticColors } from "./constants"; -import { extractThemeColors } from "./extractColorObject"; - -const palette = extractThemeColors("dark", newSemanticColors); - -const darkTheme = createTheme({ - palette, - ...baseTheme(palette), -}); - -export default darkTheme; diff --git a/client/src/Utils/Theme/extractColorObject.js b/client/src/Utils/Theme/extractColorObject.js deleted file mode 100644 index bfd1a5aff..000000000 --- a/client/src/Utils/Theme/extractColorObject.js +++ /dev/null @@ -1,25 +0,0 @@ -function extractThemeColors(themeType, colorObject) { - if (!["light", "dark"].includes(themeType)) { - throw new Error('Invalid theme type. Use "light" or "dark".'); - } - - const extract = (obj) => { - if (typeof obj !== "object" || obj === null) { - return obj; - } - - if ("light" in obj && "dark" in obj) { - // CAIO_REVIEW: This will break if the root object has light and dark properties - return obj[themeType]; // Return the value for the specified theme - } - - // Reduce the object, keeping only the extracted themeType values - return Object.keys(obj).reduce((acc, key) => { - acc[key] = extract(obj[key]); - return acc; - }, {}); - }; - return extract(colorObject); -} - -export { extractThemeColors }; diff --git a/client/src/Utils/Theme/globalTheme.js b/client/src/Utils/Theme/globalTheme.js deleted file mode 100644 index 98f3479be..000000000 --- a/client/src/Utils/Theme/globalTheme.js +++ /dev/null @@ -1,793 +0,0 @@ -import { typographyLevels } from "./constants"; -const fontFamilyPrimary = '"Inter" , sans-serif'; -import { darken } from "@mui/material/styles"; -// const fontFamilySecondary = '"Avenir", sans-serif'; - -/* TODO take the color out from here */ -const shadow = - "0px 4px 24px -4px rgba(16, 24, 40, 0.08), 0px 3px 3px -3px rgba(16, 24, 40, 0.03)"; - -const baseTheme = (palette) => ({ - typography: { - fontFamily: fontFamilyPrimary, - fontSize: typographyLevels.base, - h1: { - fontSize: typographyLevels.xl, - color: palette.primary.contrastText, - fontWeight: 500, - }, - h2: { - fontSize: typographyLevels.l, - color: palette.primary.contrastTextSecondary, - fontWeight: 400, - }, - // CAIO_REVIEW, need a brighter color for dark bg - h2DarkBg: { - fontSize: typographyLevels.l, - color: palette.primary.contrastTextSecondaryDarkBg, - fontWeight: 400, - }, - body1: { - fontSize: typographyLevels.m, - color: palette.primary.contrastTextTertiary, - fontWeight: 400, - }, - body2: { - fontSize: typographyLevels.s, - color: palette.primary.contrastTextTertiary, - fontWeight: 400, - }, - label: { - color: palette.primary.contrastTextSecondary, - fontWeight: 500, - }, - }, - /* TODO change to 4 */ - spacing: 2, - /* TODO we can skip using the callback functions on the next lines since we are already accessing it on line 10. That was the last thing I managed to do, so we are sort of doing it twice*/ - /* TODO All these should live inside of a component*/ - components: { - MuiButton: { - defaultProps: { - disableRipple: true, - }, - - styleOverrides: { - root: ({ theme }) => ({ - variants: [ - { - props: (props) => props.variant === "contained" && props.color === "accent", - backgroundColor: theme.palette.accent.main, - style: { - color: theme.palette.primary.contrastTextSecondaryDarkBg, - letterSpacing: "0.5px", - textShadow: "0 0 1px rgba(0, 0, 0, 0.15)", - "&:hover": { - backgroundColor: darken(theme.palette.accent.main, 0.25), - boxShadow: `0 2px 6px rgba(0, 0, 0, 0.1)`, - transition: "all 0.2s ease-in-out", - }, - }, - }, - { - props: (props) => props.color === "accent", - style: { - "&:hover": { - backgroundColor: darken(theme.palette.accent.main, 0.2), - }, - }, - }, - { - props: (props) => props.color === "error", - style: { - "&.Mui-disabled": { - backgroundColor: theme.palette.error.lowContrast, - }, - "& .MuiButton-loadingIndicator": { - // styles for error variant loading indicator - color: theme.palette.error.contrastText, - }, - }, - }, - { - props: (props) => props.variant === "group", - style: { - /* color: theme.palette.secondary.contrastText, */ - color: theme.palette.primary.contrastText, - backgroundColor: theme.palette.primary.main, - border: 1, - borderStyle: "solid", - borderColor: theme.palette.primary.lowContrast, - }, - }, - { - props: (props) => props.variant === "group" && props.filled === "true", - style: { - backgroundColor: theme.palette.secondary.main, - }, - }, - /* { - props: (props) => props.variant === "contained", - style: { - backgroundColor: `${theme.palette.accent.main} !important`, - }, - }, */ - - { - props: (props) => - props.variant === "contained" && props.color === "secondary", - style: { - border: 1, - borderStyle: "solid", - borderColor: theme.palette.primary.lowContrast, - }, - }, - { - props: (props) => { - return ( - props.variant === "contained" && - props.disabled && - props?.classes?.loadingIndicator === undefined // Do not apply to loading button - ); - }, - style: { - backgroundColor: `${theme.palette.secondary.main} !important`, - color: `${theme.palette.secondary.contrastText} !important`, - }, - }, - - { - props: { variant: "text", color: "info" }, - style: { - textDecoration: "underline", - color: theme.palette.primary.contrastText, - padding: 0, - margin: 0, - fontSize: typographyLevels.m, - fontWeight: theme.typography.body2.fontWeight, - backgroundColor: "transparent", - "&:hover": { - backgroundColor: "transparent", - textDecoration: "underline", - }, - "&.Mui-disabled": { - backgroundColor: "transparent", - color: theme.palette.text.primary, - opacity: 0.5, - "&.MuiButton-text": { - backgroundColor: "transparent", - }, - }, - minWidth: 0, - boxShadow: "none", - border: "none", - }, - }, - ], - height: 34, - fontWeight: 400, - borderRadius: 4, - boxShadow: "none", - textTransform: "none", - "&:focus": { - outline: "none", - }, - "&:hover": { - boxShadow: "none", - }, - "&.Mui-disabled": { - backgroundColor: theme.palette.secondary.main, - color: theme.palette.primary.contrastText, - }, - "&.MuiButton-root": { - "&:disabled": { - backgroundColor: theme.palette.secondary.main, - color: theme.palette.primary.contrastText, - }, - "&.MuiButton-colorAccent:hover": { - boxShadow: `0 0 0 1px ${theme.palette.accent.main}`, // CAIO_REVIEW, this should really have a solid BG color - }, - }, - "&.MuiButton-loading": { - "&:disabled": { - color: "transparent", - }, - - "& .MuiButton-loadingIndicator": { - color: theme.palette.primary.contrastText, - }, - }, - }), - }, - }, - - MuiIconButton: { - styleOverrides: { - root: ({ theme }) => ({ - padding: 4, - transition: "none", - "&:hover": { - backgroundColor: theme.palette.primary.lowContrast, - }, - }), - }, - }, - MuiPaper: { - styleOverrides: { - root: ({ theme }) => { - return { - marginTop: 4, - padding: 0, - border: 1, - borderStyle: "solid", - borderColor: theme.palette.primary.lowContrast, - borderRadius: 4, - boxShadow: shadow, - backgroundColor: theme.palette.primary.main, - backgroundImage: "none", - }; - }, - }, - }, - MuiList: { - styleOverrides: { - root: ({ theme }) => ({ - padding: 0, - }), - }, - }, - MuiListItemButton: { - styleOverrides: { - root: ({ theme }) => ({ - transition: "background-color .3s", - }), - }, - }, - MuiListItemText: { - styleOverrides: { - root: ({ theme }) => ({ - "& .MuiTypography-root": { - color: theme.palette.primary.contrastText, - }, - }), - }, - }, - MuiMenuItem: { - styleOverrides: { - root: ({ theme }) => ({ - borderRadius: 4, - backgroundColor: "inherit", - padding: "4px 6px", - color: theme.palette.primary.contrastTextSecondary, - fontSize: 13, - margin: 2, - minWidth: 100, - "&:hover, &.Mui-selected, &.Mui-selected:hover, &.Mui-selected.Mui-focusVisible": - { - backgroundColor: theme.palette.primary.lowContrast, - }, - }), - }, - }, - MuiTableCell: { - styleOverrides: { - root: ({ theme }) => ({ - fontSize: typographyLevels.base, - borderBottomColor: theme.palette.primary.lowContrast, - }), - }, - }, - - MuiTableHead: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.tertiary.main, - }), - }, - }, - MuiPagination: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.primary.main, - border: 1, - borderStyle: "solid", - borderColor: theme.palette.primary.lowContrast, - "& button": { - color: theme.palette.primary.contrastTextTertiary, - borderRadius: 4, - }, - "& li:first-of-type button, & li:last-of-type button": { - border: 1, - borderStyle: "solid", - borderColor: theme.palette.primary.lowContrast, - }, - }), - }, - }, - MuiPaginationItem: { - styleOverrides: { - root: ({ theme }) => ({ - "&:not(.MuiPaginationItem-ellipsis):hover, &.Mui-selected": { - backgroundColor: theme.palette.primary.lowContrast, - }, - }), - }, - }, - MuiSkeleton: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.primary.lowContrast, - }), - }, - }, - - MuiTextField: { - styleOverrides: { - root: ({ theme }) => ({ - "& fieldset": { - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - }, - - "& .MuiInputBase-input": { - padding: ".75em", - fontWeight: 400, - color: palette.primary.contrastTextSecondary, - "&.Mui-disabled": { - opacity: 0.3, - WebkitTextFillColor: "unset", - }, - "& .Mui-focused": { - /* color: "#ff0000", */ - /* borderColor: theme.palette.primary.contrastText, */ - }, - }, - - "& .MuiInputBase-input:-webkit-autofill": { - transition: "background-color 5000s ease-in-out 0s", - WebkitBoxShadow: `0 0 0px 1000px ${theme.palette.primary.main} inset`, - WebkitTextFillColor: theme.palette.primary.contrastText, - }, - - "& .MuiInputBase-input.MuiOutlinedInput-input": { - padding: `0 ${theme.spacing(5)}`, - }, - - "& .MuiOutlinedInput-root": { - color: theme.palette.primary.contrastTextSecondary, - borderRadius: 4, - }, - - "& .MuiOutlinedInput-notchedOutline": { - borderRadius: 4, - }, - - "& .MuiFormHelperText-root": { - color: palette.error.main, - opacity: 0.8, - fontSize: "var()", - marginLeft: 0, - }, - - "& .MuiFormHelperText-root.Mui-error": { - opacity: 0.8, - color: palette.error.main, - whiteSpace: "nowrap", - }, - }), - }, - }, - - MuiOutlinedInput: { - styleOverrides: { - root: { - "&.Mui-disabled .MuiOutlinedInput-notchedOutline": { - borderColor: palette.primary.contrastBorderDisabled, - }, - "&.Mui-disabled:hover .MuiOutlinedInput-notchedOutline": { - borderColor: palette.primary.contrastBorderDisabled, - }, - "&:hover .MuiOutlinedInput-notchedOutline": { - borderColor: palette.primary.lowContrast, // Adjust hover border color - }, - "&.Mui-focused .MuiOutlinedInput-notchedOutline": { - borderColor: palette.accent.main, // Adjust focus border color - }, - color: palette.primary.contrastText, - }, - }, - }, - - MuiAutocomplete: { - styleOverrides: { - root: ({ theme }) => ({ - "& .MuiOutlinedInput-root": { - paddingTop: 0, - paddingBottom: 0, - paddingRight: theme.spacing(5), - }, - "& fieldset": { - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - }, - "& .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset": - { - borderColor: theme.palette.primary.lowContrast, - }, - - "& .MuiAutocomplete-tag": { - color: theme.palette.primary.contrastText, - backgroundColor: theme.palette.primary.lowContrast, - }, - "& .MuiChip-deleteIcon": { - color: theme.palette.primary.contrastText, // CAIO_REVIEW - }, - }), - }, - }, - - MuiTab: { - styleOverrides: { - root: ({ theme }) => ({ - color: theme.palette.tertiary.contrastText, - height: "34px", - minHeight: "34px", - borderRadius: 0, - textTransform: "none", - minWidth: "fit-content", - padding: `${theme.spacing(6)}px ${theme.spacing(4)}px`, - fontWeight: 400, - "&:focus-visible": { - color: theme.palette.primary.contrastText, - borderColor: theme.palette.tertiary.contrastText, - borderRightColor: theme.palette.primary.lowContrast, - }, - "&.Mui-selected": { - color: theme.palette.secondary.contrastText, - }, - "&:hover": { - borderColor: theme.palette.primary.lowContrast, - }, - }), - }, - variants: [ - { - props: { orientation: "vertical" }, - style: ({ theme }) => ({ - alignItems: "flex-start", - padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing(1)}px ${theme.spacing(6)}px`, - minHeight: theme.spacing(12), - color: theme.palette.primary.contrastText, - backgroundColor: theme.palette.primary.main, - border: "none", - borderBottom: "none", - borderRight: "none", - borderRadius: theme.shape.borderRadius * 3, - margin: `${theme.spacing(1)}px ${theme.spacing(2)}px`, - "&.Mui-selected": { - color: theme.palette.primary.contrastText, - backgroundColor: theme.palette.tertiary.main, - opacity: 1, - border: "none", - borderBottom: "none", - borderRight: "none", - borderRadius: theme.shape.borderRadius * 3, - minHeight: theme.spacing(14), - }, - "&:hover": { - backgroundColor: theme.palette.tertiary.main, - border: "none", - borderRadius: theme.shape.borderRadius * 3, - minHeight: theme.spacing(14), - }, - }), - }, - ], - }, - MuiSvgIcon: { - styleOverrides: { - root: ({ theme }) => ({ - color: theme.palette.primary.contrastTextTertiary, - }), - }, - }, - MuiTabs: { - styleOverrides: { - root: ({ theme }) => ({ - height: "34px", - minHeight: "34px", - display: "inline-flex", - borderRadius: 0, - "& .MuiTabs-indicator": { - backgroundColor: theme.palette.tertiary.contrastText, - }, - }), - }, - variants: [ - { - props: { orientation: "vertical" }, - style: { - "& .MuiTabs-indicator": { - display: "none", - }, - }, - }, - ], - }, - MuiSwitch: { - styleOverrides: { - root: ({ theme }) => ({ - width: 42, - height: 26, - padding: 0, - "& .MuiSwitch-switchBase": { - padding: 0, - margin: 2, - transitionDuration: "300ms", - "&.Mui-checked": { - transform: "translateX(16px)", - color: "#fff", - "& + .MuiSwitch-track": { - backgroundColor: theme.palette.accent.main, - opacity: 1, - border: 0, - }, - }, - }, - "& .MuiSwitch-thumb": { - boxSizing: "border-box", - width: 22, - height: 22, - }, - "& .MuiSwitch-track": { - borderRadius: 13, - backgroundColor: theme.palette.secondary.light, - opacity: 1, - }, - }), - }, - }, - MuiSelect: { - styleOverrides: { - root: ({ theme }) => ({ - "& .MuiOutlinedInput-input": { - color: theme.palette.primary.contrastText, - }, - "& .MuiOutlinedInput-notchedOutline": { - borderColor: theme.palette.primary.lowContrast, - borderRadius: theme.shape.borderRadius, - }, - "& .MuiSelect-icon": { - color: theme.palette.primary.contrastTextSecondary, // Dropdown + color - }, - "& .MuiSelect-select": { - display: "flex", - alignItems: "center", - }, - "&:hover": { - backgroundColor: theme.palette.primary.main, // Background on hover - }, - "&:hover .MuiOutlinedInput-notchedOutline": { - borderColor: theme.palette.primary.lowContrast, - }, - padding: `0 ${theme.spacing(5)}`, - minHeight: "34px", - display: "flex", - alignItems: "center", - lineHeight: 1, - }), - }, - }, - MuiButtonGroup: { - styleOverrides: { - root: ({ theme }) => ({ - ml: "auto", - "& .MuiButtonBase-root, & .MuiButtonBase-root:hover": { - borderColor: theme.palette.primary.lowContrast, - width: "auto", - whiteSpace: "nowrap", - }, - }), - }, - }, - - // This code is added for clock in maintenance page - // code starts from here. - MuiClock: { - // Directly target the clock component - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.primary.main, // Alternative target - "& .MuiClock-clock": { - // Inner clock face - backgroundColor: theme.palette.secondary.main, - }, - }), - }, - }, - MuiClockPicker: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.secondary.main, // Outer container background - "& .MuiClock-root": { - color: theme.palette.primary.lowContrast, - }, - "& .MuiClock-clock": { - backgroundColor: theme.palette.background.default, // Clock face background - borderColor: theme.palette.secondary.lowContrast, - }, - }), - }, - }, - // The clock pointer ( pointer to number like hour/minute hand) - MuiClockPointer: { - styleOverrides: { - root: ({ theme }) => ({ - // Main pointer line color - backgroundColor: theme.palette.accent.main, - "& .MuiClockPointer-thumb": { - backgroundColor: theme.palette.grey[500], // Use your desired grey - }, - }), - }, - }, - // This is for numbers in the clock (circular one's) - MuiClockNumber: { - styleOverrides: { - root: ({ theme }) => ({ - color: theme.palette.primary.contrastText, - "&.Mui-selected": { - color: theme.palette.accent.contrastText, - backgroundColor: theme.palette.accent.main, - }, - }), - }, - }, - // This is for 00:00 am and pm (top bar) - MuiTimePickerToolbar: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.secondary.lowContrast, - // General text color - "& .MuiTypography-root": { - color: theme.palette.primary.contrastTextTertiary, - }, - // Selected time (hour/minute) color - "& .Mui-selected": { - color: `${theme.palette.accent.main} !important`, - }, - // AM/PM buttons color - "& .MuiButtonBase-root": { - "&.Mui-selected": { - backgroundColor: theme.palette.accent.main, - }, - }, - }), - }, - }, - // left and right direction style - MuiPickersArrowSwitcher: { - styleOverrides: { - root: ({ theme }) => ({ - "& .MuiIconButton-root": { - color: theme.palette.primary.contrastText, - }, - }), - }, - }, - // cancel and okay actions style - MuiDialogActions: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.primary.main, - }), - }, - }, - // DatePicker calendar popup styling - MuiPickersPopper: { - styleOverrides: { - paper: ({ theme }) => ({ - backgroundColor: theme.palette.primary.main, - }), - }, - }, - MuiDateCalendar: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.palette.primary.main, - }), - }, - }, - MuiPickersCalendarHeader: { - styleOverrides: { - root: ({ theme }) => ({ - "& .MuiPickersCalendarHeader-label": { - color: theme.palette.primary.contrastText, - }, - }), - }, - }, - MuiDayCalendar: { - styleOverrides: { - weekDayLabel: ({ theme }) => ({ - color: theme.palette.primary.contrastTextTertiary, - }), - }, - }, - MuiPickersDay: { - styleOverrides: { - root: ({ theme }) => ({ - color: theme.palette.primary.contrastText, - "&:hover": { - backgroundColor: theme.palette.tertiary.main, - }, - "&.Mui-selected": { - backgroundColor: theme.palette.accent.main, - color: theme.palette.accent.contrastText, - "&:hover": { - backgroundColor: theme.palette.accent.main, - }, - }, - "&.Mui-disabled": { - color: theme.palette.primary.contrastTextTertiary, - opacity: 0.5, - }, - }), - }, - }, - // code ends here. - - // For labels of input fields - MuiInputLabel: { - styleOverrides: { - root: ({ theme }) => ({ - "&.Mui-focused": { - color: theme.palette.accent.main, - }, - }), - }, - }, - MuiTypography: { - variants: [ - { - props: { variant: "monitorName" }, - style: { - fontSize: typographyLevels.xl, - color: palette.primary.contrastText, - fontWeight: 500, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }, - }, - { - props: { variant: "monitorUrl" }, - style: { - fontSize: typographyLevels.l, - color: palette.primary.contrastTextSecondary, - fontWeight: "bolder", - fontFamily: "monospace", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }, - }, - ], - }, - - MuiTooltip: { - styleOverrides: { - tooltip: () => ({ - fontSize: typographyLevels.m, - }), - }, - }, - }, - shape: { - borderRadius: 2, - borderThick: 2, - boxShadow: shadow, - }, -}); - -export { baseTheme }; diff --git a/client/src/Utils/Theme/lightTheme.js b/client/src/Utils/Theme/lightTheme.js deleted file mode 100644 index 94e8d3d4a..000000000 --- a/client/src/Utils/Theme/lightTheme.js +++ /dev/null @@ -1,12 +0,0 @@ -import { createTheme } from "@mui/material"; -import { baseTheme } from "./globalTheme"; -import { /* colors, */ newSemanticColors } from "./constants"; -import { extractThemeColors } from "./extractColorObject"; - -const palette = extractThemeColors("light", newSemanticColors); -const lightTheme = createTheme({ - palette, - ...baseTheme(palette), -}); - -export default lightTheme; From d45e6ec97c64a9b8c59c64500126769917c2c2af Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 20 Feb 2026 18:13:39 +0000 Subject: [PATCH 390/392] remove v2 dir --- client/src/App.tsx | 2 +- .../src/Components/{v2 => }/actions-menu/index.tsx | 0 .../{v2 => }/common/charts/HeatmapResponseTime.tsx | 2 +- .../common/charts/HeatmapResponseTimeTooltip.tsx | 0 .../common/charts/HistogramResponseTime.tsx | 0 .../{v2 => }/common/controls/HeaderCreate.tsx | 0 .../{v2 => }/common/controls/HeaderTimeRange.tsx | 2 +- client/src/Components/common/index.ts | 5 +++++ .../Components/{v2 => }/design-elements/Avatar.tsx | 0 .../{v2 => }/design-elements/BaseBox.tsx | 0 .../{v2 => }/design-elements/BaseChart.tsx | 2 +- .../{v2 => }/design-elements/BasePage.tsx | 4 ++-- .../{v2 => }/design-elements/Breadcrumb.tsx | 0 .../{v2 => }/design-elements/BulletPointCheck.tsx | 0 .../Components/{v2 => }/design-elements/Dot.tsx | 0 .../{v2 => }/design-elements/Fallback.tsx | 4 ++-- .../Components/{v2 => }/design-elements/Gauge.tsx | 2 +- .../Components/{v2 => }/design-elements/Icon.tsx | 0 .../{v2 => }/design-elements/MonitorStatus.tsx | 2 +- .../{v2 => }/design-elements/OfflineBanner.tsx | 0 .../{v2 => }/design-elements/PulseDot.tsx | 0 .../{v2 => }/design-elements/SkeletonCard.tsx | 0 .../{v2 => }/design-elements/SplitBox.tsx | 0 .../{v2 => }/design-elements/StatBox.tsx | 2 +- .../{v2 => }/design-elements/StatusBox.tsx | 2 +- .../{v2 => }/design-elements/StatusLabel.tsx | 2 +- .../Components/{v2 => }/design-elements/Table.tsx | 0 .../Components/{v2 => }/design-elements/Tabs.tsx | 0 .../{v2 => }/design-elements/TextLink.tsx | 0 .../{v2 => }/design-elements/Tooltip.tsx | 0 .../Components/{v2 => }/design-elements/index.tsx | 0 .../src/Components/{v2 => }/i18nLoader/index.tsx | 0 .../Components/{v2 => }/inputs/AutoComplete.tsx | 2 +- client/src/Components/{v2 => }/inputs/Button.tsx | 0 client/src/Components/{v2 => }/inputs/Checkbox.tsx | 0 .../src/Components/{v2 => }/inputs/ColorPicker.tsx | 2 +- .../src/Components/{v2 => }/inputs/DatePicker.tsx | 0 client/src/Components/{v2 => }/inputs/Dialog.tsx | 2 +- .../src/Components/{v2 => }/inputs/FieldLabel.tsx | 0 .../src/Components/{v2 => }/inputs/ImageUpload.tsx | 0 .../{v2 => }/inputs/LanguageSelector.tsx | 2 +- client/src/Components/{v2 => }/inputs/Radio.tsx | 0 client/src/Components/{v2 => }/inputs/Select.tsx | 0 client/src/Components/{v2 => }/inputs/Slider.tsx | 0 client/src/Components/{v2 => }/inputs/Switch.tsx | 0 .../src/Components/{v2 => }/inputs/SwitchTheme.tsx | 0 .../src/Components/{v2 => }/inputs/TextInput.tsx | 0 .../src/Components/{v2 => }/inputs/TimePicker.tsx | 0 .../Components/{v2 => }/inputs/ToggleButton.tsx | 0 client/src/Components/{v2 => }/inputs/index.tsx | 0 .../src/Components/{v2 => }/layout/AppLayout.tsx | 2 +- .../src/Components/{v2 => }/layout/RootLayout.tsx | 2 +- .../{v2 => }/monitors/ControlsFilter.tsx | 2 +- .../{v2 => }/monitors/HeaderMonitorControls.tsx | 4 ++-- .../{v2 => }/monitors/HeaderMonitorsSummary.tsx | 2 +- .../{v2 => }/monitors/MonitorStatBoxes.tsx | 2 +- .../{v2 => }/monitors/charts/HistogramDetails.tsx | 2 +- .../monitors/charts/HistogramInfrastructure.tsx | 4 ++-- .../monitors/charts/HistogramPageSpeed.tsx | 2 +- .../monitors/charts/HistogramPageSpeedDetails.tsx | 6 +++--- .../charts/HistogramPageSpeedDetailsTooltip.tsx | 2 +- .../monitors/charts/HistogramPageSpeedTooltip.tsx | 0 .../{v2 => }/monitors/charts/HistogramStatus.tsx | 2 +- .../{v2 => }/monitors/charts/PiePageSpeed.tsx | 2 +- .../monitors/charts/PiePageSpeedLegend.tsx | 2 +- .../{v2 => }/monitors/charts/RadialAvgResponse.tsx | 2 +- client/src/Components/{v2 => }/monitors/index.tsx | 0 .../Components/{v2 => }/routing/RouteProtected.tsx | 0 .../src/Components/{v2 => }/sidebar/Authfooter.tsx | 2 +- client/src/Components/{v2 => }/sidebar/Logo.tsx | 0 client/src/Components/{v2 => }/sidebar/Menu.tsx | 2 +- client/src/Components/{v2 => }/sidebar/NavItem.tsx | 0 .../src/Components/{v2 => }/sidebar/StarPrompt.tsx | 2 +- client/src/Components/{v2 => }/sidebar/index.tsx | 10 +++++----- client/src/Components/v2/common/index.ts | 5 ----- client/src/Pages/Account/EditUser/index.tsx | 4 ++-- client/src/Pages/Account/TabPassword.tsx | 4 ++-- client/src/Pages/Account/TabProfile.tsx | 6 +++--- .../Account/components/AddTeamMemberDialog.tsx | 2 +- .../Account/components/HeaderTeamControls.tsx | 4 ++-- .../Account/components/InviteTeamMemberDialog.tsx | 2 +- client/src/Pages/Account/components/TeamTable.tsx | 4 ++-- client/src/Pages/Account/index.tsx | 2 +- client/src/Pages/Auth/Login/index.tsx | 4 ++-- client/src/Pages/Auth/Recovery/index.tsx | 4 ++-- client/src/Pages/Auth/Register/index.tsx | 4 ++-- client/src/Pages/Auth/SetNewPassword/index.tsx | 8 ++------ .../Pages/Auth/components/HeaderAuthControls.tsx | 2 +- client/src/Pages/Checks/Components/ChecksTable.tsx | 9 ++------- client/src/Pages/Checks/index.tsx | 6 +++--- client/src/Pages/CreateMonitor/index.tsx | 8 ++++---- .../src/Pages/Incidents/Components/CardDetails.tsx | 2 +- .../src/Pages/Incidents/Components/CardSummary.tsx | 2 +- .../Components/ControlsIncidentFilter.tsx | 2 +- .../Incidents/Components/DialogIncidentDetails.tsx | 2 +- .../Incidents/Components/DialogResolution.tsx | 2 +- .../Pages/Incidents/Components/IncidentTable.tsx | 14 +++++++------- client/src/Pages/Incidents/index.tsx | 4 ++-- .../Infrastructure/Details/Components/Charts.tsx | 2 +- .../Details/Components/ChartsNetwork.tsx | 2 +- .../Infrastructure/Details/Components/Gauges.tsx | 2 +- .../Details/Components/StatusBoxes.tsx | 2 +- client/src/Pages/Infrastructure/Details/index.tsx | 6 +++--- .../Monitors/Components/MonitorsTable.tsx | 6 +++--- client/src/Pages/Infrastructure/Monitors/index.tsx | 8 ++++---- client/src/Pages/Logs/TabLogs.tsx | 2 +- client/src/Pages/Logs/TabQueue.tsx | 2 +- client/src/Pages/Logs/components/Metrics.tsx | 2 +- client/src/Pages/Logs/components/StatGauges.tsx | 2 +- client/src/Pages/Logs/components/Stats.tsx | 2 +- client/src/Pages/Logs/components/TableJobs.tsx | 4 ++-- client/src/Pages/Logs/components/TableLogs.tsx | 6 +++--- client/src/Pages/Logs/index.tsx | 2 +- .../Pages/Maintenance/MaintenanceWindowTable.tsx | 12 ++++++------ client/src/Pages/Maintenance/create/index.tsx | 4 ++-- client/src/Pages/Maintenance/index.tsx | 4 ++-- client/src/Pages/NotFound/index.tsx | 2 +- .../components/NotificationsTable.tsx | 6 +++--- client/src/Pages/Notifications/create/index.tsx | 4 ++-- client/src/Pages/Notifications/index.tsx | 6 +++--- client/src/Pages/PageSpeed/Details/index.tsx | 4 ++-- .../Monitors/Components/PageSpeedMonitorsTable.tsx | 10 +++++----- client/src/Pages/PageSpeed/Monitors/index.tsx | 8 ++++---- client/src/Pages/Settings/DummyChart.tsx | 2 +- client/src/Pages/Settings/index.tsx | 6 +++--- .../Components/HeaderConfigStatusControls.tsx | 4 ++-- client/src/Pages/StatusPage/Create/index.tsx | 4 ++-- .../Status/Components/HeaderStatusPageControls.tsx | 4 ++-- .../StatusPage/Status/Components/MonitorsList.tsx | 6 +++--- client/src/Pages/StatusPage/Status/index.tsx | 2 +- .../StatusPages/Components/StatusPagesTable.tsx | 4 ++-- client/src/Pages/StatusPage/StatusPages/index.tsx | 6 +++--- .../Uptime/Details/Components/ChecksTable.tsx | 4 ++-- client/src/Pages/Uptime/Details/index.tsx | 8 ++++---- .../Monitors/Components/UptimeMonitorsTable.tsx | 8 ++++---- client/src/Pages/Uptime/Monitors/index.tsx | 8 ++++---- client/src/Routes/index.tsx | 7 ++----- client/src/Utils/MonitorUtils.ts | 2 +- client/src/main.tsx | 2 +- 139 files changed, 187 insertions(+), 199 deletions(-) rename client/src/Components/{v2 => }/actions-menu/index.tsx (100%) rename client/src/Components/{v2 => }/common/charts/HeatmapResponseTime.tsx (97%) rename client/src/Components/{v2 => }/common/charts/HeatmapResponseTimeTooltip.tsx (100%) rename client/src/Components/{v2 => }/common/charts/HistogramResponseTime.tsx (100%) rename client/src/Components/{v2 => }/common/controls/HeaderCreate.tsx (100%) rename client/src/Components/{v2 => }/common/controls/HeaderTimeRange.tsx (96%) create mode 100644 client/src/Components/common/index.ts rename client/src/Components/{v2 => }/design-elements/Avatar.tsx (100%) rename client/src/Components/{v2 => }/design-elements/BaseBox.tsx (100%) rename client/src/Components/{v2 => }/design-elements/BaseChart.tsx (96%) rename client/src/Components/{v2 => }/design-elements/BasePage.tsx (97%) rename client/src/Components/{v2 => }/design-elements/Breadcrumb.tsx (100%) rename client/src/Components/{v2 => }/design-elements/BulletPointCheck.tsx (100%) rename client/src/Components/{v2 => }/design-elements/Dot.tsx (100%) rename client/src/Components/{v2 => }/design-elements/Fallback.tsx (96%) rename client/src/Components/{v2 => }/design-elements/Gauge.tsx (98%) rename client/src/Components/{v2 => }/design-elements/Icon.tsx (100%) rename client/src/Components/{v2 => }/design-elements/MonitorStatus.tsx (95%) rename client/src/Components/{v2 => }/design-elements/OfflineBanner.tsx (100%) rename client/src/Components/{v2 => }/design-elements/PulseDot.tsx (100%) rename client/src/Components/{v2 => }/design-elements/SkeletonCard.tsx (100%) rename client/src/Components/{v2 => }/design-elements/SplitBox.tsx (100%) rename client/src/Components/{v2 => }/design-elements/StatBox.tsx (96%) rename client/src/Components/{v2 => }/design-elements/StatusBox.tsx (98%) rename client/src/Components/{v2 => }/design-elements/StatusLabel.tsx (97%) rename client/src/Components/{v2 => }/design-elements/Table.tsx (100%) rename client/src/Components/{v2 => }/design-elements/Tabs.tsx (100%) rename client/src/Components/{v2 => }/design-elements/TextLink.tsx (100%) rename client/src/Components/{v2 => }/design-elements/Tooltip.tsx (100%) rename client/src/Components/{v2 => }/design-elements/index.tsx (100%) rename client/src/Components/{v2 => }/i18nLoader/index.tsx (100%) rename client/src/Components/{v2 => }/inputs/AutoComplete.tsx (97%) rename client/src/Components/{v2 => }/inputs/Button.tsx (100%) rename client/src/Components/{v2 => }/inputs/Checkbox.tsx (100%) rename client/src/Components/{v2 => }/inputs/ColorPicker.tsx (94%) rename client/src/Components/{v2 => }/inputs/DatePicker.tsx (100%) rename client/src/Components/{v2 => }/inputs/Dialog.tsx (97%) rename client/src/Components/{v2 => }/inputs/FieldLabel.tsx (100%) rename client/src/Components/{v2 => }/inputs/ImageUpload.tsx (100%) rename client/src/Components/{v2 => }/inputs/LanguageSelector.tsx (97%) rename client/src/Components/{v2 => }/inputs/Radio.tsx (100%) rename client/src/Components/{v2 => }/inputs/Select.tsx (100%) rename client/src/Components/{v2 => }/inputs/Slider.tsx (100%) rename client/src/Components/{v2 => }/inputs/Switch.tsx (100%) rename client/src/Components/{v2 => }/inputs/SwitchTheme.tsx (100%) rename client/src/Components/{v2 => }/inputs/TextInput.tsx (100%) rename client/src/Components/{v2 => }/inputs/TimePicker.tsx (100%) rename client/src/Components/{v2 => }/inputs/ToggleButton.tsx (100%) rename client/src/Components/{v2 => }/inputs/index.tsx (100%) rename client/src/Components/{v2 => }/layout/AppLayout.tsx (96%) rename client/src/Components/{v2 => }/layout/RootLayout.tsx (94%) rename client/src/Components/{v2 => }/monitors/ControlsFilter.tsx (97%) rename client/src/Components/{v2 => }/monitors/HeaderMonitorControls.tsx (97%) rename client/src/Components/{v2 => }/monitors/HeaderMonitorsSummary.tsx (95%) rename client/src/Components/{v2 => }/monitors/MonitorStatBoxes.tsx (97%) rename client/src/Components/{v2 => }/monitors/charts/HistogramDetails.tsx (97%) rename client/src/Components/{v2 => }/monitors/charts/HistogramInfrastructure.tsx (97%) rename client/src/Components/{v2 => }/monitors/charts/HistogramPageSpeed.tsx (96%) rename client/src/Components/{v2 => }/monitors/charts/HistogramPageSpeedDetails.tsx (93%) rename client/src/Components/{v2 => }/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx (97%) rename client/src/Components/{v2 => }/monitors/charts/HistogramPageSpeedTooltip.tsx (100%) rename client/src/Components/{v2 => }/monitors/charts/HistogramStatus.tsx (98%) rename client/src/Components/{v2 => }/monitors/charts/PiePageSpeed.tsx (98%) rename client/src/Components/{v2 => }/monitors/charts/PiePageSpeedLegend.tsx (97%) rename client/src/Components/{v2 => }/monitors/charts/RadialAvgResponse.tsx (97%) rename client/src/Components/{v2 => }/monitors/index.tsx (100%) rename client/src/Components/{v2 => }/routing/RouteProtected.tsx (100%) rename client/src/Components/{v2 => }/sidebar/Authfooter.tsx (98%) rename client/src/Components/{v2 => }/sidebar/Logo.tsx (100%) rename client/src/Components/{v2 => }/sidebar/Menu.tsx (97%) rename client/src/Components/{v2 => }/sidebar/NavItem.tsx (100%) rename client/src/Components/{v2 => }/sidebar/StarPrompt.tsx (97%) rename client/src/Components/{v2 => }/sidebar/index.tsx (92%) delete mode 100644 client/src/Components/v2/common/index.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 148978521..127426c85 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,7 +5,7 @@ import { ToastContainer } from "react-toastify"; import { ThemeProvider } from "@emotion/react"; import { CssBaseline, GlobalStyles } from "@mui/material"; import { Routes } from "./Routes"; -import AppLayout from "@/Components/v2/layout/AppLayout"; +import AppLayout from "@/Components/layout/AppLayout"; import type { RootState } from "@/Types/state"; import { lightTheme, darkTheme } from "@/Utils/Theme/Theme"; diff --git a/client/src/Components/v2/actions-menu/index.tsx b/client/src/Components/actions-menu/index.tsx similarity index 100% rename from client/src/Components/v2/actions-menu/index.tsx rename to client/src/Components/actions-menu/index.tsx diff --git a/client/src/Components/v2/common/charts/HeatmapResponseTime.tsx b/client/src/Components/common/charts/HeatmapResponseTime.tsx similarity index 97% rename from client/src/Components/v2/common/charts/HeatmapResponseTime.tsx rename to client/src/Components/common/charts/HeatmapResponseTime.tsx index 972291f57..8178ffb92 100644 --- a/client/src/Components/v2/common/charts/HeatmapResponseTime.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTime.tsx @@ -2,7 +2,7 @@ import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; import type { CheckSnapshot } from "@/Types/Check"; import { getResponseColor } from "@/Utils/DataUtils"; -import { HeatmapResponseTimeTooltip } from "@/Components/v2/common/charts/HeatmapResponseTimeTooltip"; +import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip"; import type { SxProps } from "@mui/material/styles"; import type { ResponsiveStyleValue } from "@mui/system"; diff --git a/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx similarity index 100% rename from client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx rename to client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx diff --git a/client/src/Components/v2/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx similarity index 100% rename from client/src/Components/v2/common/charts/HistogramResponseTime.tsx rename to client/src/Components/common/charts/HistogramResponseTime.tsx diff --git a/client/src/Components/v2/common/controls/HeaderCreate.tsx b/client/src/Components/common/controls/HeaderCreate.tsx similarity index 100% rename from client/src/Components/v2/common/controls/HeaderCreate.tsx rename to client/src/Components/common/controls/HeaderCreate.tsx diff --git a/client/src/Components/v2/common/controls/HeaderTimeRange.tsx b/client/src/Components/common/controls/HeaderTimeRange.tsx similarity index 96% rename from client/src/Components/v2/common/controls/HeaderTimeRange.tsx rename to client/src/Components/common/controls/HeaderTimeRange.tsx index c66ee9ff6..d7c7102bc 100644 --- a/client/src/Components/v2/common/controls/HeaderTimeRange.tsx +++ b/client/src/Components/common/controls/HeaderTimeRange.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs"; +import { ToggleButtonGroup, ToggleButton } from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import CircularProgress from "@mui/material/CircularProgress"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Components/common/index.ts b/client/src/Components/common/index.ts new file mode 100644 index 000000000..4db9cac34 --- /dev/null +++ b/client/src/Components/common/index.ts @@ -0,0 +1,5 @@ +export * from "./charts/HistogramResponseTime"; +export * from "./charts/HeatmapResponseTime"; +export * from "./charts/HeatmapResponseTimeTooltip"; +export * from "./controls/HeaderCreate"; +export * from "./controls/HeaderTimeRange"; diff --git a/client/src/Components/v2/design-elements/Avatar.tsx b/client/src/Components/design-elements/Avatar.tsx similarity index 100% rename from client/src/Components/v2/design-elements/Avatar.tsx rename to client/src/Components/design-elements/Avatar.tsx diff --git a/client/src/Components/v2/design-elements/BaseBox.tsx b/client/src/Components/design-elements/BaseBox.tsx similarity index 100% rename from client/src/Components/v2/design-elements/BaseBox.tsx rename to client/src/Components/design-elements/BaseBox.tsx diff --git a/client/src/Components/v2/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx similarity index 96% rename from client/src/Components/v2/design-elements/BaseChart.tsx rename to client/src/Components/design-elements/BaseChart.tsx index fd9251915..569652b20 100644 --- a/client/src/Components/v2/design-elements/BaseChart.tsx +++ b/client/src/Components/design-elements/BaseChart.tsx @@ -1,4 +1,4 @@ -import { BaseBox } from "../design-elements"; +import { BaseBox } from "."; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/design-elements/BasePage.tsx similarity index 97% rename from client/src/Components/v2/design-elements/BasePage.tsx rename to client/src/Components/design-elements/BasePage.tsx index 48c836e54..cf28da40f 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/design-elements/BasePage.tsx @@ -8,8 +8,8 @@ import { EmptyFallback, EmptyMonitorFallback, BaseFallback, -} from "@/Components/v2/design-elements/Fallback"; -import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb"; +} from "@/Components/design-elements/Fallback"; +import { Breadcrumb } from "@/Components/design-elements/Breadcrumb"; import CircularProgress from "@mui/material/CircularProgress"; import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls"; diff --git a/client/src/Components/v2/design-elements/Breadcrumb.tsx b/client/src/Components/design-elements/Breadcrumb.tsx similarity index 100% rename from client/src/Components/v2/design-elements/Breadcrumb.tsx rename to client/src/Components/design-elements/Breadcrumb.tsx diff --git a/client/src/Components/v2/design-elements/BulletPointCheck.tsx b/client/src/Components/design-elements/BulletPointCheck.tsx similarity index 100% rename from client/src/Components/v2/design-elements/BulletPointCheck.tsx rename to client/src/Components/design-elements/BulletPointCheck.tsx diff --git a/client/src/Components/v2/design-elements/Dot.tsx b/client/src/Components/design-elements/Dot.tsx similarity index 100% rename from client/src/Components/v2/design-elements/Dot.tsx rename to client/src/Components/design-elements/Dot.tsx diff --git a/client/src/Components/v2/design-elements/Fallback.tsx b/client/src/Components/design-elements/Fallback.tsx similarity index 96% rename from client/src/Components/v2/design-elements/Fallback.tsx rename to client/src/Components/design-elements/Fallback.tsx index 593829ab5..93cdac410 100644 --- a/client/src/Components/v2/design-elements/Fallback.tsx +++ b/client/src/Components/design-elements/Fallback.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { BulletPointCheck, SkeletonCard } from "@/Components/v2/design-elements"; -import { Button } from "@/Components/v2/inputs"; +import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements"; +import { Button } from "@/Components/inputs"; import { useNavigate } from "react-router"; import useMediaQuery from "@mui/material/useMediaQuery"; diff --git a/client/src/Components/v2/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx similarity index 98% rename from client/src/Components/v2/design-elements/Gauge.tsx rename to client/src/Components/design-elements/Gauge.tsx index 4fee5386c..fda42df3c 100644 --- a/client/src/Components/v2/design-elements/Gauge.tsx +++ b/client/src/Components/design-elements/Gauge.tsx @@ -1,4 +1,4 @@ -import { BaseChart } from "@/Components/v2/design-elements"; +import { BaseChart } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; diff --git a/client/src/Components/v2/design-elements/Icon.tsx b/client/src/Components/design-elements/Icon.tsx similarity index 100% rename from client/src/Components/v2/design-elements/Icon.tsx rename to client/src/Components/design-elements/Icon.tsx diff --git a/client/src/Components/v2/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx similarity index 95% rename from client/src/Components/v2/design-elements/MonitorStatus.tsx rename to client/src/Components/design-elements/MonitorStatus.tsx index a9720e293..04c62de47 100644 --- a/client/src/Components/v2/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -1,7 +1,7 @@ import type { Monitor } from "@/Types/Monitor"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { PulseDot, Dot } from "@/Components/v2/design-elements"; +import { PulseDot, Dot } from "@/Components/design-elements"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; import prettyMilliseconds from "pretty-ms"; diff --git a/client/src/Components/v2/design-elements/OfflineBanner.tsx b/client/src/Components/design-elements/OfflineBanner.tsx similarity index 100% rename from client/src/Components/v2/design-elements/OfflineBanner.tsx rename to client/src/Components/design-elements/OfflineBanner.tsx diff --git a/client/src/Components/v2/design-elements/PulseDot.tsx b/client/src/Components/design-elements/PulseDot.tsx similarity index 100% rename from client/src/Components/v2/design-elements/PulseDot.tsx rename to client/src/Components/design-elements/PulseDot.tsx diff --git a/client/src/Components/v2/design-elements/SkeletonCard.tsx b/client/src/Components/design-elements/SkeletonCard.tsx similarity index 100% rename from client/src/Components/v2/design-elements/SkeletonCard.tsx rename to client/src/Components/design-elements/SkeletonCard.tsx diff --git a/client/src/Components/v2/design-elements/SplitBox.tsx b/client/src/Components/design-elements/SplitBox.tsx similarity index 100% rename from client/src/Components/v2/design-elements/SplitBox.tsx rename to client/src/Components/design-elements/SplitBox.tsx diff --git a/client/src/Components/v2/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx similarity index 96% rename from client/src/Components/v2/design-elements/StatBox.tsx rename to client/src/Components/design-elements/StatBox.tsx index 901468f23..01a8e9835 100644 --- a/client/src/Components/v2/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -5,7 +5,7 @@ import { useTheme } from "@mui/material/styles"; import { lighten } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { PaletteKey } from "@/Utils/Theme/Theme"; -import { BaseBox, TooltipWithInfo } from "@/Components/v2/design-elements"; +import { BaseBox, TooltipWithInfo } from "@/Components/design-elements"; import type { SxProps } from "@mui/material"; type GradientBox = React.PropsWithChildren<{ diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/design-elements/StatusBox.tsx similarity index 98% rename from client/src/Components/v2/design-elements/StatusBox.tsx rename to client/src/Components/design-elements/StatusBox.tsx index 73a881736..96a9d9fbf 100644 --- a/client/src/Components/v2/design-elements/StatusBox.tsx +++ b/client/src/Components/design-elements/StatusBox.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; -import { BaseBox } from "@/Components/v2/design-elements"; +import { BaseBox } from "@/Components/design-elements"; import { useTranslation } from "react-i18next"; import type { SxProps } from "@mui/material"; diff --git a/client/src/Components/v2/design-elements/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx similarity index 97% rename from client/src/Components/v2/design-elements/StatusLabel.tsx rename to client/src/Components/design-elements/StatusLabel.tsx index 19bb8d157..d50058378 100644 --- a/client/src/Components/v2/design-elements/StatusLabel.tsx +++ b/client/src/Components/design-elements/StatusLabel.tsx @@ -1,5 +1,5 @@ import Box from "@mui/material/Box"; -import { BaseBox } from "@/Components/v2/design-elements"; +import { BaseBox } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; import type { MonitorStatus } from "@/Types/Monitor"; diff --git a/client/src/Components/v2/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx similarity index 100% rename from client/src/Components/v2/design-elements/Table.tsx rename to client/src/Components/design-elements/Table.tsx diff --git a/client/src/Components/v2/design-elements/Tabs.tsx b/client/src/Components/design-elements/Tabs.tsx similarity index 100% rename from client/src/Components/v2/design-elements/Tabs.tsx rename to client/src/Components/design-elements/Tabs.tsx diff --git a/client/src/Components/v2/design-elements/TextLink.tsx b/client/src/Components/design-elements/TextLink.tsx similarity index 100% rename from client/src/Components/v2/design-elements/TextLink.tsx rename to client/src/Components/design-elements/TextLink.tsx diff --git a/client/src/Components/v2/design-elements/Tooltip.tsx b/client/src/Components/design-elements/Tooltip.tsx similarity index 100% rename from client/src/Components/v2/design-elements/Tooltip.tsx rename to client/src/Components/design-elements/Tooltip.tsx diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx similarity index 100% rename from client/src/Components/v2/design-elements/index.tsx rename to client/src/Components/design-elements/index.tsx diff --git a/client/src/Components/v2/i18nLoader/index.tsx b/client/src/Components/i18nLoader/index.tsx similarity index 100% rename from client/src/Components/v2/i18nLoader/index.tsx rename to client/src/Components/i18nLoader/index.tsx diff --git a/client/src/Components/v2/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx similarity index 97% rename from client/src/Components/v2/inputs/AutoComplete.tsx rename to client/src/Components/inputs/AutoComplete.tsx index af0e8cbfd..c98e8ff32 100644 --- a/client/src/Components/v2/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -1,6 +1,6 @@ import Autocomplete from "@mui/material/Autocomplete"; import type { AutocompleteProps } from "@mui/material/Autocomplete"; -import { TextField, Checkbox } from "@/Components/v2/inputs"; +import { TextField, Checkbox } from "@/Components/inputs"; import ListItem from "@mui/material/ListItem"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; diff --git a/client/src/Components/v2/inputs/Button.tsx b/client/src/Components/inputs/Button.tsx similarity index 100% rename from client/src/Components/v2/inputs/Button.tsx rename to client/src/Components/inputs/Button.tsx diff --git a/client/src/Components/v2/inputs/Checkbox.tsx b/client/src/Components/inputs/Checkbox.tsx similarity index 100% rename from client/src/Components/v2/inputs/Checkbox.tsx rename to client/src/Components/inputs/Checkbox.tsx diff --git a/client/src/Components/v2/inputs/ColorPicker.tsx b/client/src/Components/inputs/ColorPicker.tsx similarity index 94% rename from client/src/Components/v2/inputs/ColorPicker.tsx rename to client/src/Components/inputs/ColorPicker.tsx index d2899c203..de736c85d 100644 --- a/client/src/Components/v2/inputs/ColorPicker.tsx +++ b/client/src/Components/inputs/ColorPicker.tsx @@ -3,7 +3,7 @@ import type { MuiColorInputProps } from "mui-color-input"; import { typographyLevels } from "@/Utils/Theme/Palette"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; -import { FieldLabel } from "@/Components/v2/inputs/FieldLabel"; +import { FieldLabel } from "@/Components/inputs/FieldLabel"; interface ColorPickerProps extends MuiColorInputProps { fieldLabel?: string; diff --git a/client/src/Components/v2/inputs/DatePicker.tsx b/client/src/Components/inputs/DatePicker.tsx similarity index 100% rename from client/src/Components/v2/inputs/DatePicker.tsx rename to client/src/Components/inputs/DatePicker.tsx diff --git a/client/src/Components/v2/inputs/Dialog.tsx b/client/src/Components/inputs/Dialog.tsx similarity index 97% rename from client/src/Components/v2/inputs/Dialog.tsx rename to client/src/Components/inputs/Dialog.tsx index 9dd546dea..b0c924f55 100644 --- a/client/src/Components/v2/inputs/Dialog.tsx +++ b/client/src/Components/inputs/Dialog.tsx @@ -4,7 +4,7 @@ import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; -import { Button } from "@/Components/v2/inputs"; +import { Button } from "@/Components/inputs"; import { typographyLevels } from "@/Utils/Theme/Palette"; import { useTranslation } from "react-i18next"; import type { ReactNode } from "react"; diff --git a/client/src/Components/v2/inputs/FieldLabel.tsx b/client/src/Components/inputs/FieldLabel.tsx similarity index 100% rename from client/src/Components/v2/inputs/FieldLabel.tsx rename to client/src/Components/inputs/FieldLabel.tsx diff --git a/client/src/Components/v2/inputs/ImageUpload.tsx b/client/src/Components/inputs/ImageUpload.tsx similarity index 100% rename from client/src/Components/v2/inputs/ImageUpload.tsx rename to client/src/Components/inputs/ImageUpload.tsx diff --git a/client/src/Components/v2/inputs/LanguageSelector.tsx b/client/src/Components/inputs/LanguageSelector.tsx similarity index 97% rename from client/src/Components/v2/inputs/LanguageSelector.tsx rename to client/src/Components/inputs/LanguageSelector.tsx index 2a1274ef2..a6d52a555 100644 --- a/client/src/Components/v2/inputs/LanguageSelector.tsx +++ b/client/src/Components/inputs/LanguageSelector.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import MenuItem from "@mui/material/MenuItem"; -import { Select } from "@/Components/v2/inputs"; +import { Select } from "@/Components/inputs"; import "flag-icons/css/flag-icons.min.css"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Components/v2/inputs/Radio.tsx b/client/src/Components/inputs/Radio.tsx similarity index 100% rename from client/src/Components/v2/inputs/Radio.tsx rename to client/src/Components/inputs/Radio.tsx diff --git a/client/src/Components/v2/inputs/Select.tsx b/client/src/Components/inputs/Select.tsx similarity index 100% rename from client/src/Components/v2/inputs/Select.tsx rename to client/src/Components/inputs/Select.tsx diff --git a/client/src/Components/v2/inputs/Slider.tsx b/client/src/Components/inputs/Slider.tsx similarity index 100% rename from client/src/Components/v2/inputs/Slider.tsx rename to client/src/Components/inputs/Slider.tsx diff --git a/client/src/Components/v2/inputs/Switch.tsx b/client/src/Components/inputs/Switch.tsx similarity index 100% rename from client/src/Components/v2/inputs/Switch.tsx rename to client/src/Components/inputs/Switch.tsx diff --git a/client/src/Components/v2/inputs/SwitchTheme.tsx b/client/src/Components/inputs/SwitchTheme.tsx similarity index 100% rename from client/src/Components/v2/inputs/SwitchTheme.tsx rename to client/src/Components/inputs/SwitchTheme.tsx diff --git a/client/src/Components/v2/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx similarity index 100% rename from client/src/Components/v2/inputs/TextInput.tsx rename to client/src/Components/inputs/TextInput.tsx diff --git a/client/src/Components/v2/inputs/TimePicker.tsx b/client/src/Components/inputs/TimePicker.tsx similarity index 100% rename from client/src/Components/v2/inputs/TimePicker.tsx rename to client/src/Components/inputs/TimePicker.tsx diff --git a/client/src/Components/v2/inputs/ToggleButton.tsx b/client/src/Components/inputs/ToggleButton.tsx similarity index 100% rename from client/src/Components/v2/inputs/ToggleButton.tsx rename to client/src/Components/inputs/ToggleButton.tsx diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/inputs/index.tsx similarity index 100% rename from client/src/Components/v2/inputs/index.tsx rename to client/src/Components/inputs/index.tsx diff --git a/client/src/Components/v2/layout/AppLayout.tsx b/client/src/Components/layout/AppLayout.tsx similarity index 96% rename from client/src/Components/v2/layout/AppLayout.tsx rename to client/src/Components/layout/AppLayout.tsx index 24d18bfee..baadd85ca 100644 --- a/client/src/Components/v2/layout/AppLayout.tsx +++ b/client/src/Components/layout/AppLayout.tsx @@ -4,7 +4,7 @@ import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import BackgroundSVG from "@/assets/Images/background.svg"; import type { RootState } from "@/Types/state"; -import { OfflineBanner } from "@/Components/v2/design-elements"; +import { OfflineBanner } from "@/Components/design-elements"; import { setServerUnreachableCallback, get } from "@/Utils/ApiClient"; interface AppLayoutProps { diff --git a/client/src/Components/v2/layout/RootLayout.tsx b/client/src/Components/layout/RootLayout.tsx similarity index 94% rename from client/src/Components/v2/layout/RootLayout.tsx rename to client/src/Components/layout/RootLayout.tsx index 117c08255..c22bd7455 100644 --- a/client/src/Components/v2/layout/RootLayout.tsx +++ b/client/src/Components/layout/RootLayout.tsx @@ -1,4 +1,4 @@ -import { Sidebar } from "@/Components/v2/sidebar"; +import { Sidebar } from "@/Components/sidebar"; import { Outlet } from "react-router"; import Stack from "@mui/material/Stack"; import { useMediaQuery } from "@mui/material"; diff --git a/client/src/Components/v2/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx similarity index 97% rename from client/src/Components/v2/monitors/ControlsFilter.tsx rename to client/src/Components/monitors/ControlsFilter.tsx index 01802ee3f..e37408819 100644 --- a/client/src/Components/v2/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { Select, Button } from "@/Components/v2/inputs"; +import { Select, Button } from "@/Components/inputs"; import MenuItem from "@mui/material/MenuItem"; import useMediaQuery from "@mui/material/useMediaQuery"; diff --git a/client/src/Components/v2/monitors/HeaderMonitorControls.tsx b/client/src/Components/monitors/HeaderMonitorControls.tsx similarity index 97% rename from client/src/Components/v2/monitors/HeaderMonitorControls.tsx rename to client/src/Components/monitors/HeaderMonitorControls.tsx index 6d321cbb8..7f6968f0b 100644 --- a/client/src/Components/v2/monitors/HeaderMonitorControls.tsx +++ b/client/src/Components/monitors/HeaderMonitorControls.tsx @@ -1,6 +1,6 @@ import Stack from "@mui/material/Stack"; -import { Icon, MonitorStatus } from "@/Components/v2/design-elements"; -import { Button } from "@/Components/v2/inputs"; +import { Icon, MonitorStatus } from "@/Components/design-elements"; +import { Button } from "@/Components/inputs"; import { Settings, Pause, Play, Mail, Bug, Trash } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx b/client/src/Components/monitors/HeaderMonitorsSummary.tsx similarity index 95% rename from client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx rename to client/src/Components/monitors/HeaderMonitorsSummary.tsx index 38a6adfde..1d130209d 100644 --- a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx +++ b/client/src/Components/monitors/HeaderMonitorsSummary.tsx @@ -4,7 +4,7 @@ import { PausedStatusBox, InitializingStatusBox, BreachedStatusBox, -} from "@/Components/v2/design-elements"; +} from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import type { MonitorsSummary } from "@/Types/Monitor"; diff --git a/client/src/Components/v2/monitors/MonitorStatBoxes.tsx b/client/src/Components/monitors/MonitorStatBoxes.tsx similarity index 97% rename from client/src/Components/v2/monitors/MonitorStatBoxes.tsx rename to client/src/Components/monitors/MonitorStatBoxes.tsx index 867f85516..044bb71ab 100644 --- a/client/src/Components/v2/monitors/MonitorStatBoxes.tsx +++ b/client/src/Components/monitors/MonitorStatBoxes.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { StatBox } from "@/Components/v2/design-elements"; +import { StatBox } from "@/Components/design-elements"; import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material/styles"; diff --git a/client/src/Components/v2/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx similarity index 97% rename from client/src/Components/v2/monitors/charts/HistogramDetails.tsx rename to client/src/Components/monitors/charts/HistogramDetails.tsx index 8858a9784..c2f75aa7b 100644 --- a/client/src/Components/v2/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -1,4 +1,4 @@ -import { BaseChart, BaseBox } from "@/Components/v2/design-elements"; +import { BaseChart, BaseBox } from "@/Components/design-elements"; import { Clock } from "lucide-react"; import { AreaChart, diff --git a/client/src/Components/v2/monitors/charts/HistogramInfrastructure.tsx b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx similarity index 97% rename from client/src/Components/v2/monitors/charts/HistogramInfrastructure.tsx rename to client/src/Components/monitors/charts/HistogramInfrastructure.tsx index e65a58bd9..8dd15ae03 100644 --- a/client/src/Components/v2/monitors/charts/HistogramInfrastructure.tsx +++ b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx @@ -1,4 +1,4 @@ -import { BaseChart } from "@/Components/v2/design-elements"; +import { BaseChart } from "@/Components/design-elements"; import { AreaChart, Area, @@ -8,7 +8,7 @@ import { ResponsiveContainer, } from "recharts"; import { Fragment, useId } from "react"; -import { XTick } from "@/Components/v2/monitors"; +import { XTick } from "@/Components/monitors"; import { useTheme } from "@mui/material/styles"; import type { HardwareCheckStats } from "@/Types/Monitor"; diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeed.tsx b/client/src/Components/monitors/charts/HistogramPageSpeed.tsx similarity index 96% rename from client/src/Components/v2/monitors/charts/HistogramPageSpeed.tsx rename to client/src/Components/monitors/charts/HistogramPageSpeed.tsx index f19e0323c..79c826851 100644 --- a/client/src/Components/v2/monitors/charts/HistogramPageSpeed.tsx +++ b/client/src/Components/monitors/charts/HistogramPageSpeed.tsx @@ -1,6 +1,6 @@ import Stack from "@mui/material/Stack"; import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts"; -import { HistogramPageSpeedTooltip } from "@/Components/v2/monitors/charts/HistogramPageSpeedTooltip"; +import { HistogramPageSpeedTooltip } from "@/Components/monitors/charts/HistogramPageSpeedTooltip"; import { useTheme } from "@mui/material/styles"; import type { CheckSnapshot } from "@/Types/Check"; import type { MonitorStatus } from "@/Types/Monitor"; diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetails.tsx b/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx similarity index 93% rename from client/src/Components/v2/monitors/charts/HistogramPageSpeedDetails.tsx rename to client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx index 05bee8bc8..8df491197 100644 --- a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramPageSpeedDetails.tsx @@ -1,6 +1,6 @@ -import { BaseChart } from "@/Components/v2/design-elements"; +import { BaseChart } from "@/Components/design-elements"; import { TrendingUp } from "lucide-react"; -import { XTick } from "@/Components/v2/monitors/"; +import { XTick } from "@/Components/monitors"; import { XAxis, AreaChart, @@ -9,7 +9,7 @@ import { CartesianGrid, ResponsiveContainer, } from "recharts"; -import { HistogramPageSpeedScoresTooltip } from "@/Components/v2/monitors"; +import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors"; import { useTheme } from "@mui/material/styles"; import type { CheckSnapshot } from "@/Types/Check"; diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx b/client/src/Components/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx similarity index 97% rename from client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx rename to client/src/Components/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx index 1663ab3b5..37c172b4d 100644 --- a/client/src/Components/v2/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx +++ b/client/src/Components/monitors/charts/HistogramPageSpeedDetailsTooltip.tsx @@ -3,7 +3,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { formatDateWithTz } from "@/Utils/TimeUtils"; -import type { ConfigItem } from "@/Components/v2/monitors"; +import type { ConfigItem } from "@/Components/monitors"; import type { TooltipProps } from "recharts"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeedTooltip.tsx b/client/src/Components/monitors/charts/HistogramPageSpeedTooltip.tsx similarity index 100% rename from client/src/Components/v2/monitors/charts/HistogramPageSpeedTooltip.tsx rename to client/src/Components/monitors/charts/HistogramPageSpeedTooltip.tsx diff --git a/client/src/Components/v2/monitors/charts/HistogramStatus.tsx b/client/src/Components/monitors/charts/HistogramStatus.tsx similarity index 98% rename from client/src/Components/v2/monitors/charts/HistogramStatus.tsx rename to client/src/Components/monitors/charts/HistogramStatus.tsx index d3aa62a69..44f6bea97 100644 --- a/client/src/Components/v2/monitors/charts/HistogramStatus.tsx +++ b/client/src/Components/monitors/charts/HistogramStatus.tsx @@ -1,6 +1,6 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { BaseChart } from "@/Components/v2/design-elements"; +import { BaseChart } from "@/Components/design-elements"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import { formatDateWithTz } from "@/Utils/TimeUtils"; diff --git a/client/src/Components/v2/monitors/charts/PiePageSpeed.tsx b/client/src/Components/monitors/charts/PiePageSpeed.tsx similarity index 98% rename from client/src/Components/v2/monitors/charts/PiePageSpeed.tsx rename to client/src/Components/monitors/charts/PiePageSpeed.tsx index 205ec9a96..397262cd9 100644 --- a/client/src/Components/v2/monitors/charts/PiePageSpeed.tsx +++ b/client/src/Components/monitors/charts/PiePageSpeed.tsx @@ -1,4 +1,4 @@ -import { BaseChart } from "@/Components/v2/design-elements"; +import { BaseChart } from "@/Components/design-elements"; import { FileText } from "lucide-react"; import type { CheckAudits, CheckSnapshot } from "@/Types/Check"; import { Pie, PieChart, ResponsiveContainer, Label } from "recharts"; diff --git a/client/src/Components/v2/monitors/charts/PiePageSpeedLegend.tsx b/client/src/Components/monitors/charts/PiePageSpeedLegend.tsx similarity index 97% rename from client/src/Components/v2/monitors/charts/PiePageSpeedLegend.tsx rename to client/src/Components/monitors/charts/PiePageSpeedLegend.tsx index b98b09b33..a3977e284 100644 --- a/client/src/Components/v2/monitors/charts/PiePageSpeedLegend.tsx +++ b/client/src/Components/monitors/charts/PiePageSpeedLegend.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import { BarChart3 } from "lucide-react"; -import { BaseChart } from "@/Components/v2/design-elements"; +import { BaseChart } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; import type { CheckSnapshot } from "@/Types/Check"; diff --git a/client/src/Components/v2/monitors/charts/RadialAvgResponse.tsx b/client/src/Components/monitors/charts/RadialAvgResponse.tsx similarity index 97% rename from client/src/Components/v2/monitors/charts/RadialAvgResponse.tsx rename to client/src/Components/monitors/charts/RadialAvgResponse.tsx index b54bfc764..fcfaa426b 100644 --- a/client/src/Components/v2/monitors/charts/RadialAvgResponse.tsx +++ b/client/src/Components/monitors/charts/RadialAvgResponse.tsx @@ -1,4 +1,4 @@ -import { BaseChart } from "@/Components/v2/design-elements"; +import { BaseChart } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Gauge } from "lucide-react"; diff --git a/client/src/Components/v2/monitors/index.tsx b/client/src/Components/monitors/index.tsx similarity index 100% rename from client/src/Components/v2/monitors/index.tsx rename to client/src/Components/monitors/index.tsx diff --git a/client/src/Components/v2/routing/RouteProtected.tsx b/client/src/Components/routing/RouteProtected.tsx similarity index 100% rename from client/src/Components/v2/routing/RouteProtected.tsx rename to client/src/Components/routing/RouteProtected.tsx diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/sidebar/Authfooter.tsx similarity index 98% rename from client/src/Components/v2/sidebar/Authfooter.tsx rename to client/src/Components/sidebar/Authfooter.tsx index 4724ed97d..3edac75dd 100644 --- a/client/src/Components/v2/sidebar/Authfooter.tsx +++ b/client/src/Components/sidebar/Authfooter.tsx @@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next"; import { useState } from "react"; import { useNavigate } from "react-router"; import { MoreVertical, LogOut } from "lucide-react"; -import { Avatar, Icon } from "@/Components/v2/design-elements"; +import { Avatar, Icon } from "@/Components/design-elements"; import { clearAuthState } from "@/Features/Auth/authSlice.js"; import type { RootState } from "@/Types/state.js"; diff --git a/client/src/Components/v2/sidebar/Logo.tsx b/client/src/Components/sidebar/Logo.tsx similarity index 100% rename from client/src/Components/v2/sidebar/Logo.tsx rename to client/src/Components/sidebar/Logo.tsx diff --git a/client/src/Components/v2/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx similarity index 97% rename from client/src/Components/v2/sidebar/Menu.tsx rename to client/src/Components/sidebar/Menu.tsx index 918a82858..9d005e547 100644 --- a/client/src/Components/v2/sidebar/Menu.tsx +++ b/client/src/Components/sidebar/Menu.tsx @@ -1,4 +1,4 @@ -import { Icon } from "@/Components/v2/design-elements"; +import { Icon } from "@/Components/design-elements"; import { Globe, diff --git a/client/src/Components/v2/sidebar/NavItem.tsx b/client/src/Components/sidebar/NavItem.tsx similarity index 100% rename from client/src/Components/v2/sidebar/NavItem.tsx rename to client/src/Components/sidebar/NavItem.tsx diff --git a/client/src/Components/v2/sidebar/StarPrompt.tsx b/client/src/Components/sidebar/StarPrompt.tsx similarity index 97% rename from client/src/Components/v2/sidebar/StarPrompt.tsx rename to client/src/Components/sidebar/StarPrompt.tsx index 5319f6bcc..9897ca91e 100644 --- a/client/src/Components/v2/sidebar/StarPrompt.tsx +++ b/client/src/Components/sidebar/StarPrompt.tsx @@ -1,5 +1,5 @@ import { Typography, IconButton, Stack, Box } from "@mui/material"; -import { Icon } from "@/Components/v2/design-elements"; +import { Icon } from "@/Components/design-elements"; import { X } from "lucide-react"; import { useTheme } from "@mui/material"; diff --git a/client/src/Components/v2/sidebar/index.tsx b/client/src/Components/sidebar/index.tsx similarity index 92% rename from client/src/Components/v2/sidebar/index.tsx rename to client/src/Components/sidebar/index.tsx index 6469e56f0..dcf02e45f 100644 --- a/client/src/Components/v2/sidebar/index.tsx +++ b/client/src/Components/sidebar/index.tsx @@ -4,11 +4,11 @@ import Stack from "@mui/material/Stack"; import List from "@mui/material/List"; import Divider from "@mui/material/Divider"; import { useSidebar } from "@/Hooks/useSidebar.js"; -import { Logo } from "@/Components/v2/sidebar/Logo"; -import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/v2/sidebar/Menu"; -import { NavItem } from "@/Components/v2/sidebar/NavItem"; -import { StarPrompt } from "@/Components/v2/sidebar/StarPrompt"; -import { AuthFooter } from "@/Components/v2/sidebar/Authfooter"; +import { Logo } from "@/Components/sidebar/Logo"; +import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu"; +import { NavItem } from "@/Components/sidebar/NavItem"; +import { StarPrompt } from "@/Components/sidebar/StarPrompt"; +import { AuthFooter } from "@/Components/sidebar/Authfooter"; import { useNavigate, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Components/v2/common/index.ts b/client/src/Components/v2/common/index.ts deleted file mode 100644 index 2ea3bf3f7..000000000 --- a/client/src/Components/v2/common/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "../common/charts/HistogramResponseTime"; -export * from "../common/charts/HeatmapResponseTime"; -export * from "../common/charts/HeatmapResponseTimeTooltip"; -export * from "../common/controls/HeaderCreate"; -export * from "../common/controls/HeaderTimeRange"; diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index 483181c35..a3ddebaba 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -1,12 +1,12 @@ import { Trash2 } from "lucide-react"; -import { TextField, Button, Autocomplete } from "@/Components/v2/inputs"; +import { TextField, Button, Autocomplete } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import Divider from "@mui/material/Divider"; import FormHelperText from "@mui/material/FormHelperText"; import { useForm, Controller } from "react-hook-form"; -import { ConfigBox, BasePage } from "@/Components/v2/design-elements"; +import { ConfigBox, BasePage } from "@/Components/design-elements"; import { UserRoles } from "@/Types/User"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index 85e13fb7e..d09a9eeea 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,6 +1,6 @@ import { Stack } from "@mui/material"; -import { ConfigBox } from "@/Components/v2/design-elements"; -import { TextField, Button } from "@/Components/v2/inputs"; +import { ConfigBox } from "@/Components/design-elements"; +import { TextField, Button } from "@/Components/inputs"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index dd1960e6f..01e4f14b1 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,7 +1,7 @@ import { Stack } from "@mui/material"; -import { ConfigBox } from "@/Components/v2/design-elements"; -import { TextField, Button } from "@/Components/v2/inputs"; -import { ImageUpload } from "@/Components/v2/inputs"; +import { ConfigBox } from "@/Components/design-elements"; +import { TextField, Button } from "@/Components/inputs"; +import { ImageUpload } from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index 7a108680d..14765eb79 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -4,7 +4,7 @@ import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Dialog, TextField, Select } from "@/Components/v2/inputs"; +import { Dialog, TextField, Select } from "@/Components/inputs"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; import type { UserRole, User } from "@/Types/User"; diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 57f000170..51fea25b6 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import MenuItem from "@mui/material/MenuItem"; -import { Button, Select } from "@/Components/v2/inputs"; -import { Icon } from "@/Components/v2/design-elements"; +import { Button, Select } from "@/Components/inputs"; +import { Icon } from "@/Components/design-elements"; import { UserPlus, Mail } from "lucide-react"; import { UserRoles } from "@/Types/User"; diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index 02e5ba29c..e7c42a1ac 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -4,7 +4,7 @@ import { useTheme, FormHelperText, Typography } from "@mui/material"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useForm, Controller } from "react-hook-form"; -import { Dialog, TextField, Select, Button } from "@/Components/v2/inputs"; +import { Dialog, TextField, Select, Button } from "@/Components/inputs"; import type { UserRole } from "@/Types/User"; import { useInviteForm } from "@/Hooks/useInviteForm"; import type { InviteFormData } from "@/Validation/invite"; diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx index 2583d1c39..ab8dcc3b4 100644 --- a/client/src/Pages/Account/components/TeamTable.tsx +++ b/client/src/Pages/Account/components/TeamTable.tsx @@ -1,8 +1,8 @@ import Typography from "@mui/material/Typography"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Table } from "@/Components/v2/design-elements"; -import type { Header } from "@/Components/v2/design-elements/Table"; +import { Table } from "@/Components/design-elements"; +import type { Header } from "@/Components/design-elements/Table"; import { useIsSuperAdmin } from "@/Hooks/useIsAdmin"; import type { User } from "@/Types/User"; diff --git a/client/src/Pages/Account/index.tsx b/client/src/Pages/Account/index.tsx index 4a57b539a..fbe8db64b 100644 --- a/client/src/Pages/Account/index.tsx +++ b/client/src/Pages/Account/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements"; +import { BasePage, Tabs, Tab } from "@/Components/design-elements"; import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { TabProfile } from "./TabProfile"; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 8611499d9..ec95a0ce3 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,5 +1,5 @@ -import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; -import { Button, TextField } from "@/Components/v2/inputs"; +import { BaseAuthPage, TextLink } from "@/Components/design-elements"; +import { Button, TextField } from "@/Components/inputs"; import { useTranslation } from "react-i18next"; import { useForm, Controller } from "react-hook-form"; diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index 5add53077..d0eeaef06 100644 --- a/client/src/Pages/Auth/Recovery/index.tsx +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -1,5 +1,5 @@ -import { Button, TextField } from "@/Components/v2/inputs"; -import { BaseAuthPage, TextLink } from "@/Components/v2/design-elements"; +import { Button, TextField } from "@/Components/inputs"; +import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 3c8a4e4bc..a2a439023 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -1,5 +1,5 @@ -import { BaseAuthPage } from "@/Components/v2/design-elements"; -import { Button, TextField } from "@/Components/v2/inputs"; +import { BaseAuthPage } from "@/Components/design-elements"; +import { Button, TextField } from "@/Components/inputs"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRegisterForm } from "@/Hooks/useRegisterForm"; diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx index 2e99be254..5d70b4811 100644 --- a/client/src/Pages/Auth/SetNewPassword/index.tsx +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -1,9 +1,5 @@ -import { - BaseAuthPage, - BulletPointCheck, - TextLink, -} from "@/Components/v2/design-elements"; -import { Button, TextField } from "@/Components/v2/inputs"; +import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements"; +import { Button, TextField } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Auth/components/HeaderAuthControls.tsx b/client/src/Pages/Auth/components/HeaderAuthControls.tsx index a62048372..c986362f8 100644 --- a/client/src/Pages/Auth/components/HeaderAuthControls.tsx +++ b/client/src/Pages/Auth/components/HeaderAuthControls.tsx @@ -1,7 +1,7 @@ import Stack, { type StackProps } from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Logo from "@/assets/icons/checkmate-icon.svg?react"; -import { LanguageSelector, SwitchTheme } from "@/Components/v2/inputs"; +import { LanguageSelector, SwitchTheme } from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index bb1920ab2..4a40d993b 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -1,11 +1,6 @@ -import { - Table, - Pagination, - ValueLabel, - StatusLabel, -} from "@/Components/v2/design-elements"; +import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements"; import Box from "@mui/material/Box"; -import type { Header } from "@/Components/v2/design-elements/Table"; +import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx index fa2adfae9..108547f3e 100644 --- a/client/src/Pages/Checks/index.tsx +++ b/client/src/Pages/Checks/index.tsx @@ -4,9 +4,9 @@ import { TotalChecksBox, UpChecksBox, DownChecksBox, -} from "@/Components/v2/design-elements"; -import { HeaderTimeRange } from "@/Components/v2/common"; -import { Select } from "@/Components/v2/inputs"; +} from "@/Components/design-elements"; +import { HeaderTimeRange } from "@/Components/common"; +import { Select } from "@/Components/inputs"; import { ChecksTable } from "./Components/ChecksTable"; import { MenuItem } from "@mui/material"; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 1c111e23d..5012c1154 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -14,9 +14,9 @@ import Link from "@mui/material/Link"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import { Trash2 } from "lucide-react"; -import { HeaderDeleteControls } from "@/Components/v2/monitors"; +import { HeaderDeleteControls } from "@/Components/monitors"; -import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { BasePage, ConfigBox } from "@/Components/design-elements"; import { RadioWithDescription, Button, @@ -26,7 +26,7 @@ import { SwitchComponent as Switch, SliderWithLabel, Dialog, -} from "@/Components/v2/inputs"; +} from "@/Components/inputs"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor"; @@ -620,7 +620,7 @@ const CreateMonitorPage = () => { name="notifications" control={control} render={({ field }) => { - // Map notifications to have 'name' property for v2 Autocomplete + // Map notifications to have 'name' property for Autocomplete const notificationOptions = (notifications ?? []).map((n) => ({ ...n, name: n.notificationName, diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index 577c777de..70f3bc6e3 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Grid from "@mui/material/Grid"; import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; -import { BaseBox, ValueLabel } from "@/Components/v2/design-elements"; +import { BaseBox, ValueLabel } from "@/Components/design-elements"; import { useTranslation } from "react-i18next"; import type { Incident } from "@/Types/Incident"; diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx index c0e129dde..938455808 100644 --- a/client/src/Pages/Incidents/Components/CardSummary.tsx +++ b/client/src/Pages/Incidents/Components/CardSummary.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; import Grid from "@mui/material/Grid"; -import { BaseBox, ValueLabel } from "@/Components/v2/design-elements"; +import { BaseBox, ValueLabel } from "@/Components/design-elements"; import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react"; import Box from "@mui/material/Box"; diff --git a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx index 186942cf0..a0f4eb15a 100644 --- a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx +++ b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { Select, Button } from "@/Components/v2/inputs"; +import { Select, Button } from "@/Components/inputs"; import MenuItem from "@mui/material/MenuItem"; import useMediaQuery from "@mui/material/useMediaQuery"; import { Typography, useTheme } from "@mui/material"; diff --git a/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx b/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx index daef58f68..628f6cb29 100644 --- a/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx +++ b/client/src/Pages/Incidents/Components/DialogIncidentDetails.tsx @@ -1,4 +1,4 @@ -import { Dialog } from "@/Components/v2/inputs"; +import { Dialog } from "@/Components/inputs"; import { CardDetails } from "@/Pages/Incidents/Components/CardDetails"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Incidents/Components/DialogResolution.tsx b/client/src/Pages/Incidents/Components/DialogResolution.tsx index a102b1de3..99dd898dd 100644 --- a/client/src/Pages/Incidents/Components/DialogResolution.tsx +++ b/client/src/Pages/Incidents/Components/DialogResolution.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import Box from "@mui/material/Box"; -import { Dialog, TextField } from "@/Components/v2/inputs"; +import { Dialog, TextField } from "@/Components/inputs"; import { usePut } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index a9b4bc104..bb1759dcd 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -1,12 +1,12 @@ -import { Table } from "@/Components/v2/design-elements"; -import { Pagination } from "@/Components/v2/design-elements/Table"; -import type { Header } from "@/Components/v2/design-elements/Table"; -import { ValueLabel } from "@/Components/v2/design-elements"; -import type { ValueType } from "@/Components/v2/design-elements/StatusLabel"; -import { ActionsMenu } from "@/Components/v2/actions-menu"; +import { Table } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; +import type { Header } from "@/Components/design-elements/Table"; +import { ValueLabel } from "@/Components/design-elements"; +import type { ValueType } from "@/Components/design-elements/StatusLabel"; +import { ActionsMenu } from "@/Components/actions-menu"; import type { Incident } from "@/Types/Incident"; import type { Monitor } from "@/Types/Monitor"; -import type { ActionMenuItem } from "@/Components/v2/actions-menu"; +import type { ActionMenuItem } from "@/Components/actions-menu"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { formatDateWithTz } from "@/Utils/TimeUtils"; diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 1f332224b..9003f6956 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -1,4 +1,4 @@ -import { BasePage } from "@/Components/v2/design-elements"; +import { BasePage } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; @@ -11,7 +11,7 @@ import { import { IncidentsTable } from "@/Pages/Incidents/Components/IncidentTable"; import { DialogResolution } from "@/Pages/Incidents/Components/DialogResolution"; import { DialogIncidentDetails } from "@/Pages/Incidents/Components/DialogIncidentDetails"; -import { HeaderTimeRange } from "@/Components/v2/common"; +import { HeaderTimeRange } from "@/Components/common"; import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter"; import { useGet } from "@/Hooks/UseApi"; diff --git a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx index e3752de95..4309feca7 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx @@ -1,5 +1,5 @@ import Grid from "@mui/material/Grid"; -import { HistogramInfrastructure } from "@/Components/v2/monitors"; +import { HistogramInfrastructure } from "@/Components/monitors"; import { useTranslation } from "react-i18next"; import type { HardwareCheckStats } from "@/Types/Monitor"; diff --git a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx index 535bdc0fd..54e3cc1ed 100644 --- a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx @@ -1,5 +1,5 @@ import Grid from "@mui/material/Grid"; -import { HistogramInfrastructure } from "@/Components/v2/monitors"; +import { HistogramInfrastructure } from "@/Components/monitors"; import { useTranslation } from "react-i18next"; import type { HardwareCheckStats } from "@/Types/Monitor"; diff --git a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx index dded8ecf5..04c87ca95 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { DetailGauge } from "@/Components/v2/design-elements"; +import { DetailGauge } from "@/Components/design-elements"; import prettyBytes from "pretty-bytes"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx index dffa6a175..ebfdad569 100644 --- a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { StatBox } from "@/Components/v2/design-elements"; +import { StatBox } from "@/Components/design-elements"; import prettyBytes from "pretty-bytes"; import { useTheme } from "@mui/material"; diff --git a/client/src/Pages/Infrastructure/Details/index.tsx b/client/src/Pages/Infrastructure/Details/index.tsx index f27979a4f..202887ad5 100644 --- a/client/src/Pages/Infrastructure/Details/index.tsx +++ b/client/src/Pages/Infrastructure/Details/index.tsx @@ -1,6 +1,6 @@ -import { BasePage, Tab, Tabs } from "@/Components/v2/design-elements"; -import { HeaderTimeRange } from "@/Components/v2/common"; -import { MonitorStatBoxes, HeaderMonitorControls } from "@/Components/v2/monitors"; +import { BasePage, Tab, Tabs } from "@/Components/design-elements"; +import { HeaderTimeRange } from "@/Components/common"; +import { MonitorStatBoxes, HeaderMonitorControls } from "@/Components/monitors"; import { TabNetwork } from "@/Pages/Infrastructure/Details/Components/TabNetwork"; import { TabOverview } from "@/Pages/Infrastructure/Details/Components/TabOverview"; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index b2bcadc1a..994ddcea1 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -1,8 +1,8 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { Table, Pagination, StatusLabel, Gauge } from "@/Components/v2/design-elements"; -import type { Header } from "@/Components/v2/design-elements/Table"; -import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; +import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements"; +import type { Header } from "@/Components/design-elements/Table"; +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import { ArrowUp, ArrowDown } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 8d1ef1a3a..bdf477613 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -1,9 +1,9 @@ import Stack from "@mui/material/Stack"; import useMediaQuery from "@mui/material/useMediaQuery"; -import { MonitorBasePageWithStates } from "@/Components/v2/design-elements"; -import { HeaderCreate } from "@/Components/v2/common"; -import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/v2/monitors"; -import { TextField, Dialog } from "@/Components/v2/inputs"; +import { MonitorBasePageWithStates } from "@/Components/design-elements"; +import { HeaderCreate } from "@/Components/common"; +import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors"; +import { TextField, Dialog } from "@/Components/inputs"; import { useGet, useDelete } from "@/Hooks/UseApi"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; diff --git a/client/src/Pages/Logs/TabLogs.tsx b/client/src/Pages/Logs/TabLogs.tsx index a44de73a4..a05726b42 100644 --- a/client/src/Pages/Logs/TabLogs.tsx +++ b/client/src/Pages/Logs/TabLogs.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import { useSelector } from "react-redux"; -import { Select } from "@/Components/v2/inputs"; +import { Select } from "@/Components/inputs"; import { TableLogs } from "@/Pages/Logs/components/TableLogs"; import { useTheme } from "@mui/material"; diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index be0013368..06ce8ed0c 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { useGet, usePost } from "@/Hooks/UseApi"; import type { QueueData } from "@/Types/Queue"; import { Metrics } from "@/Pages/Logs/components/Metrics"; -import { Button } from "@/Components/v2/inputs"; +import { Button } from "@/Components/inputs"; export const TabQueue = () => { const theme = useTheme(); diff --git a/client/src/Pages/Logs/components/Metrics.tsx b/client/src/Pages/Logs/components/Metrics.tsx index 8f96ec5ac..9705b9ecd 100644 --- a/client/src/Pages/Logs/components/Metrics.tsx +++ b/client/src/Pages/Logs/components/Metrics.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { StatBox } from "@/Components/v2/design-elements"; +import { StatBox } from "@/Components/design-elements"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Logs/components/StatGauges.tsx b/client/src/Pages/Logs/components/StatGauges.tsx index 34a148948..65c443831 100644 --- a/client/src/Pages/Logs/components/StatGauges.tsx +++ b/client/src/Pages/Logs/components/StatGauges.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { DetailGauge } from "@/Components/v2/design-elements"; +import { DetailGauge } from "@/Components/design-elements"; import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils"; import prettyBytes from "pretty-bytes"; diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 429330873..7bf343de3 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -1,5 +1,5 @@ import Stack from "@mui/material/Stack"; -import { StatBox } from "@/Components/v2/design-elements"; +import { StatBox } from "@/Components/design-elements"; import prettyBytes from "pretty-bytes"; import prettyMilliseconds from "pretty-ms"; diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx index e9608df9a..660a5df4f 100644 --- a/client/src/Pages/Logs/components/TableJobs.tsx +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -1,11 +1,11 @@ -import { Table } from "@/Components/v2/design-elements"; +import { Table } from "@/Components/design-elements"; import { Typography, useTheme } from "@mui/material"; import prettyMilliseconds from "pretty-ms"; import { formatTimestamp } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; -import type { Header } from "@/Components/v2/design-elements"; +import type { Header } from "@/Components/design-elements"; type QueueJobWithId = QueueJobSummary & { id: string | number }; diff --git a/client/src/Pages/Logs/components/TableLogs.tsx b/client/src/Pages/Logs/components/TableLogs.tsx index dace0db43..144d6056c 100644 --- a/client/src/Pages/Logs/components/TableLogs.tsx +++ b/client/src/Pages/Logs/components/TableLogs.tsx @@ -1,9 +1,9 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { Table } from "@/Components/v2/design-elements"; -import { Pagination } from "@/Components/v2/design-elements/Table"; +import { Table } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; import { useTheme } from "@mui/material"; -import type { Header } from "@/Components/v2/design-elements/Table"; +import type { Header } from "@/Components/design-elements/Table"; import type { Log, LogLevel } from "@/Types/Log"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx index ab30b84eb..101ac11af 100644 --- a/client/src/Pages/Logs/index.tsx +++ b/client/src/Pages/Logs/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements"; +import { BasePage, Tabs, Tab } from "@/Components/design-elements"; import { TabLogs } from "@/Pages/Logs/TabLogs"; import { TabQueue } from "@/Pages/Logs/TabQueue"; import { TabDiagnostics } from "@/Pages/Logs/TabDiagnostics"; diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx index 876969d3d..6dadb5a9c 100644 --- a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -1,13 +1,13 @@ import Typography from "@mui/material/Typography"; -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 { Table, ValueLabel } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; +import { ActionsMenu } from "@/Components/actions-menu"; +import { DialogInput } from "@/Components/inputs/Dialog"; import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; -import type { Header } from "@/Components/v2/design-elements/Table"; -import type { ActionMenuItem } from "@/Components/v2/actions-menu"; +import type { Header } from "@/Components/design-elements/Table"; +import type { ActionMenuItem } from "@/Components/actions-menu"; import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 370694a94..b6666d4e2 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -2,7 +2,7 @@ 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 { BasePage, ConfigBox } from "@/Components/design-elements"; import { TextField, Select, @@ -10,7 +10,7 @@ import { TimePicker, Button, Autocomplete, -} from "@/Components/v2/inputs"; +} from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/Maintenance/index.tsx b/client/src/Pages/Maintenance/index.tsx index ad8b8dac7..6a9c6f12b 100644 --- a/client/src/Pages/Maintenance/index.tsx +++ b/client/src/Pages/Maintenance/index.tsx @@ -1,5 +1,5 @@ -import { BasePageWithStates } from "@/Components/v2/design-elements"; -import { HeaderCreate } from "@/Components/v2/common"; +import { BasePageWithStates } from "@/Components/design-elements"; +import { HeaderCreate } from "@/Components/common"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/UseApi"; import { MaintenanceWindowTable } from "./MaintenanceWindowTable"; diff --git a/client/src/Pages/NotFound/index.tsx b/client/src/Pages/NotFound/index.tsx index 868a1573a..ce974a2e1 100644 --- a/client/src/Pages/NotFound/index.tsx +++ b/client/src/Pages/NotFound/index.tsx @@ -1,5 +1,5 @@ import NotFoundSvg from "@/assets/Images/sushi_404.svg"; -import { Button } from "@/Components/v2/inputs"; +import { Button } from "@/Components/inputs"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx index 2885a34bb..d103458c3 100644 --- a/client/src/Pages/Notifications/components/NotificationsTable.tsx +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -1,7 +1,7 @@ -import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import Typography from "@mui/material/Typography"; -import type { Header } from "@/Components/v2/design-elements/Table"; -import { Table } from "@/Components/v2/design-elements"; +import type { Header } from "@/Components/design-elements/Table"; +import { Table } from "@/Components/design-elements"; import type { Notification } from "@/Types/Notification"; import { useNavigate } from "react-router"; diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 30cfd4c80..7aa7bc831 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -1,5 +1,5 @@ -import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; -import { TextField, Select, Button } from "@/Components/v2/inputs"; +import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { TextField, Select, Button } from "@/Components/inputs"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Stack from "@mui/material/Stack"; diff --git a/client/src/Pages/Notifications/index.tsx b/client/src/Pages/Notifications/index.tsx index 37c8cbbed..1e4091461 100644 --- a/client/src/Pages/Notifications/index.tsx +++ b/client/src/Pages/Notifications/index.tsx @@ -1,7 +1,7 @@ -import { BasePageWithStates } from "@/Components/v2/design-elements"; +import { BasePageWithStates } from "@/Components/design-elements"; import { NotificationsTable } from "@/Pages/Notifications/components/NotificationsTable"; -import { Dialog } from "@/Components/v2/inputs"; -import { HeaderCreate } from "@/Components/v2/common"; +import { Dialog } from "@/Components/inputs"; +import { HeaderCreate } from "@/Components/common"; import { useState } from "react"; import { useGet, useDelete } from "@/Hooks/UseApi"; diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx index 3be3a245b..f67ce6730 100644 --- a/client/src/Pages/PageSpeed/Details/index.tsx +++ b/client/src/Pages/PageSpeed/Details/index.tsx @@ -1,4 +1,4 @@ -import { BasePage } from "@/Components/v2/design-elements"; +import { BasePage } from "@/Components/design-elements"; import type { PageSpeedDetailsResponse } from "@/Types/Monitor"; import Stack from "@mui/material/Stack"; import { @@ -7,7 +7,7 @@ import { PiePageSpeedLegend, MonitorStatBoxes, HeaderMonitorControls, -} from "@/Components/v2/monitors"; +} from "@/Components/monitors"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useGet } from "@/Hooks/UseApi"; diff --git a/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx index 1dc9ce9df..a812883e0 100644 --- a/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx +++ b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx @@ -1,11 +1,11 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { Table, Pagination, StatusLabel } from "@/Components/v2/design-elements"; -import { HistogramPageSpeed } from "@/Components/v2/monitors"; +import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { HistogramPageSpeed } from "@/Components/monitors"; import { ArrowDown, ArrowUp } from "lucide-react"; -import type { Header } from "@/Components/v2/design-elements/Table"; -import { ActionsMenu } from "@/Components/v2/actions-menu"; +import type { Header } from "@/Components/design-elements/Table"; +import { ActionsMenu } from "@/Components/actions-menu"; import { useTranslation } from "react-i18next"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -14,7 +14,7 @@ import { useNavigate } from "react-router-dom"; import { usePatch } from "@/Hooks/UseApi"; import type { Monitor, MonitorWithChecks } from "@/Types/Monitor"; -import type { ActionMenuItem } from "@/Components/v2/actions-menu"; +import type { ActionMenuItem } from "@/Components/actions-menu"; export const PageSpeedMonitorsTable = ({ monitors, diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx index f958b5619..cfdbe6c4e 100644 --- a/client/src/Pages/PageSpeed/Monitors/index.tsx +++ b/client/src/Pages/PageSpeed/Monitors/index.tsx @@ -1,9 +1,9 @@ import { MonitorBasePageWithStates, PageSpeedKeyPriorityFallback, -} from "@/Components/v2/design-elements"; -import { Dialog } from "@/Components/v2/inputs"; -import { HeaderCreate } from "@/Components/v2/common"; +} from "@/Components/design-elements"; +import { Dialog } from "@/Components/inputs"; +import { HeaderCreate } from "@/Components/common"; import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable"; import type { Monitor } from "@/Types/Monitor"; @@ -13,7 +13,7 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useGet, useDelete } from "@/Hooks/UseApi"; import type { MonitorsWithChecksResponse } from "@/Types/Monitor"; import type { AppSettingsResponse } from "@/Types/Settings"; -import { HeaderMonitorsSummary } from "@/Components/v2/monitors"; +import { HeaderMonitorsSummary } from "@/Components/monitors"; const PageSpeedMonitorsPage = () => { const { t } = useTranslation(); diff --git a/client/src/Pages/Settings/DummyChart.tsx b/client/src/Pages/Settings/DummyChart.tsx index df7cd5603..b354ee9bb 100644 --- a/client/src/Pages/Settings/DummyChart.tsx +++ b/client/src/Pages/Settings/DummyChart.tsx @@ -1,7 +1,7 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; -import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common"; +import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common"; import type { CheckSnapshot } from "@/Types/Check"; interface DummyChartProps { diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 5e01e02f1..b9d3e1f3c 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,5 +1,5 @@ -import { BasePage, ConfigBox, TextLink } from "@/Components/v2/design-elements"; -import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/v2/inputs"; +import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements"; +import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs"; import { Stack, useTheme, MenuItem, Link, type SelectChangeEvent } from "@mui/material"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -13,7 +13,7 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData } from "@/Validation/settings"; import { useState } from "react"; import { Controller } from "react-hook-form"; -import { TextField, Button, FieldLabel } from "@/Components/v2/inputs"; +import { TextField, Button, FieldLabel } from "@/Components/inputs"; import { Box, Typography } from "@mui/material"; import { useDelete } from "@/Hooks/UseApi"; diff --git a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx index 99ae46cdd..407306f1b 100644 --- a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx +++ b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx @@ -1,6 +1,6 @@ import Stack from "@mui/material/Stack"; -import { Icon } from "@/Components/v2/design-elements"; -import { Button } from "@/Components/v2/inputs"; +import { Icon } from "@/Components/design-elements"; +import { Button } from "@/Components/inputs"; import { Trash } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 8b4394f14..b1b43d8bb 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { BasePage, ConfigBox } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; @@ -15,7 +15,7 @@ import { Checkbox, Dialog, ColorInput, -} from "@/Components/v2/inputs"; +} from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx index 0e8f444c9..2cc2395bb 100644 --- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx +++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx @@ -1,8 +1,8 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { Icon } from "@/Components/v2/design-elements"; -import { Button } from "@/Components/v2/inputs"; +import { Icon } from "@/Components/design-elements"; +import { Button } from "@/Components/inputs"; import { Settings, ExternalLink } from "lucide-react"; import { useTheme } from "@mui/material"; diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx index 35aad7a26..b94b20a80 100644 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx @@ -2,9 +2,9 @@ import Stack from "@mui/material/Stack"; import { useTranslation } from "react-i18next"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/v2/common"; -import { StatusLabel, BaseBox } from "@/Components/v2/design-elements"; -import { SwitchComponent } from "@/Components/v2/inputs"; +import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common"; +import { StatusLabel, BaseBox } from "@/Components/design-elements"; +import { SwitchComponent } from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index b1826b867..0ca629581 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, BaseFallback } from "@/Components/v2/design-elements"; +import { BasePage, BaseFallback } from "@/Components/design-elements"; import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar"; import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList"; import Typography from "@mui/material/Typography"; diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx index 192f9c101..978466bb4 100644 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -1,8 +1,8 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { Table, type Header, ValueLabel } from "@/Components/v2/design-elements"; -import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu"; +import { Table, type Header, ValueLabel } from "@/Components/design-elements"; +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import { ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx index 85b84e638..9468b7e12 100644 --- a/client/src/Pages/StatusPage/StatusPages/index.tsx +++ b/client/src/Pages/StatusPage/StatusPages/index.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; -import { BasePageWithStates } from "@/Components/v2/design-elements"; -import { Dialog } from "@/Components/v2/inputs"; +import { BasePageWithStates } from "@/Components/design-elements"; +import { Dialog } from "@/Components/inputs"; import { StatusPagesTable } from "./Components/StatusPagesTable"; import { useGet, useDelete } from "@/Hooks/UseApi"; import type { StatusPage } from "@/Types/StatusPage"; import { useTranslation } from "react-i18next"; -import { HeaderCreate } from "@/Components/v2/common"; +import { HeaderCreate } from "@/Components/common"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; const StatusPages = () => { diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index ceb8e1a28..26905e7cc 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -1,6 +1,6 @@ -import { Table, Pagination, StatusLabel } from "@/Components/v2/design-elements"; +import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; import Box from "@mui/material/Box"; -import type { Header } from "@/Components/v2/design-elements/"; +import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; import { useNavigate } from "react-router"; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index b7910a17c..23f9a2196 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -1,15 +1,15 @@ -import { BasePage } from "@/Components/v2/design-elements"; -import { HeaderTimeRange } from "@/Components/v2/common"; +import { BasePage } from "@/Components/design-elements"; +import { HeaderTimeRange } from "@/Components/common"; import Stack from "@mui/material/Stack"; import { HistogramStatus, RadialAvgResponse, HistogramDetails, HeaderMonitorControls, -} from "@/Components/v2/monitors"; +} from "@/Components/monitors"; import { TrendingUp, AlertTriangle } from "lucide-react"; import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; -import { MonitorStatBoxes } from "@/Components/v2/monitors"; +import { MonitorStatBoxes } from "@/Components/monitors"; import { useTheme } from "@mui/material/styles"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 75738e6ad..87db4246d 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -6,9 +6,9 @@ import { type Header, Pagination, StatusLabel, -} from "@/Components/v2/design-elements"; -import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common"; -import { ActionsMenu } from "@/Components/v2/actions-menu"; +} from "@/Components/design-elements"; +import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common"; +import { ActionsMenu } from "@/Components/actions-menu"; import { ArrowDown, ArrowUp } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -18,7 +18,7 @@ import { usePost } from "@/Hooks/UseApi"; import { useSelector } from "react-redux"; import type { Monitor } from "@/Types/Monitor"; -import type { ActionMenuItem } from "@/Components/v2/actions-menu"; +import type { ActionMenuItem } from "@/Components/actions-menu"; import type { RootState } from "@/Types/state"; export const MonitorTable = ({ diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 9e36ccc86..5c419738b 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -1,9 +1,9 @@ -import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/v2/monitors"; -import { MonitorBasePageWithStates } from "@/Components/v2/design-elements"; -import { TextField, Dialog } from "@/Components/v2/inputs"; +import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors"; +import { MonitorBasePageWithStates } from "@/Components/design-elements"; +import { TextField, Dialog } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable"; -import { HeaderCreate } from "@/Components/v2/common"; +import { HeaderCreate } from "@/Components/common"; import { useTranslation } from "react-i18next"; import useMediaQuery from "@mui/material/useMediaQuery"; diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index a1b5d907a..20869c47c 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -1,5 +1,5 @@ import { Navigate, Route, Routes as LibRoutes } from "react-router"; -import RootLayout from "@/Components/v2/layout/RootLayout"; +import RootLayout from "@/Components/layout/RootLayout"; import NotFound from "@/Pages/NotFound"; // Auth @@ -46,10 +46,7 @@ import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; import Logs from "@/Pages/Logs"; // Routing -import { - ProtectedRoute, - RoleProtectedRoute, -} from "@/Components/v2/routing/RouteProtected"; +import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected"; import CreateMonitor from "@/Pages/CreateMonitor"; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 3be1b30a1..a19316c62 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,6 +1,6 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; -import type { ValueType } from "@/Components/v2/design-elements/StatusLabel"; +import type { ValueType } from "@/Components/design-elements/StatusLabel"; export const getMonitorPath = (type: MonitorType): string => { const pathMap: Record = { diff --git a/client/src/main.tsx b/client/src/main.tsx index f075886d3..1f5d4d2ef 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -5,7 +5,7 @@ import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; import { persistor, store } from "@/store.js"; import { PersistGate } from "redux-persist/integration/react"; -import I18nLoader from "./Components/v2/i18nLoader"; +import I18nLoader from "./Components/i18nLoader/index.js"; import { initApiClient } from "./Utils/ApiClient.js"; initApiClient(store); From f9653888b1fbd93afdf3a8dee18453040de7ce97 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Sat, 21 Feb 2026 00:10:39 +0530 Subject: [PATCH 391/392] Fix: One Route --- server/src/config/routes.ts | 2 +- server/src/middleware/verifyStatusPageAccess.ts | 8 ++++---- server/src/routes/statusPageRoute.ts | 3 --- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index ba9158cd5..8707009a8 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -26,7 +26,7 @@ export const setupRoutes = (app: any, controllers: Record, services const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController); const queueRoutes = new QueueRoutes(controllers.queueController); const logRoutes = new LogRoutes(controllers.logController); - const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository); + const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository, verifyJWT); const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT, verifyStatusPageAccess); const notificationRoutes = new NotificationRoutes(controllers.notificationController); const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController, verifyJWT); diff --git a/server/src/middleware/verifyStatusPageAccess.ts b/server/src/middleware/verifyStatusPageAccess.ts index 6dbf9ba72..7f8d8baba 100644 --- a/server/src/middleware/verifyStatusPageAccess.ts +++ b/server/src/middleware/verifyStatusPageAccess.ts @@ -1,8 +1,8 @@ -import { NextFunction, Request, Response } from "express"; +import { NextFunction, Request, RequestHandler, Response } from "express"; import { IStatusPagesRepository } from "@/repositories/index.js"; import { AppError } from "@/utils/AppError.js"; -export const createVerifyStatusPageAccess = (statusPagesRepository: IStatusPagesRepository) => { +export const createVerifyStatusPageAccess = (statusPagesRepository: IStatusPagesRepository, verifyJWT: RequestHandler) => { return async (req: Request, res: Response, next: NextFunction) => { try { const url = req.params.url; @@ -11,9 +11,9 @@ export const createVerifyStatusPageAccess = (statusPagesRepository: IStatusPages } const statusPage = await statusPagesRepository.findByUrl(url); if (statusPage.isPublished) { - next(); // Published — proceed to controller (no JWT) + next(); // Published — no auth needed } else { - next("route"); // Unpublished — skip to next route (which has verifyJWT) + verifyJWT(req, res, next); // Unpublished — require JWT } } catch (error) { next(error); diff --git a/server/src/routes/statusPageRoute.ts b/server/src/routes/statusPageRoute.ts index ca917af2a..a53aa04d9 100755 --- a/server/src/routes/statusPageRoute.ts +++ b/server/src/routes/statusPageRoute.ts @@ -18,10 +18,7 @@ class StatusPageRoutes { this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); - // Route 1: published pages — no JWT this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); - // Route 2: unpublished pages — JWT required (reached via next("route")) - this.router.get("/:url", verifyJWT, this.statusPageController.getStatusPageByUrl); this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); } From e25669ac6a64cd133bc05bcc23570a38139027f7 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Sat, 21 Feb 2026 00:12:17 +0530 Subject: [PATCH 392/392] Fix: Client Redirect --- client/src/Utils/ApiClient.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/ApiClient.ts index 0fef9b4a0..b18f2dfbc 100644 --- a/client/src/Utils/ApiClient.ts +++ b/client/src/Utils/ApiClient.ts @@ -61,10 +61,7 @@ export const initApiClient = (store: StoreType): void => { } if (error.response?.status === 401) { - if ( - window.location.pathname !== "/login" && - !window.location.pathname.startsWith("/status/public") - ) { + if (window.location.pathname !== "/login") { window.location.href = "/login"; } }