Merge branch 'develop' into feat/fe/details-empty-views

This commit is contained in:
Alexander Holliday
2024-11-24 18:32:44 -08:00
committed by GitHub
16 changed files with 437 additions and 131 deletions

View File

@@ -548,23 +548,6 @@ const Login = () => {
)
)}
</Stack>
<Box
textAlign="center"
p={theme.spacing(12)}
>
<Typography display="inline-block">Don&apos;t have an account? </Typography>
<Typography
component="span"
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => {
navigate("/register");
}}
sx={{ userSelect: "none" }}
>
Sign Up
</Typography>
</Box>
</Stack>
);
};

View File

@@ -132,7 +132,7 @@ const CreateInfrastructureMonitor = () => {
Object.keys(form)
.filter((k) => k.startsWith(THRESHOLD_FIELD_PREFIX))
.map((k) => {
if (form[k]) thresholds[k] = form[k];
if (form[k]) thresholds[k] = form[k] / 100;
delete form[k];
delete form[k.substring(THRESHOLD_FIELD_PREFIX.length)];
});
@@ -158,6 +158,7 @@ const CreateInfrastructureMonitor = () => {
: infrastructureMonitor.name,
interval: infrastructureMonitor.interval * MS_PER_MINUTE,
};
delete form.notifications;
if (hasValidationErrors(form, infrastructureMonitorValidation, setErrors)) {
return;
@@ -194,25 +195,6 @@ const CreateInfrastructureMonitor = () => {
{ _id: 10, name: "10 minutes" },
];
const NOTIFY_MULTIPLE_EMAIL_LABEL = (
<Box>
<Typography mb={theme.spacing(4)}>
Also notify via email to multiple addresses (coming soon)
</Typography>
<Field
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
onBlur={handleBlur}
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
);
return (
<Box className="create-infrastructure-monitor">
<Breadcrumbs
@@ -253,7 +235,8 @@ const CreateInfrastructureMonitor = () => {
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of monitor.
Here you can select the URL of the host, together with the friendly name and
authorization secret to connect to the server agent.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
@@ -307,15 +290,6 @@ const CreateInfrastructureMonitor = () => {
onChange={(e) => handleChange(e)}
onBlur={handleBlur}
/>
<Checkbox
id="notify-email"
label={NOTIFY_MULTIPLE_EMAIL_LABEL}
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
onBlur={handleBlur}
isDisabled={true}
/>
</Stack>
</ConfigBox>
@@ -323,8 +297,7 @@ const CreateInfrastructureMonitor = () => {
<Box>
<Typography component="h2">Customize alerts</Typography>
<Typography component="p">
Send a notification to user(s) When the thresholds exceed a certain number
or percentage.
Send a notification to user(s) when thresholds exceed a specified percentage.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>

View File

@@ -11,6 +11,7 @@ import PulseDot from "../../../Components/Animated/PulseDot";
import useUtils from "../../Monitors/utils";
import { useNavigate } from "react-router-dom";
import Empty from "./empty";
import { logger } from "../../../Utils/Logger";
import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils";
import {
TzTick,
@@ -190,7 +191,7 @@ const InfrastructureDetails = () => {
];
const [monitor, setMonitor] = useState(null);
const { authToken } = useSelector((state) => state.auth);
const [dateRange, setDateRange] = useState("day");
const [dateRange, setDateRange] = useState("all");
const { statusColor, determineState } = useUtils();
// These calculations are needed because ResponsiveContainer
// doesn't take padding of parent/siblings into account
@@ -210,23 +211,23 @@ const InfrastructureDetails = () => {
const response = await networkService.getStatsByMonitorId({
authToken: authToken,
monitorId: monitorId,
sortOrder: null,
sortOrder: "asc",
limit: null,
dateRange: dateRange,
numToDisplay: 50,
normalize: true,
normalize: false,
});
setMonitor(response.data.data);
} catch (error) {
navigate("/not-found", { replace: true });
console.error(error);
logger.error(error);
}
};
fetchData();
}, [authToken, monitorId, dateRange]);
const statBoxConfigs = [
{
id: 0,

View File

@@ -44,6 +44,14 @@ class Logger {
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() {

View File

@@ -16,6 +16,28 @@ const NotificationSchema = mongoose.Schema(
phone: {
type: String,
},
alertThreshold: {
type: Number,
default: 5,
},
cpuAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
memoryAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
diskAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
},
{
timestamps: true,

View File

@@ -198,7 +198,7 @@ const getIncidents = (checks) => {
/**
* Get date range parameters
* @param {string} dateRange - 'day' | 'week' | 'month'
* @param {string} dateRange - 'day' | 'week' | 'month' | 'all'
* @returns {Object} Start and end dates
*/
const getDateRange = (dateRange) => {
@@ -206,6 +206,7 @@ const getDateRange = (dateRange) => {
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: new Date(0),
};
return {
start: startDates[dateRange],

View File

@@ -11,7 +11,7 @@
"dependencies": {
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bullmq": "5.28.2",
"bullmq": "5.29.1",
"cors": "^2.8.5",
"dockerode": "4.0.2",
"dotenv": "^16.4.5",
@@ -1217,9 +1217,9 @@
}
},
"node_modules/bullmq": {
"version": "5.28.2",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.28.2.tgz",
"integrity": "sha512-2F94CJnOKP2kyf29v8bxpNNyF7IsG+z0RY80gP7dAvUprcEbb3CAvyCKMcbkHeF/kBKkcJCNQOlVY/+JY8dsUQ==",
"version": "5.29.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.29.1.tgz",
"integrity": "sha512-TZWiwRlPnpaN+Qwh4D8IQf2cYLpkiDX1LbaaWEabc6y37ojIttWOSynxDewpVHyW233LssSIC4+aLMSvAjtpmg==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.6.0",

View File

@@ -14,7 +14,7 @@
"dependencies": {
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"bullmq": "5.28.2",
"bullmq": "5.29.1",
"cors": "^2.8.5",
"dockerode": "4.0.2",
"dotenv": "^16.4.5",

View File

@@ -65,7 +65,7 @@ class EmailService {
serverIsDownTemplate: this.loadTemplate("serverIsDown"),
serverIsUpTemplate: this.loadTemplate("serverIsUp"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
thresholdViolatedTemplate: this.loadTemplate("thresholdViolated"),
hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"),
};
/**

View File

@@ -165,17 +165,12 @@ class JobQueue {
// Handle status change
const { monitor, statusChanged, prevStatus } =
await this.statusService.updateStatus(networkResponse);
//If status hasn't changed, we're done
if (statusChanged === false) return;
// if prevStatus is undefined, monitor is resuming, we're done
if (prevStatus === undefined) return;
// Handle notifications
this.notificationService.handleNotifications({
...networkResponse,
monitor,
prevStatus,
statusChanged,
});
} catch (error) {
this.logger.error({

View File

@@ -14,15 +14,34 @@ class NotificationService {
}
/**
* Sends an email notification based on the network response.
* Sends an email notification for hardware infrastructure alerts
*
* @param {Object} networkResponse - The response from the network monitor.
* @param {Object} networkResponse.monitor - The monitor object containing details about the monitored service.
* @param {string} networkResponse.monitor.name - The name of the monitor.
* @param {string} networkResponse.monitor.url - The URL of the monitor.
* @param {boolean} networkResponse.status - The current status of the monitor (true for up, false for down).
* @param {boolean} networkResponse.prevStatus - The previous status of the monitor (true for up, false for down).
* @param {string} address - The email address to send the notification to.
* @async
* @function sendHardwareEmail
* @param {Object} networkResponse - Response object containing monitor information
* @param {string} address - Email address to send the notification to
* @param {Array} [alerts=[]] - List of hardware alerts to include in the email
* @returns {Promise<boolean>} - Indicates whether email was sent successfully
* @throws {Error}
*/
async sendHardwareEmail(networkResponse, address, alerts = []) {
if (alerts.length === 0) return false;
const { monitor, status, prevStatus } = networkResponse;
const template = "hardwareIncidentTemplate";
const context = { monitor: monitor.name, url: monitor.url, alerts };
const subject = `Monitor ${monitor.name} infrastructure alerts`;
this.emailService.buildAndSendEmail(template, context, address, subject);
return true;
}
/**
* Sends an email notification about monitor status change
*
* @async
* @function sendEmail
* @param {Object} networkResponse - Response object containing monitor status information
* @param {string} address - Email address to send the notification to
* @returns {Promise<boolean>} - Indicates email was sent successfully
*/
async sendEmail(networkResponse, address) {
const { monitor, status, prevStatus } = networkResponse;
@@ -30,25 +49,133 @@ class NotificationService {
const context = { monitor: monitor.name, url: monitor.url };
const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`;
this.emailService.buildAndSendEmail(template, context, address, subject);
return true;
}
/**
* Handles notifications based on the network response.
*
* @param {Object} networkResponse - The response from the network monitor.
* @param {string} networkResponse.monitorId - The ID of the monitor.
*/
async handleNotifications(networkResponse) {
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") {
this.sendEmail(networkResponse, notification.address);
}
// Handle other types of notifications here
}
return true;
} catch (error) {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
method: "handleNotifications",
stack: error.stack,
});
}
}
/**
* Handles status change notifications for a monitor
*
* @async
* @function handleStatusNotifications
* @param {Object} networkResponse - Response object containing monitor status information
* @returns {Promise<boolean>} - Indicates whether notifications were processed
* @throws {Error}
*/
async handleHardwareNotifications(networkResponse) {
const thresholds = networkResponse?.monitor?.thresholds;
if (thresholds === undefined) return false; // No thresholds set, we're done
// Get thresholds from monitor
const {
usage_cpu: cpuThreshold = -1,
usage_memory: memoryThreshold = -1,
usage_disk: diskThreshold = -1,
} = thresholds;
// Get metrics from response
const metrics = networkResponse?.payload?.data ?? null;
if (metrics === null) return false;
const {
cpu: { usage_percent: cpuUsage = -1 } = {},
memory: { usage_percent: memoryUsage = -1 } = {},
disk = [],
} = metrics;
const alerts = {
cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false,
memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false,
disk: disk.some((d) => diskThreshold !== -1 && d.usage_percent > diskThreshold)
? true
: false,
};
const notifications = await this.db.getNotificationsByMonitorId(
networkResponse.monitorId
);
for (const notification of notifications) {
const alertsToSend = [];
const alertTypes = ["cpu", "memory", "disk"];
for (const type of alertTypes) {
// Iterate over each alert type to see if any need to be decremented
if (alerts[type] === true) {
notification[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered
if (notification[`${type}AlertThreshold`] <= 0) {
// If threshold drops below 0, reset and send notification
notification[`${type}AlertThreshold`] = notification.alertThreshold;
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 * 100).toFixed(0)}%)`)
.join(
", "
)} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`,
};
alertsToSend.push(formatAlert[type]());
}
}
}
await notification.save();
if (alertsToSend.length === 0) continue; // No alerts to send, we're done
if (notification.type === "email") {
this.sendHardwareEmail(networkResponse, notification.address, alertsToSend);
}
}
return true;
}
/**
* Handles notifications for different monitor types
*
* @async
* @function handleNotifications
* @param {Object} networkResponse - Response object containing monitor information
* @returns {Promise<boolean>} - Indicates whether notifications were processed successfully
*/
async handleNotifications(networkResponse) {
try {
if (networkResponse.monitor.type === "hardware") {
this.handleHardwareNotifications(networkResponse);
}
this.handleStatusNotifications(networkResponse);
return true;
} catch (error) {
this.logger.warn({
message: error.message,

View File

@@ -29,7 +29,8 @@ class StatusService {
const { monitorId, status } = networkResponse;
const monitor = await this.db.getMonitorById(monitorId);
// No change in monitor status, return early
if (monitor.status === status) return { statusChanged: false };
if (monitor.status === status)
return { monitor, statusChanged: false, prevStatus: monitor.status };
// Monitor status changed, save prev status and update monitor
this.logger.info({
@@ -103,7 +104,7 @@ class StatusService {
if (type === "hardware") {
const { cpu, memory, disk, host } = payload?.data ?? {};
const { errors } = payload;
const { errors } = payload?.errors ?? [];
check.cpu = cpu ?? {};
check.memory = memory ?? {};
check.disk = disk ?? {};

View File

@@ -0,0 +1,43 @@
<mjml>
<mj-head>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text align="left" font-size="10px">
Message from BlueWave Infrastructure Monitoring
</mj-text>
</mj-column>
<mj-column width="45%" padding-top="20px">
<mj-text align="center" font-weight="500" padding="0px" font-size="18px" color="red">
Infrastructure Alerts
</mj-text>
<mj-divider border-width="2px" border-color="#616161"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{name}}!</p>
<p>{{monitor}} at {{url}} has the following infrastructure alerts:</p>
{{#each alerts}}
<p>• {{this}}</p>
{{/each}}
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider border-width="1px" border-color="#E0E0E0"></mj-divider>
<mj-button background-color="#1570EF"> View Infrastructure Details </mj-button>
<mj-text font-size="12px">
<p>This email was sent by BlueWave Infrastructure Monitoring.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

View File

@@ -1,40 +0,0 @@
<mjml>
<mj-head>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css?family=Roboto:300,500"></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text font-weight="300" font-size="16px" color="#616161" line-height="24px"></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text align="left" font-size="10px">
Message from BlueWave Uptime Service
</mj-text>
</mj-column>
<mj-column width="45%" padding-top="20px">
<mj-text font-weight="500" padding="0px" font-size="18px">
{{message}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if cpu}}
{{cpu}}
{{/if}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if disk}}
{{disk}}
{{/if}}
</mj-text>
<mj-text font-weight="500" padding="0px" font-size="18px">
{{#if memory}}
{{memory}}
{{/if}}
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

View File

@@ -78,11 +78,23 @@ describe("NotificationService", () => {
describe("handleNotifications", async () => {
it("should handle notifications based on the network response", async () => {
notificationService.sendEmail = sinon.stub();
notificationService.db.getNotificationsByMonitorId.resolves([
{ type: "email", address: "www.google.com" },
]);
await notificationService.handleNotifications({ monitorId: "123" });
expect(notificationService.sendEmail.calledOnce).to.be.true;
const res = await notificationService.handleNotifications({
monitor: {
type: "email",
address: "www.google.com",
},
});
expect(res).to.be.true;
});
it("should handle hardware notifications", async () => {
notificationService.sendEmail = sinon.stub();
const res = await notificationService.handleNotifications({
monitor: {
type: "hardware",
address: "www.google.com",
},
});
expect(res).to.be.true;
});
it("should handle an error when getting notifications", async () => {
@@ -92,4 +104,184 @@ describe("NotificationService", () => {
expect(notificationService.logger.warn.calledOnce).to.be.true;
});
});
describe("sendHardwareEmail", async () => {
let networkResponse, address, alerts;
beforeEach(() => {
networkResponse = {
monitor: {
name: "Test Monitor",
url: "http://test.com",
},
status: true,
prevStatus: false,
};
address = "test@test.com";
alerts = ["test"];
});
afterEach(() => {
sinon.restore();
});
it("should send an email notification with Hardware Template", async () => {
emailService.buildAndSendEmail.resolves(true);
const res = await notificationService.sendHardwareEmail(
networkResponse,
address,
alerts
);
expect(res).to.be.true;
});
it("should return false if no alerts are provided", async () => {
alerts = [];
emailService.buildAndSendEmail.resolves(true);
const res = await notificationService.sendHardwareEmail(
networkResponse,
address,
alerts
);
expect(res).to.be.false;
});
});
describe("handleStatusNotifications", async () => {
let networkResponse;
beforeEach(() => {
networkResponse = {
monitor: {
name: "Test Monitor",
url: "http://test.com",
},
statusChanged: true,
status: true,
prevStatus: false,
};
});
afterEach(() => {
sinon.restore();
});
it("should handle status notifications", async () => {
db.getNotificationsByMonitorId.resolves([
{ type: "email", address: "test@test.com" },
]);
const res = await notificationService.handleStatusNotifications(networkResponse);
expect(res).to.be.true;
});
it("should return false if status hasn't changed", async () => {
networkResponse.statusChanged = false;
const res = await notificationService.handleStatusNotifications(networkResponse);
expect(res).to.be.false;
});
it("should return false if prevStatus is undefined", async () => {
networkResponse.prevStatus = undefined;
const res = await notificationService.handleStatusNotifications(networkResponse);
expect(res).to.be.false;
});
it("should handle an error", async () => {
const testError = new Error("Test Error");
db.getNotificationsByMonitorId.rejects(testError);
try {
await notificationService.handleStatusNotifications(networkResponse);
} catch (error) {
expect(error).to.be.an.instanceOf(Error);
expect(error.message).to.equal("Test Error");
}
});
});
describe("handleHardwareNotifications", async () => {
let networkResponse;
beforeEach(() => {
networkResponse = {
monitor: {
name: "Test Monitor",
url: "http://test.com",
thresholds: {
usage_cpu: 1,
usage_memory: 1,
usage_disk: 1,
},
},
payload: {
data: {
cpu: {
usage_percent: 0.655,
},
memory: {
usage_percent: 0.783,
},
disk: [
{
name: "/dev/sda1",
usage_percent: 0.452,
},
{
name: "/dev/sdb1",
usage_percent: 0.627,
},
],
},
},
};
});
afterEach(() => {
sinon.restore();
});
describe("it should return false if no thresholds are set", () => {
it("should return false if no thresholds are set", async () => {
networkResponse.monitor.thresholds = undefined;
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.false;
});
it("should return false if metrics are null", async () => {
networkResponse.payload.data = null;
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.false;
});
it("should return true if request is well formed and thresholds > 0", async () => {
db.getNotificationsByMonitorId.resolves([
{
type: "email",
address: "test@test.com",
alertThreshold: 1,
cpuAlertThreshold: 1,
memoryAlertThreshold: 1,
diskAlertThreshold: 1,
save: sinon.stub().resolves(),
},
]);
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.true;
});
it("should return true if thresholds are exceeded", async () => {
db.getNotificationsByMonitorId.resolves([
{
type: "email",
address: "test@test.com",
alertThreshold: 1,
cpuAlertThreshold: 1,
memoryAlertThreshold: 1,
diskAlertThreshold: 1,
save: sinon.stub().resolves(),
},
]);
networkResponse.monitor.thresholds = {
usage_cpu: 0.01,
usage_memory: 0.01,
usage_disk: 0.01,
};
const res =
await notificationService.handleHardwareNotifications(networkResponse);
expect(res).to.be.true;
});
});
});
});

View File

@@ -178,7 +178,7 @@ const getMonitorStatsByIdQueryValidation = joi.object({
status: joi.string(),
limit: joi.number(),
sortOrder: joi.string().valid("asc", "desc"),
dateRange: joi.string().valid("day", "week", "month"),
dateRange: joi.string().valid("day", "week", "month", "all"),
numToDisplay: joi.number(),
normalize: joi.boolean(),
});