Merge branch 'develop' into fix-incidents-creation

This commit is contained in:
karenvicent
2025-11-06 17:07:24 -05:00
25 changed files with 12973 additions and 1078 deletions

View File

@@ -88,3 +88,4 @@ jobs:
docker compose down
docker compose pull
docker compose up -d
docker system prune -af

11325
Checkmate.CodeCanvas Normal file

File diff suppressed because one or more lines are too long

View File

@@ -162,6 +162,7 @@ Here's how you can contribute:
4. Open an issue if you believe you've encountered a bug.
5. Check for good-first-issue's if you are a newcomer.
6. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
7. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />

View File

@@ -21,6 +21,10 @@ cd checkmate/charts/helm/checkmate
Edit `values.yaml` to update:
- `client.ingress.host` and `server.ingress.host` with your domain names
- `server.protocol` (usually http or https)
- **If upgrading**: Migrate persistence settings from flat structure to nested:
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
- Old: `persistence.redisSize` → New: `persistence.redis.size`
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
### 3. Deploy the Helm chart

View File

@@ -37,8 +37,8 @@ spec:
- metadata:
name: checkmate-mongo-persistent-storage
spec:
storageClassName: "gp3"
storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.persistence.mongodbSize }}
storage: {{ .Values.persistence.mongo.size | default "5Gi" | quote }}

View File

@@ -25,9 +25,9 @@ spec:
- metadata:
name: checkmate-redis-persistent-storage
spec:
storageClassName: "gp3"
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.persistence.redisSize }}
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
{{- end }}

View File

@@ -44,5 +44,9 @@ secrets:
# REFRESH_TOKEN_TTL: 99d
persistence:
mongodbSize: 5Gi
redisSize: 1Gi
mongo:
size: 5Gi
storageClass: ""
redis:
size: 1Gi
storageClass: ""

View File

