Merge branch 'develop' into fix/2336-settings-select-value-type

This commit is contained in:
Alexander Holliday
2025-06-01 11:16:20 -07:00
committed by GitHub
15 changed files with 149 additions and 120 deletions

View File

@@ -0,0 +1,16 @@
import i18n from "../../Utils/i18n";
import { useSelector } from "react-redux";
import { useEffect } from "react";
const I18nLoader = () => {
const language = useSelector((state) => state.ui.language);
useEffect(() => {
if (language && i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [language]);
return null;
};
export default I18nLoader;

View File

@@ -164,7 +164,9 @@ Select.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]).isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]).isRequired,
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool])
.isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,

View File

@@ -3,15 +3,17 @@ import { Box, MenuItem, Select, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
import "flag-icons/css/flag-icons.min.css";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { setLanguage } from "../Features/UI/uiSlice";
const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const { language } = useSelector((state) => state.ui);
const dispatch = useDispatch();
const handleChange = (event) => {
const newLang = event.target.value;
i18n.changeLanguage(newLang);
dispatch(setLanguage(newLang));
};
const languages = Object.keys(i18n.options.resources || {});
@@ -32,7 +34,6 @@ const LanguageSelector = () => {
}
if (parsedLang.includes("-")) {
parsedLang = parsedLang.split("-")[1].toLowerCase();
console.log("parsedLang", parsedLang);
}
const flag = parsedLang ? `fi fi-${parsedLang}` : null;

View File

@@ -5,7 +5,6 @@ const initialState = {
isLoading: false,
apiBaseUrl: "",
logLevel: "debug",
language: "gb",
pagespeedApiKey: "",
};

View File

@@ -24,7 +24,7 @@ const initialState = {
greeting: { index: 0, lastUpdate: null },
timezone: "America/Toronto",
distributedUptimeEnabled: false,
language: "gb",
language: "en",
starPromptOpen: true,
};
@@ -57,7 +57,7 @@ const uiSlice = createSlice({
setTimezone(state, action) {
state.timezone = action.payload.timezone;
},
setLanguage(state, action) {
setLanguage: (state, action) => {
state.language = action.payload;
},
setStarPromptOpen: (state, action) => {

View File

@@ -22,7 +22,7 @@ const SettingsURL = ({ HEADING_SX, handleChange, showURL = false }) => {
<Select
name="showURL"
label={t("settingsURLSelectTitle")}
value={showURL}
value={showURL || ""}
onChange={handleChange}
items={[
{ _id: true, name: t("settingsURLEnabled") },

View File

@@ -93,7 +93,6 @@ const Settings = () => {
if (name === "language") {
dispatch(setLanguage(value));
i18n.changeLanguage(value);
}
if (name === "deleteStats") {

View File

@@ -17,14 +17,17 @@ const StatusBoxes = ({ shouldRender, monitorsSummary }) => {
>
<StatusBox
title={t("monitorStatus.up")}
status="up"
value={monitorsSummary?.upMonitors ?? 0}
/>
<StatusBox
title={t("monitorStatus.down")}
status="down"
value={monitorsSummary?.downMonitors ?? 0}
/>
<StatusBox
title={t("monitorStatus.paused")}
status="paused"
value={monitorsSummary?.pausedMonitors ?? 0}
/>
</Stack>

View File

@@ -5,9 +5,8 @@ import Arrow from "../../../../../assets/icons/top-right-arrow.svg?react";
import Background from "../../../../../assets/Images/background-grid.svg?react";
import ClockSnooze from "../../../../../assets/icons/clock-snooze.svg?react";
const StatusBox = ({ title, value }) => {
const StatusBox = ({ title, value, status }) => {
const theme = useTheme();
let sharedStyles = {
position: "absolute",
right: 8,
@@ -17,21 +16,21 @@ const StatusBox = ({ title, value }) => {
let color;
let icon;
if (title === "up") {
if (status === "up") {
color = theme.palette.success.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: 8 }}>
<Arrow />
</Box>
);
} else if (title === "down") {
} else if (status === "down") {
color = theme.palette.error.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: 5 }}>
<Arrow />
</Box>
);
} else if (title === "paused") {
} else if (status === "paused") {
color = theme.palette.warning.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: 12, right: 12 }}>
@@ -100,8 +99,9 @@ const StatusBox = ({ title, value }) => {
};
StatusBox.propTypes = {
title: PropTypes.oneOf(["up", "down", "paused"]).isRequired,
title: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
status: PropTypes.string,
};
export default StatusBox;

View File

@@ -4,7 +4,6 @@ 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";
import { clearUptimeMonitorState } from "../Features/UptimeMonitors/uptimeMonitorsSlice";
class NetworkService {
constructor(store, dispatch, navigate) {
this.store = store;

View File

@@ -1,10 +1,9 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { setLanguage } from "../Features/UI/uiSlice";
import store from "../store";
const primaryLanguage = "en";
// Load all translation files eagerly
const translations = import.meta.glob("../locales/*.json", { eager: true });
const resources = {};
@@ -15,12 +14,9 @@ Object.keys(translations).forEach((path) => {
};
});
const savedLanguage = store.getState()?.ui?.language;
const initialLanguage = savedLanguage;
i18n.use(initReactI18next).init({
resources,
lng: initialLanguage,
lng: primaryLanguage,
fallbackLng: primaryLanguage,
debug: import.meta.env.MODE === "development",
ns: ["translation"],
@@ -30,8 +26,4 @@ i18n.use(initReactI18next).init({
},
});
i18n.on("languageChanged", (lng) => {
store.dispatch(setLanguage(lng));
});
export default i18n;

View File

@@ -277,6 +277,7 @@ const settingsValidation = joi.object({
}),
pagespeedApiKey: joi.string().allow("").optional(),
language: joi.string().required(),
timezone: joi.string().allow("").optional(),
systemEmailHost: joi.string().allow(""),
systemEmailPort: joi.number().allow(null, ""),
systemEmailAddress: joi.string().allow(""),

View File

@@ -1,7 +1,6 @@
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
import "./Utils/i18n";
import { BrowserRouter as Router } from "react-router-dom";
import { Provider } from "react-redux";
import { persistor, store } from "./store";
@@ -9,6 +8,7 @@ import { PersistGate } from "redux-persist/integration/react";
import NetworkServiceProvider from "./Utils/NetworkServiceProvider.jsx";
import { networkService } from "./Utils/NetworkService";
export { networkService };
import I18nLoader from "./Components/I18nLoader";
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
@@ -16,6 +16,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
loading={null}
persistor={persistor}
>
<I18nLoader />
<Router>
<NetworkServiceProvider>
<App />

View File

@@ -1,11 +1,12 @@
const SERVICE_NAME = "NotificationService";
const TELEGRAM_API_BASE_URL = "https://api.telegram.org/bot";
const PLATFORM_TYPES = ["telegram", "slack", "discord"];
const PLATFORM_TYPES = ["telegram", "slack", "discord", "webhook"];
const MESSAGE_FORMATTERS = {
telegram: (messageText, chatId) => ({ chat_id: chatId, text: messageText }),
slack: (messageText) => ({ text: messageText }),
discord: (messageText) => ({ content: messageText }),
webhook: (messageText) => ({ text: messageText }),
};
class NotificationService {
@@ -42,49 +43,57 @@ class NotificationService {
formatNotificationMessage(monitor, status, platform, chatId, code, timestamp) {
// Format timestamp using the local system timezone
const formatTime = (timestamp) => {
const date = new Date(timestamp);
// Get timezone abbreviation and format the date
const timeZoneAbbr = date.toLocaleTimeString('en-US', { timeZoneName: 'short' })
.split(' ').pop();
// Format the date with readable format
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).replace(/(\d+)\/(\d+)\/(\d+),\s/, '$3-$1-$2 ') + ' ' + timeZoneAbbr;
const date = new Date(timestamp);
// Get timezone abbreviation and format the date
const timeZoneAbbr = date
.toLocaleTimeString("en-US", { timeZoneName: "short" })
.split(" ")
.pop();
// Format the date with readable format
return (
date
.toLocaleString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/(\d+)\/(\d+)\/(\d+),\s/, "$3-$1-$2 ") +
" " +
timeZoneAbbr
);
};
// Get formatted time
const formattedTime = timestamp
? formatTime(timestamp)
: formatTime(new Date().getTime());
const formattedTime = timestamp
? formatTime(timestamp)
: formatTime(new Date().getTime());
// Create different messages based on status with extra spacing
let messageText;
if (status === true) {
messageText = this.stringService.monitorUpAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || 'Unknown');
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
} else {
messageText = this.stringService.monitorDownAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || 'Unknown');
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
}
if (!PLATFORM_TYPES.includes(platform)) {
return undefined;
return undefined;
}
return MESSAGE_FORMATTERS[platform](messageText, chatId);
}
}
/**
* Sends a webhook notification to a specified platform.
@@ -105,56 +114,56 @@ class NotificationService {
const { monitor, status, code } = networkResponse;
const { platform } = notification;
const { webhookUrl, botToken, chatId } = notification.config;
// Early return if platform is not supported
if (!PLATFORM_TYPES.includes(platform)) {
this.logger.warn({
message: this.stringService.getWebhookUnsupportedPlatform(platform),
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
details: { platform }
});
return false;
this.logger.warn({
message: this.stringService.getWebhookUnsupportedPlatform(platform),
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
details: { platform },
});
return false;
}
// Early return for telegram if required fields are missing
if (platform === "telegram" && (!botToken || !chatId)) {
this.logger.warn({
message: "Missing required fields for Telegram notification",
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
details: { platform }
});
return false;
this.logger.warn({
message: "Missing required fields for Telegram notification",
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
details: { platform },
});
return false;
}
let url = webhookUrl;
if (platform === "telegram") {
url = `${TELEGRAM_API_BASE_URL}${botToken}/sendMessage`;
url = `${TELEGRAM_API_BASE_URL}${botToken}/sendMessage`;
}
const message = this.formatNotificationMessage(
monitor,
status,
platform,
chatId,
code, // Pass the code field directly
networkResponse.timestamp
monitor,
status,
platform,
chatId,
code, // Pass the code field directly
networkResponse.timestamp
);
try {
const response = await this.networkService.requestWebhook(platform, url, message);
return response.status;
const response = await this.networkService.requestWebhook(platform, url, message);
return response.status;
} catch (error) {
this.logger.error({
message: this.stringService.getWebhookSendError(platform),
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
stack: error.stack,
});
return false;
this.logger.error({
message: this.stringService.getWebhookSendError(platform),
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
stack: error.stack,
});
return false;
}
}
}
/**
* Sends an email notification for hardware infrastructure alerts
@@ -197,33 +206,33 @@ class NotificationService {
async handleStatusNotifications(networkResponse) {
try {
// If status hasn't changed, we're done
if (networkResponse.statusChanged === false) return false;
// if prevStatus is undefined, monitor is resuming, we're done
if (networkResponse.prevStatus === undefined) return false;
const notifications = await this.db.getNotificationsByMonitorId(
networkResponse.monitorId
);
for (const notification of notifications) {
if (notification.type === "email") {
await this.sendEmail(networkResponse, notification.address);
} else if (notification.type === "webhook") {
await this.sendWebhookNotification(networkResponse, notification);
// If status hasn't changed, we're done
if (networkResponse.statusChanged === false) return false;
// if prevStatus is undefined, monitor is resuming, we're done
if (networkResponse.prevStatus === undefined) return false;
const notifications = await this.db.getNotificationsByMonitorId(
networkResponse.monitorId
);
for (const notification of notifications) {
if (notification.type === "email") {
await this.sendEmail(networkResponse, notification.address);
} else if (notification.type === "webhook") {
await this.sendWebhookNotification(networkResponse, notification);
}
// Handle other types of notifications here
}
// Handle other types of notifications here
}
return true;
return true;
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "handleNotifications",
stack: error.stack,
});
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "handleNotifications",
stack: error.stack,
});
}
}
}
/**
* Handles status change notifications for a monitor
*
@@ -257,7 +266,13 @@ class NotificationService {
const alerts = {
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,
disk:
disk?.some(
(d) =>
diskThreshold !== -1 &&
typeof d?.usage_percent === "number" &&
d?.usage_percent > diskThreshold
) ?? false,
};
const notifications = await this.db.getNotificationsByMonitorId(

View File

@@ -427,6 +427,7 @@ const updateAppSettingsBodyValidation = joi.object({
checkTTL: joi.number().allow(""),
pagespeedApiKey: joi.string().allow(""),
language: joi.string().allow(""),
timezone: joi.string().allow(""),
// showURL: joi.bool().required(),
systemEmailHost: joi.string().allow(""),
systemEmailPort: joi.number().allow(""),