Merge upstream/develop into fix/3120-data-leak

This commit is contained in:
Craig Lawson committed 2026-01-15 11:06:27 +00:00
commit f0a6fc4fdf
309 files changed
+8413 -4468

No files matched your search

@@ -0,0 +1,39 @@
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 (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.aboutSettings.title")}
</Typography>
</Box>
<Box>
<Typography component="h2">
{t("common.appName")} {__APP_VERSION__}
</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(6), opacity: 0.6 }}>
{t("settingsPage.aboutSettings.labelDevelopedBy")}
</Typography>
<Link
level="secondary"
url="https://github.com/bluewave-labs/checkmate"
label="https://github.com/bluewave-labs/checkmate"
/>
</Box>
</ConfigBox>
);
};
export default SettingsAbout;
@@ -0,0 +1,106 @@
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";
const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) => {
const { t } = useTranslation();
const theme = useTheme();
// Local state
const [isOpen, setIsOpen] = useState(false);
if (!isAdmin) {
return null;
}
return (
<>
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.demoMonitorsSettings.title")}
</Typography>
<Typography sx={HEADER_SX}>
{t("settingsPage.demoMonitorsSettings.description")}
</Typography>
</Box>
<Box>
<Button
variant="contained"
color="accent"
loading={isLoading}
onClick={() => {
const syntheticEvent = {
target: {
name: "demo",
},
};
handleChange(syntheticEvent);
}}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsPage.demoMonitorsSettings.buttonAddMonitors")}
</Button>
</Box>
</ConfigBox>
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.systemResetSettings.title")}
</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsPage.systemResetSettings.description")}
</Typography>
</Box>
<Box>
<Button
variant="contained"
color="error"
loading={isLoading}
onClick={() => setIsOpen(true)}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsPage.systemResetSettings.buttonRemoveAllMonitors")}
</Button>
</Box>
<Dialog
open={isOpen}
theme={theme}
title={t("settingsPage.systemResetSettings.dialogTitle")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t("settingsPage.systemResetSettings.dialogConfirm")}
onConfirm={() => {
const syntheticEvent = {
target: {
name: "deleteMonitors",
},
};
handleChange(syntheticEvent);
setIsOpen(false);
}}
isLoading={isLoading}
/>
</ConfigBox>
</>
);
};
SettingsDemoMonitors.propTypes = {
isAdmin: PropTypes.bool,
handleChange: PropTypes.func,
HEADER_SX: PropTypes.object,
};
export default SettingsDemoMonitors;
+330
View File
@@ -0,0 +1,330 @@
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 { useSendTestEmail } from "@/Hooks/useSendTestEmail.js";
import { createToast } from "@/Utils/toastUtils.jsx";
const SettingsEmail = ({
isAdmin,
HEADER_SX,
handleChange,
settingsData,
setSettingsData,
isEmailPasswordSet,
emailPasswordHasBeenReset,
setEmailPasswordHasBeenReset,
}) => {
// 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,
} = settingsData?.settings || {};
// Local state
const [password, setPassword] = useState("");
// Network
const [isSending, , sendTestEmail] = useSendTestEmail(); // Using empty placeholder for unused error variable
// 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
*/
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
) {
createToast({
body: t("settingsPage.emailSettings.toastEmailRequiredFieldsError"),
variant: "error",
});
return;
}
// Send test email with current form values
sendTestEmail(emailConfig);
};
if (!isAdmin) {
return null;
}
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.emailSettings.title")}
</Typography>
<Typography sx={HEADER_SX}>
{t("settingsPage.emailSettings.description")}
</Typography>
</Box>
<Box>
<Stack gap={theme.spacing(10)}>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelHost")}
name="systemEmailHost"
placeholder="smtp.gmail.com"
value={systemEmailHost}
onChange={handleChange}
/>
</Box>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelPort")}
name="systemEmailPort"
placeholder="425"
type="number"
value={systemEmailPort}
onChange={handleChange}
/>
</Box>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelUser")}
name="systemEmailUser"
placeholder={t("settingsPage.emailSettings.placeholderUser")}
value={systemEmailUser}
onChange={handleChange}
/>
</Box>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelAddress")}
name="systemEmailAddress"
placeholder="uptime@bluewavelabs.ca"
value={systemEmailAddress}
onChange={handleChange}
/>
</Box>
{(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && (
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelPassword")}
name="systemEmailPassword"
type="password"
placeholder="123 456 789 101112"
value={password}
onChange={handlePasswordChange}
endAdornment={<PasswordEndAdornment />}
/>
</Box>
)}
{isEmailPasswordSet === true && emailPasswordHasBeenReset === false && (
<Box>
<Typography>{t("settingsPage.emailSettings.labelPasswordSet")}</Typography>
<Button
onClick={() => {
setPassword("");
setSettingsData({
...settingsData,
settings: { ...settingsData.settings, systemEmailPassword: "" },
});
setEmailPasswordHasBeenReset(true);
}}
variant="contained"
color="error"
sx={{ mt: theme.spacing(4) }}
>
{t("reset")}
</Button>
</Box>
)}
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelTLSServername")}
name="systemEmailTLSServername"
placeholder="bluewavelabs.ca"
value={systemEmailTLSServername}
onChange={handleChange}
/>
</Box>
<Box>
<TextInput
label={t("settingsPage.emailSettings.labelConnectionHost")}
name="systemEmailConnectionHost"
placeholder="bluewavelabs.ca"
value={systemEmailConnectionHost}
onChange={handleChange}
/>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(4),
}}
>
{[
[
"settingsPage.emailSettings.labelSecure",
"systemEmailSecure",
systemEmailSecure,
],
[
"settingsPage.emailSettings.labelPool",
"systemEmailPool",
systemEmailPool,
],
[
"settingsPage.emailSettings.labelIgnoreTLS",
"systemEmailIgnoreTLS",
systemEmailIgnoreTLS,
],
[
"settingsPage.emailSettings.labelRequireTLS",
"systemEmailRequireTLS",
systemEmailRequireTLS,
],
[
"settingsPage.emailSettings.labelRejectUnauthorized",
"systemEmailRejectUnauthorized",
systemEmailRejectUnauthorized,
],
].map(([labelKey, name, value]) => (
<Box
key={name}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography>{t(labelKey)}</Typography>
<Switch
name={name}
checked={value}
onChange={handleChange}
/>
</Box>
))}
<TextLink
text={t("settingsPage.emailSettings.descriptionTransport")}
linkText={t("settingsPage.emailSettings.linkTransport")}
href="https://nodemailer.com/smtp"
target="_blank"
/>
<Box
component={"pre"}
sx={{
fontFamily: "monospace",
p: 2,
borderRadius: 1,
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: systemEmailHost,
port: systemEmailPort,
secure: systemEmailSecure,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: "<your_password>",
},
name: systemEmailConnectionHost || "localhost",
pool: systemEmailPool,
tls: {
rejectUnauthorized: systemEmailRejectUnauthorized,
ignoreTLS: systemEmailIgnoreTLS,
requireTLS: systemEmailRequireTLS,
servername: systemEmailTLSServername,
},
},
null,
2
)}
</code>
</Box>
</Box>
<Box>
{systemEmailHost &&
systemEmailPort &&
systemEmailAddress &&
systemEmailPassword && (
<Button
variant="contained"
color="accent"
loading={isSending}
onClick={handleSendTestEmail}
>
{t("settingsPage.emailSettings.buttonSendTestEmail")}
</Button>
)}
</Box>
</Stack>
</Box>
</ConfigBox>
);
};
SettingsEmail.propTypes = {
isAdmin: PropTypes.bool,
settingsData: PropTypes.object,
setSettingsData: PropTypes.func,
handleChange: PropTypes.func,
HEADER_SX: PropTypes.object,
isPasswordSet: PropTypes.bool,
};
export default SettingsEmail;
@@ -0,0 +1,65 @@
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";
const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) => {
const { t } = useTranslation();
const theme = useTheme();
// Local state
const [isOpen, setIsOpen] = useState(false);
if (!isAdmin) {
return null;
}
return (
<>
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
Export monitors to JSON
</Typography>
<Typography sx={HEADER_SX}>
Export your monitors data as a JSON file for backup or transfer.
</Typography>
</Box>
<Box>
<Button
variant="contained"
color="accent"
loading={isLoading}
onClick={() => {
const syntheticEvent = {
target: {
name: "export",
},
};
handleChange(syntheticEvent);
}}
sx={{ mt: theme.spacing(4) }}
>
Export Monitors to JSON
</Button>
</Box>
</ConfigBox>
</>
);
};
SettingsDemoMonitors.propTypes = {
isAdmin: PropTypes.bool,
handleChange: PropTypes.func,
HEADER_SX: PropTypes.object,
};
export default SettingsDemoMonitors;
@@ -0,0 +1,95 @@
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";
const SettingsGlobalThresholds = ({
isAdmin,
HEADING_SX,
settingsData,
setSettingsData,
}) => {
const { t } = useTranslation(); // For language translation
const theme = useTheme(); // MUI theme access
// Handles input change and updates parent state
const handleChange = (e, min, max) => {
const { name, value } = e.target;
const numValue = parseFloat(value);
const isValidNumber =
value === "" ||
(!isNaN(numValue) && isFinite(numValue) && numValue >= min && numValue <= max);
if (isValidNumber) {
setSettingsData((prev) => ({
...prev,
settings: {
...prev.settings,
globalThresholds: {
...prev.settings?.globalThresholds,
[name]: value,
},
},
}));
}
};
// Only render this section for admins
if (!isAdmin) return null;
return (
<ConfigBox>
{/* Header and description */}
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.globalThresholds.title", "Global Thresholds")}
</Typography>
<Typography sx={HEADING_SX}>
{t(
"settingsPage.globalThresholds.description",
"Configure global CPU, Memory, Disk, and Temperature thresholds."
)}
</Typography>
</Box>
{/* Threshold inputs */}
<Stack gap={theme.spacing(20)}>
{[
["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]) => (
<TextInput
key={name}
name={name}
label={label}
placeholder={`${min} - ${max}`}
type="number"
value={settingsData?.settings?.globalThresholds?.[name] || ""}
onChange={(e) => handleChange(e, min, max)}
/>
))}
</Stack>
</ConfigBox>
);
};
// Prop types
SettingsGlobalThresholds.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
settingsData: PropTypes.object,
setSettingsData: PropTypes.func,
};
export default SettingsGlobalThresholds;
@@ -0,0 +1,104 @@
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";
const SettingsPagespeed = ({
isAdmin,
HEADING_SX,
settingsData,
setSettingsData,
isApiKeySet,
apiKeyHasBeenReset,
setApiKeyHasBeenReset,
}) => {
const { t } = useTranslation();
const theme = useTheme();
// Local state
const [apiKey, setApiKey] = useState("");
// Handler
const handleChange = (e) => {
setApiKey(e.target.value);
setSettingsData({
...settingsData,
settings: { ...settingsData.settings, pagespeedApiKey: e.target.value },
});
};
if (!isAdmin) {
return null;
}
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.pageSpeedSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.pageSpeedSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
{(isApiKeySet === false || apiKeyHasBeenReset === true) && (
<TextInput
name="pagespeedApiKey"
label={t("settingsPage.pageSpeedSettings.labelApiKey")}
value={apiKey}
type={"password"}
onChange={handleChange}
optionalLabel="(Optional)"
endAdornment={<PasswordEndAdornment />}
/>
)}
{isApiKeySet === true && apiKeyHasBeenReset === false && (
<Box>
<Typography>{t("settingsPage.pageSpeedSettings.labelApiKeySet")}</Typography>
<Button
onClick={() => {
setApiKey("");
setSettingsData({
...settingsData,
settings: { ...settingsData.settings, pagespeedApiKey: "" },
});
setApiKeyHasBeenReset(true);
}}
variant="contained"
color="error"
sx={{ mt: theme.spacing(4) }}
>
{t("reset")}
</Button>
</Box>
)}
</Stack>
</ConfigBox>
);
};
SettingsPagespeed.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
settingsData: PropTypes.object,
setSettingsData: PropTypes.func,
isApiKeySet: PropTypes.bool,
setIsApiKeySet: PropTypes.func,
apiKeyHasBeenReset: PropTypes.bool,
setApiKeyHasBeenReset: PropTypes.func,
};
export default SettingsPagespeed;
@@ -0,0 +1,85 @@
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";
const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors }) => {
const theme = useTheme();
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
if (!isAdmin) {
return null;
}
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
sx={HEADING_SX}
>
{t("settingsPage.statsSettings.title")}
</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsPage.statsSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Box>
<Typography>
{t("settingsPage.statsSettings.clearAllStatsDescription")}
</Typography>
<Button
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsPage.statsSettings.clearAllStatsButton")}
</Button>
</Box>
</Stack>
<Dialog
open={isOpen}
theme={theme}
title={t("settingsPage.statsSettings.clearAllStatsDialogTitle")}
description={t("settingsPage.statsSettings.clearAllStatsDialogDescription")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t(
"settingsPage.statsSettings.clearAllStatsDialogConfirm"
)}
onConfirm={() => {
const syntheticEvent = {
target: {
name: "deleteStats",
},
};
handleChange(syntheticEvent);
setIsOpen(false);
}}
isLoading={false}
/>
</ConfigBox>
);
};
SettingsStats.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
settingsData: PropTypes.object,
errors: PropTypes.object,
};
export default SettingsStats;
@@ -0,0 +1,75 @@
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 { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
const theme = useTheme();
const { t } = useTranslation();
const [rawInput, setRawInput] = useState("");
const selectedTimezone = useMemo(
() => timezones.find((tz) => tz._id === timezone) ?? null,
[timezone]
);
const handleTimezoneChange = useCallback(
(newValue) => {
setRawInput("");
handleChange({
target: {
name: "timezone",
value: newValue?._id ?? "",
},
});
},
[handleChange]
);
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.timezoneSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
<Typography component="span">
{t("settingsPage.timezoneSettings.description")}
</Typography>
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Search
id="timezone"
label={t("settingsDisplayTimezone")}
options={timezones}
filteredBy="name"
value={selectedTimezone}
inputValue={rawInput}
handleInputChange={(val) => setRawInput(val)}
handleChange={handleTimezoneChange}
isAdorned={true}
unit="timezone"
/>
</Stack>
</ConfigBox>
);
};
SettingsTimeZone.propTypes = {
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
timezone: PropTypes.string,
};
export default SettingsTimeZone;
+59
View File
@@ -0,0 +1,59 @@
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";
// Utils
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
const SettingsUI = ({ HEADING_SX, handleChange, mode, language }) => {
const { t, i18n } = useTranslation();
const theme = useTheme();
const languages = Object.keys(i18n.options.resources || {});
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.uiSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.uiSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
name="mode"
label={t("settingsPage.uiSettings.labelTheme")}
value={mode}
onChange={handleChange}
items={[
{ _id: "light", name: "Light" },
{ _id: "dark", name: "Dark" },
]}
></Select>
<Select
name="language"
label={t("settingsPage.uiSettings.labelLanguage")}
value={language}
onChange={handleChange}
items={languages.map((lang) => ({ _id: lang, name: lang.toUpperCase() }))}
></Select>
</Stack>
</ConfigBox>
);
};
SettingsUI.propTypes = {
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
mode: PropTypes.string,
language: PropTypes.string,
};
export default SettingsUI;
+50
View File
@@ -0,0 +1,50 @@
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";
// Utils
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
const SettingsURL = ({ HEADING_SX, handleChange, showURL = false }) => {
const { t } = useTranslation();
const theme = useTheme();
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsPage.urlSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.urlSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
name="showURL"
label={t("settingsPage.urlSettings.label")}
value={showURL === true}
onChange={handleChange}
items={[
{ _id: true, name: t("settingsPage.urlSettings.selectEnabled") },
{ _id: false, name: t("settingsPage.urlSettings.selectDisabled") },
]}
></Select>
</Stack>
</ConfigBox>
);
};
SettingsURL.propTypes = {
HEADING_SX: PropTypes.object,
handleChange: PropTypes.func,
showURL: PropTypes.bool,
};
export default SettingsURL;
+285
View File
@@ -0,0 +1,285 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import SettingsTimeZone from "./SettingsTimeZone.jsx";
import SettingsUI from "./SettingsUI.jsx";
import SettingsURL from "./SettingsURL.jsx";
import SettingsPagespeed from "./SettingsPagespeed.jsx";
import SettingsDemoMonitors from "./SettingsDemoMonitors.jsx";
import SettingsAbout from "./SettingsAbout.jsx";
import SettingsEmail from "./SettingsEmail.jsx";
import SettingsGlobalThresholds from "./SettingsGlobalThresholds.jsx";
import SettingsExport from "./SettingsExport.jsx";
import Button from "@mui/material/Button";
// Utils
import { settingsValidation } from "@/Validation/validation.js";
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setTimezone, setMode, setLanguage, setShowURL } from "@/Features/UI/uiSlice.js";
import SettingsStats from "./SettingsStats.jsx";
import { useFetchSettings, useSaveSettings } from "@/Hooks/settingsHooks.js";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import {
useAddDemoMonitors,
useDeleteAllMonitors,
useDeleteMonitorStats,
useFetchJson,
} from "@/Hooks/monitorHooks.js";
// Constants
const BREADCRUMBS = [{ name: `Settings`, path: "/settings" }];
const Settings = () => {
// Redux state
const { mode, language, timezone, showURL } = useSelector((state) => state.ui);
// Local state
const [settingsData, setSettingsData] = useState({});
const [errors, setErrors] = useState({});
const [isApiKeySet, setIsApiKeySet] = useState(settingsData?.pagespeedKeySet ?? false);
const [apiKeyHasBeenReset, setApiKeyHasBeenReset] = useState(false);
const [isEmailPasswordSet, setIsEmailPasswordSet] = useState(
settingsData?.emailPasswordSet ?? false
);
const [emailPasswordHasBeenReset, setEmailPasswordHasBeenReset] = useState(false);
// Network
const [isSettingsLoading, settingsError] = useFetchSettings({
setSettingsData,
setIsApiKeySet,
setIsEmailPasswordSet,
});
const [addDemoMonitors, isAddingDemoMonitors] = useAddDemoMonitors();
const [isSaving, saveError, saveSettings] = useSaveSettings({
setSettingsData,
setIsApiKeySet,
setApiKeyHasBeenReset,
setIsEmailPasswordSet,
setEmailPasswordHasBeenReset,
});
const [deleteAllMonitors, isDeletingMonitors] = useDeleteAllMonitors();
const [deleteMonitorStats, isDeletingMonitorStats] = useDeleteMonitorStats();
const [fetchJson, isFetchingJson] = useFetchJson();
// Setup
const isAdmin = useIsAdmin();
const theme = useTheme();
const HEADING_SX = { mt: theme.spacing(2), mb: theme.spacing(2) };
const { t } = useTranslation();
const dispatch = useDispatch();
// Handlers
const handleChange = async (e) => {
const { name, value, checked } = e.target;
let newValue;
if (
name === "systemEmailIgnoreTLS" ||
name === "systemEmailRequireTLS" ||
name === "systemEmailRejectUnauthorized" ||
name === "systemEmailSecure" ||
name === "systemEmailPool"
) {
newValue = checked;
}
// Ensure showURL is a proper boolean
if (name === "showURL") {
newValue = value === true || value === "true";
}
// Build next state early
const newSettingsData = {
...settingsData,
settings: { ...settingsData.settings, [name]: newValue ?? value },
};
if (name === "timezone") {
dispatch(setTimezone({ timezone: value }));
return;
}
if (name === "mode") {
dispatch(setMode(value));
return;
}
if (name === "language") {
dispatch(setLanguage(value));
return;
}
if (name === "deleteStats") {
await deleteMonitorStats();
return;
}
if (name === "demo") {
await addDemoMonitors();
return;
}
if (name === "deleteMonitors") {
await deleteAllMonitors();
return;
}
if (name === "export") {
const json = await fetchJson();
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;
}
setSettingsData(newSettingsData);
// Update Redux immediately for UI feedback
if (name === "showURL") {
dispatch(setShowURL(newValue));
}
};
const handleSave = () => {
// Validate
const { error } = settingsValidation.validate(settingsData.settings, {
abortEarly: false,
});
if (!error || error.details.length === 0) {
setErrors({});
saveSettings(settingsData?.settings);
} else {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
}
};
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<Typography variant="h1">{t("settingsPage.title")}</Typography>
<SettingsTimeZone
HEADING_SX={HEADING_SX}
handleChange={handleChange}
timezone={timezone}
/>
<SettingsUI
HEADING_SX={HEADING_SX}
handleChange={handleChange}
mode={mode}
language={language}
/>
<SettingsPagespeed
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
settingsData={settingsData}
setSettingsData={setSettingsData}
isApiKeySet={isApiKeySet}
apiKeyHasBeenReset={apiKeyHasBeenReset}
setApiKeyHasBeenReset={setApiKeyHasBeenReset}
/>
<SettingsURL
HEADING_SX={HEADING_SX}
handleChange={handleChange}
showURL={showURL}
/>
<SettingsStats
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
settingsData={settingsData}
handleChange={handleChange}
errors={errors}
/>
<SettingsGlobalThresholds
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
settingsData={settingsData}
setSettingsData={setSettingsData}
/>
<SettingsDemoMonitors
isAdmin={isAdmin}
HEADER_SX={HEADING_SX}
handleChange={handleChange}
isLoading={
isSettingsLoading || isSaving || isDeletingMonitorStats || isAddingDemoMonitors
}
/>
<SettingsEmail
isAdmin={isAdmin}
HEADER_SX={HEADING_SX}
handleChange={handleChange}
settingsData={settingsData}
setSettingsData={setSettingsData}
isEmailPasswordSet={isEmailPasswordSet}
emailPasswordHasBeenReset={emailPasswordHasBeenReset}
setEmailPasswordHasBeenReset={setEmailPasswordHasBeenReset}
/>
<SettingsExport
isAdmin={isAdmin}
HEADER_SX={HEADING_SX}
handleChange={handleChange}
isLoading={isSettingsLoading || isSaving || isFetchingJson}
/>
<SettingsAbout />
<Stack
direction="row"
justifyContent="flex-end"
sx={{
position: "sticky",
bottom: 0,
boxShadow: theme.shape.boxShadow,
zIndex: 1000,
mt: 3,
backgroundColor: theme.palette.primary.main,
display: "flex",
justifyContent: "flex-end",
pb: theme.spacing(4),
pr: theme.spacing(15),
pl: theme.spacing(5),
pt: theme.spacing(4),
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.spacing(2),
}}
>
<Button
loading={
isSaving || isDeletingMonitorStats || isSettingsLoading || isDeletingMonitors
}
disabled={Object.keys(errors).length > 0}
variant="contained"
color="accent"
sx={{ px: theme.spacing(12), py: theme.spacing(8) }}
onClick={handleSave}
>
{t("settingsPage.saveButtonLabel")}
</Button>
</Stack>
</Stack>
);
};
export default Settings;