@@ -223,18 +223,54 @@ const CreateNotifications = () => {
<Typography component="p">{t(DESCRIPTION_MAP[type])}</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<TextInput
label={t(LABEL_MAP[type])}
name="address"
placeholder={t(PLACEHOLDER_MAP[type])}
value={notification.address}
onChange={onChange}
error={Boolean(errors.address)}
helperText={errors["address"]}
/>
{type === "matrix" ? (
<>
<TextInput
label={t("createNotifications.matrixSettings.homeserverLabel")}
name="homeserverUrl"
placeholder={t(
"createNotifications.matrixSettings.homeserverPlaceholder"
)}
value={notification.homeserverUrl || ""}
onChange={onChange}
error={Boolean(errors.homeserverUrl)}
helperText={errors["homeserverUrl"]}
/>
<TextInput
label={t("createNotifications.matrixSettings.roomIdLabel")}
name="roomId"
placeholder={t("createNotifications.matrixSettings.roomIdPlaceholder")}
value={notification.roomId || ""}
onChange={onChange}
error={Boolean(errors.roomId)}
helperText={errors["roomId"]}
/>
<TextInput
label={t("createNotifications.matrixSettings.accessTokenLabel")}
name="accessToken"
type="password"
placeholder={t(
"createNotifications.matrixSettings.accessTokenPlaceholder"
)}
value={notification.accessToken || ""}
onChange={onChange}
error={Boolean(errors.accessToken)}
helperText={errors["accessToken"]}
/>
</>
) : (
<TextInput
label={t(LABEL_MAP[type])}
name="address"
placeholder={t(PLACEHOLDER_MAP[type])}
value={notification.address}
onChange={onChange}
error={Boolean(errors.address)}
helperText={errors["address"]}
/>
)}
</Stack>
</ConfigBox>
</ConfigBox>{" "}
<Stack
direction="row"
justifyContent="flex-end"

View File

@@ -4,6 +4,7 @@ export const NOTIFICATION_TYPES = [
{ _id: 3, name: "PagerDuty", value: "pager_duty" },
{ _id: 4, name: "Webhook", value: "webhook" },
{ _id: 5, name: "Discord", value: "discord" },
{ _id: 6, name: "Matrix", value: "matrix" },
];
export const TITLE_MAP = {
@@ -12,6 +13,7 @@ export const TITLE_MAP = {
pager_duty: "createNotifications.pagerdutySettings.title",
webhook: "createNotifications.webhookSettings.title",
discord: "createNotifications.discordSettings.title",
matrix: "createNotifications.matrixSettings.title",
};
export const DESCRIPTION_MAP = {
@@ -20,6 +22,7 @@ export const DESCRIPTION_MAP = {
pager_duty: "createNotifications.pagerdutySettings.description",
webhook: "createNotifications.webhookSettings.description",
discord: "createNotifications.discordSettings.description",
matrix: "createNotifications.matrixSettings.description",
};
export const LABEL_MAP = {
@@ -28,6 +31,7 @@ export const LABEL_MAP = {
pager_duty: "createNotifications.pagerdutySettings.integrationKeyLabel",
webhook: "createNotifications.webhookSettings.webhookLabel",
discord: "createNotifications.discordSettings.webhookLabel",
matrix: "createNotifications.matrixSettings.homeserverLabel",
};
export const PLACEHOLDER_MAP = {
@@ -36,4 +40,5 @@ export const PLACEHOLDER_MAP = {
pager_duty: "createNotifications.pagerdutySettings.integrationKeyPlaceholder",
webhook: "createNotifications.webhookSettings.webhookPlaceholder",
discord: "createNotifications.discordSettings.webhookPlaceholder",
matrix: "createNotifications.matrixSettings.homeserverPlaceholder",
};

View File

@@ -14,7 +14,7 @@ const useMonitorsFetch = () => {
try {
const response = await networkService.getMonitorsByTeamId({
limit: null, // donot return any checks for the monitors
types: ["http", "ping", "port"], // status page is available for uptime, ping, and port monitors
types: ["http", "ping", "port", "game"], // include game servers in status page monitor selection
});
setMonitors(response.data.data.monitors);
} catch (error) {

View File

@@ -7,6 +7,80 @@ import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "../../../../../../Utils/timeUtils.js";
import SkeletonLayout from "./skeleton.jsx";
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import { lighten, useTheme } from "@mui/material";
/**
* Creates tooltip content with detailed timing breakdown
* Following the pattern from IncidentTable's GetTooltip function
* @param {Object} timings - Timing object (guaranteed to have phases by caller)
* @param {Object} theme - MUI theme object
* @param {Function} t - Translation function
* @returns {JSX.Element} Tooltip content
*/
const GetTooltip = (timings, theme, t) => {
const phases = timings.phases;
const timingDetails = [
{ label: t("dnsLookup"), value: phases.dns },
{ label: t("tcpConnection"), value: phases.tcp },
{ label: t("tlsHandshake"), value: phases.tls },
{ label: t("waitTime"), value: phases.wait },
{ label: t("timeToFirstByte"), value: phases.firstByte },
{ label: t("download"), value: phases.download },
{ label: t("total"), value: phases.total },
].filter((item) => item.value > 0);
return (
<Stack
sx={{
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
variant="body2"
sx={{
fontWeight: 600,
marginBottom: theme.spacing(1),
color: theme.palette.primary.contrastText,
}}
>
{t("responseTimeBreakdown")}
</Typography>
{timingDetails.map((detail, index, array) => (
<Box
key={index}
sx={{
display: "flex",
justifyContent: "space-between",
gap: theme.spacing(4),
marginBottom: index < array.length - 1 ? theme.spacing(0.5) : 0,
}}
>
<Typography
variant="body2"
sx={{ color: theme.palette.primary.contrastText }}
>
{detail.label}:
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: index === array.length - 1 ? 600 : 400,
color: theme.palette.primary.contrastText,
}}
>
{Math.round(detail.value)} ms
</Typography>
</Box>
))}
</Stack>
);
};
const ResponseTable = ({
isLoading = false,
checks = [],
@@ -18,6 +92,8 @@ const ResponseTable = ({
setRowsPerPage,
}) => {
const { t } = useTranslation();
const theme = useTheme();
if (isLoading) {
return <SkeletonLayout />;
}
@@ -54,6 +130,56 @@ const ResponseTable = ({
content: t("message"),
render: (row) => row.message,
},
{
id: "responseTime",
content: t("responseTime"),
render: (row) => {
const hasTimings = row.timings && row.timings.phases;
const responseTime = row.responseTime;
const responseTimeDisplay =
responseTime !== null && responseTime !== undefined
? `${Math.round(responseTime)} ms`
: "N/A";
if (!hasTimings) {
return responseTimeDisplay;
}
return (
<Tooltip
title={GetTooltip(row.timings, theme, t)}
placement="top"
arrow
enterDelay={300}
enterNextDelay={300}
slotProps={{
tooltip: {
sx: {
backgroundColor: lighten(theme.palette.primary.main, 0.1),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.shape.borderRadius,
"& .MuiTooltip-arrow": {
color: lighten(theme.palette.primary.main, 0.1),
"&::before": {
border: `1px solid ${theme.palette.primary.lowContrast}`,
},
},
},
},
}}
>
<Box
sx={{
cursor: "help",
display: "inline-block",
}}
>
{responseTimeDisplay}
</Box>
</Tooltip>
);
},
},
];
return (

View File

@@ -458,7 +458,7 @@ const notificationValidation = joi.object({
type: joi
.string()
.valid("email", "webhook", "slack", "discord", "pager_duty")
.valid("email", "webhook", "slack", "discord", "pager_duty", "matrix")
.required()
.messages({
"string.empty": "Notification type is required",
@@ -495,8 +495,40 @@ const notificationValidation = joi.object({
"string.uri": "Please enter a valid Webhook URL",
}),
},
{
is: "matrix",
then: joi.string().allow("").optional(),
},
],
}),
homeserverUrl: joi.when("type", {
is: "matrix",
then: joi.string().uri().required().messages({
"string.empty": "Homeserver URL cannot be empty",
"any.required": "Homeserver URL is required",
"string.uri": "Please enter a valid Homeserver URL",
}),
otherwise: joi.string().allow("").optional(),
}),
roomId: joi.when("type", {
is: "matrix",
then: joi.string().required().messages({
"string.empty": "Room ID cannot be empty",
"any.required": "Room ID is required",
}),
otherwise: joi.string().allow("").optional(),
}),
accessToken: joi.when("type", {
is: "matrix",
then: joi.string().required().messages({
"string.empty": "Access Token cannot be empty",
"any.required": "Access Token is required",
}),
otherwise: joi.string().allow("").optional(),
}),
});
const editUserValidation = joi.object({

View File

@@ -20,6 +20,13 @@
"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",

View File

@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig(({}) => {
let version = "3.2.0";
let version = "3.2.1";
return {
base: "/",

2316
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
"bullmq": "5.41.2",
"cacheable-lookup": "7.0.0",
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -51,7 +52,7 @@
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.1",
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0"
},

View File

@@ -16,7 +16,7 @@ const NotificationSchema = mongoose.Schema(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty"],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix"],
},
notificationName: {
type: String,
@@ -28,6 +28,16 @@ const NotificationSchema = mongoose.Schema(
phone: {
type: String,
},
// Matrix-specific fields
homeserverUrl: {
type: String,
},
roomId: {
type: String,
},
accessToken: {
type: String,
},
},
{
timestamps: true,

View File

@@ -1,5 +1,5 @@
import { Router } from "express";
import MaintenanceWindow from "../../db/v1/models/MaintenanceWindow.js";
class MaintenanceWindowRoutes {
constructor(maintenanceWindowController) {
this.router = Router();

View File

@@ -35,8 +35,12 @@ class SuperSimpleQueue {
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
const monitors = await this.db.monitorModule.getAllMonitors();
for (const monitor of monitors) {
await this.addJob(monitor._id, monitor);
const randomOffset = Math.floor(Math.random() * 100);
setTimeout(() => {
this.addJob(monitor._id, monitor);
}, randomOffset);
}
return true;
} catch (error) {
this.logger.error({

View File

@@ -46,7 +46,7 @@ class EmailService {
*/
this.loadTemplate = (templateName) => {
try {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templatePath = this.path.join(__dirname, `../../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
return this.compile(templateContent);
} catch (error) {

View File

@@ -1,3 +1,4 @@
import CacheableLookup from "cacheable-lookup";
const SERVICE_NAME = "NetworkService";
class NetworkService {
@@ -15,7 +16,6 @@ class NetworkService {
this.NETWORK_ERROR = 5000;
this.PING_ERROR = 5001;
this.axios = axios;
this.got = got;
this.https = https;
this.jmespath = jmespath;
this.GameDig = GameDig;
@@ -26,6 +26,16 @@ class NetworkService {
this.net = net;
this.stringService = stringService;
this.settingsService = settingsService;
const cacheable = new CacheableLookup();
this.got = got.extend({
dnsCache: cacheable,
timeout: {
request: 30000,
},
retry: { limit: 1 },
});
}
// Helper functions
@@ -529,6 +539,42 @@ class NetworkService {
throw error;
}
}
async requestMatrix({ homeserverUrl, accessToken, roomId, message }) {
try {
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: message,
format: "org.matrix.custom.html",
formatted_body: message,
};
const response = await this.axios.post(url, body, {
headers: {
"Content-Type": "application/json",
},
});
return {
status: true,
code: response.status,
message: "Successfully sent Matrix notification",
};
} catch (error) {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
method: "requestMatrix",
});
return {
status: false,
code: error.response?.status || this.NETWORK_ERROR,
message: "Failed to send Matrix notification",
payload: error.response?.data,
};
}
}
}
export default NetworkService;

View File

@@ -0,0 +1,34 @@
const SERVICE_NAME = "Matrix";
class Matrix {
static SERVICE_NAME = SERVICE_NAME;
constructor({ networkService, logger }) {
this.networkService = networkService;
this.logger = logger;
}
get serviceName() {
return Matrix.SERVICE_NAME;
}
async send({ friendlyName, homeserverUrl, accessToken, roomId, message, monitorName }) {
const title = `Checkmate status for ${monitorName}`;
const formattedMessage = `## ${title}\n${message}`;
try {
await this.networkService.requestMatrix({
homeserverUrl,
accessToken,
roomId,
message: formattedMessage,
});
this.logger.info(`Successfully sent Matrix notification for ${friendlyName}`);
return true;
} catch (error) {
this.logger.error(`Failed to send Matrix notification for ${friendlyName}: ${error.message}`);
return false;
}
}
}
export default Matrix;

View File

@@ -1,4 +1,5 @@
const SERVICE_NAME = "NotificationService";
import Matrix from "./notificationProviders/matrix.js";
class NotificationService {
static SERVICE_NAME = SERVICE_NAME;
@@ -46,6 +47,14 @@ class NotificationService {
return response;
}
if (type === "matrix") {
const { friendlyName, homeserverUrl, accessToken, roomId } = notification;
const monitorName = subject;
const message = content;
const matrix = new Matrix({ networkService: this.networkService, logger: this.logger });
const success = await matrix.send({ friendlyName, homeserverUrl, accessToken, roomId, message, monitorName });
return success;
}
};
async handleNotifications(networkResponse) {

View File

@@ -381,7 +381,7 @@ class MonitorService implements IMonitorService {
// Get monitor stats
const monitorStats = await MonitorStats.findOne({
monitorId: monitor._id,
}).lean();
});
if (!monitorStats) {
throw new ApiError("Monitor stats not found", 404);

View File

@@ -575,10 +575,10 @@ const createNotificationBodyValidation = joi.object({
"any.required": "Notification name is required",
}),
type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty").required().messages({
type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty", "matrix").required().messages({
"string.empty": "Notification type is required",
"any.required": "Notification type is required",
"any.only": "Notification type must be email, webhook, or pager_duty",
"any.only": "Notification type must be email, webhook, slack, discord, pager_duty, or matrix",
}),
address: joi.when("type", {
@@ -606,8 +606,40 @@ const createNotificationBodyValidation = joi.object({
"string.uri": "Please enter a valid Webhook URL",
}),
},
{
is: "matrix",
then: joi.string().allow("").optional(),
},
],
}),
homeserverUrl: joi.when("type", {
is: "matrix",
then: joi.string().uri().required().messages({
"string.empty": "Homeserver URL cannot be empty",
"any.required": "Homeserver URL is required",
"string.uri": "Please enter a valid Homeserver URL",
}),
otherwise: joi.string().allow("").optional(),
}),
roomId: joi.when("type", {
is: "matrix",
then: joi.string().required().messages({
"string.empty": "Room ID cannot be empty",
"any.required": "Room ID is required",
}),
otherwise: joi.string().allow("").optional(),
}),
accessToken: joi.when("type", {
is: "matrix",
then: joi.string().required().messages({
"string.empty": "Access Token cannot be empty",
"any.required": "Access Token is required",
}),
otherwise: joi.string().allow("").optional(),
}),
});
//****************************************