Merge branch 'develop' into feature/email-testing-without-saving

This commit is contained in:
Alexander Holliday authored and GitHub committed 2025-05-20 13:26:53 -07:00
commit e04212f9e9
50 files changed
+1029 -980

No files matched your search

@@ -11,9 +11,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -0,0 +1,30 @@
name: Distribution deploy - Monolithic
on:
push:
branches: ["master"]
workflow_dispatch:
jobs:
docker-build-and-push-server:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-dist-mono \
-f ./docker/dist-mono/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-dist-mono
+4 -4
View File
@@ -52,7 +52,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"prettier": "^3.3.3",
"vite": "^5.2.0"
"vite": "^5.4.19"
}
},
"node_modules/@ampproject/remapping": {
@@ -6544,9 +6544,9 @@
}
},
"node_modules/vite": {
"version": "5.4.18",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz",
"integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==",
"version": "5.4.19",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
+1 -1
View File
@@ -63,6 +63,6 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"prettier": "^3.3.3",
"vite": "^5.2.0"
"vite": "^5.4.19"
}
}
@@ -5,12 +5,15 @@ import Button from "@mui/material/Button";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import PauseOutlinedIcon from "@mui/icons-material/PauseOutlined";
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
import EmailIcon from "@mui/icons-material/Email";
// Utils
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { usePauseMonitor } from "../../Hooks/useMonitorControls";
import { useSendTestEmail } from "../../Hooks/useSendTestEmail";
import { useTranslation } from "react-i18next";
/**
* MonitorDetailsControlHeader component displays the control header for monitor details.
@@ -34,11 +37,14 @@ const MonitorDetailsControlHeader = ({
}) => {
const navigate = useNavigate();
const theme = useTheme();
const { t } = useTranslation();
const [pauseMonitor, isPausing, error] = usePauseMonitor({
monitorId: monitor?._id,
triggerUpdate,
});
const [isSending, emailError, sendTestEmail] = useSendTestEmail();
if (isLoading) {
return <Skeleton />;
}
@@ -54,6 +60,24 @@ const MonitorDetailsControlHeader = ({
direction="row"
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isSending}
startIcon={<EmailIcon />}
onClick={sendTestEmail}
>
{t("sendTestEmail")}
</Button>
<Button
variant="contained"
color="secondary"
onClick={(e) => {
navigate(`/incidents/${monitor?._id}`);
}}
>
{t("menu.incidents")}
</Button>
<Button
variant="contained"
color="secondary"
+4
View File
@@ -31,8 +31,12 @@ const useSendTestEmail = () => {
});
} 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}}", {
@@ -294,7 +294,7 @@ const CreateInfrastructureMonitor = () => {
</Typography>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="h2" variant="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="p">
{t("infrastructureCreateGeneralSettingsDescription")}
</Typography>
@@ -368,7 +368,7 @@ const CreateInfrastructureMonitor = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateIncidentNotification")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateIncidentNotification")}</Typography>
<Typography component="p">
{t("distributedUptimeCreateIncidentDescription")}
</Typography>
@@ -385,7 +385,7 @@ const CreateInfrastructureMonitor = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("infrastructureCustomizeAlerts")}</Typography>
<Typography component="h2" variant="h2">{t("infrastructureCustomizeAlerts")}</Typography>
<Typography component="p">
{t("infrastructureAlertNotificationDescription")}
</Typography>
@@ -432,7 +432,7 @@ const CreateInfrastructureMonitor = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateAdvancedSettings")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateAdvancedSettings")}</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
@@ -310,7 +310,7 @@ const PageSpeedConfigure = () => {
</Stack>
<ConfigBox>
<Box>
<Typography component="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="h2" variant="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="p">
{t("pageSpeedConfigureSettingsDescription")}
</Typography>
@@ -349,7 +349,7 @@ const PageSpeedConfigure = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateIncidentNotification")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateIncidentNotification")}</Typography>
<Typography component="p">
{t("distributedUptimeCreateIncidentDescription")}
</Typography>
@@ -405,7 +405,7 @@ const PageSpeedConfigure = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateAdvancedSettings")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateAdvancedSettings")}</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
+4 -4
View File
@@ -216,7 +216,7 @@ const CreatePageSpeed = () => {
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="h2" variant="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="p">
{t("distributedUptimeCreateSelectURL")}
</Typography>
@@ -251,7 +251,7 @@ const CreatePageSpeed = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateChecks")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateChecks")}</Typography>
<Typography component="p">
{t("distributedUptimeCreateChecksDescription")}
</Typography>
@@ -300,7 +300,7 @@ const CreatePageSpeed = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateIncidentNotification")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateIncidentNotification")}</Typography>
<Typography component="p">
{t("distributedUptimeCreateIncidentDescription")}
</Typography>
@@ -320,7 +320,7 @@ const CreatePageSpeed = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateAdvancedSettings")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateAdvancedSettings")}</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
+1 -1
View File
@@ -12,7 +12,7 @@ const SettingsAbout = () => {
return (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsAbout")}</Typography>
<Typography component="h1" variant="h2">{t("settingsAbout")}</Typography>
</Box>
<Box>
<Typography component="h2">Checkmate {2.1}</Typography>
@@ -23,7 +23,7 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
<>
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsDemoMonitors")}</Typography>
<Typography component="h1" variant="h2">{t("settingsDemoMonitors")}</Typography>
<Typography sx={HEADER_SX}>{t("settingsDemoMonitorsDescription")}</Typography>
</Box>
<Box>
@@ -48,7 +48,7 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsSystemReset")}</Typography>
<Typography component="h1" variant="h2">{t("settingsSystemReset")}</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsSystemResetDescription")}
</Typography>
+1 -1
View File
@@ -75,7 +75,7 @@ const SettingsEmail = ({
return (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsEmail")}</Typography>
<Typography component="h1" variant="h2">{t("settingsEmail")}</Typography>
<Typography sx={HEADER_SX}>{t("settingsEmailDescription")}</Typography>
</Box>
<Box>
@@ -41,7 +41,7 @@ const SettingsPagespeed = ({
return (
<ConfigBox>
<Box>
<Typography component="h1">{t("pageSpeedApiKeyFieldTitle")}</Typography>
<Typography component="h1" variant="h2">{t("pageSpeedApiKeyFieldTitle")}</Typography>
<Typography sx={HEADING_SX}>{t("pageSpeedApiKeyFieldDescription")}</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
@@ -26,6 +26,7 @@ const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors
<Box>
<Typography
component="h1"
variant="h2"
sx={HEADING_SX}
>
{t("settingsHistoryAndMonitoring")}
@@ -15,7 +15,7 @@ const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
return (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsGeneralSettings")}</Typography>
<Typography component="h1" variant="h2">{t("settingsGeneralSettings")}</Typography>
<Typography sx={HEADING_SX}>
<Typography component="span">{t("settingsDisplayTimezone")}</Typography>-{" "}
{t("settingsDisplayTimezoneDescription")}
+1 -1
View File
@@ -16,7 +16,7 @@ const SettingsUI = ({ HEADING_SX, handleChange, mode, language }) => {
return (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsAppearance")}</Typography>
<Typography component="h1" variant="h2">{t("settingsAppearance")}</Typography>
<Typography sx={HEADING_SX}>{t("settingsAppearanceDescription")}</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
@@ -9,7 +9,7 @@ const ConfigStack = ({ title, description, children }) => {
return (
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">{title}</Typography>
<Typography component="h2" variant="h2">{title}</Typography>
<Typography component="p">{description}</Typography>
</Stack>
{children}
@@ -34,7 +34,7 @@ const TabSettings = ({
<Stack gap={theme.spacing(10)}>
<ConfigBox>
<Stack>
<Typography component="h2">{t("access")}</Typography>
<Typography component="h2" variant="h2">{t("access")}</Typography>
<Typography component="p">
{t("statusPageCreateSettings")}
</Typography>
@@ -51,7 +51,7 @@ const TabSettings = ({
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">{t("basicInformation")}</Typography>
<Typography component="h2" variant="h2">{t("basicInformation")}</Typography>
<Typography component="p">
{t("statusPageCreateBasicInfoDescription")}
</Typography>
@@ -82,7 +82,7 @@ const TabSettings = ({
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">{t("timezone")}</Typography>
<Typography component="h2" variant="h2">{t("timezone")}</Typography>
<Typography component="p">
{t("statusPageCreateSelectTimeZoneDescription")}
</Typography>
@@ -100,7 +100,7 @@ const TabSettings = ({
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">{t("settingsAppearance")}</Typography>
<Typography component="h2" variant="h2">{t("settingsAppearance")}</Typography>
<Typography component="p">
{t("statusPageCreateAppearanceDescription")}
</Typography>
+2 -1
View File
@@ -184,7 +184,6 @@ const CreateStatusPage = () => {
size: null,
};
}
setForm((prev) => {
return {
...prev,
@@ -194,6 +193,8 @@ const CreateStatusPage = () => {
monitors: statusPageMonitors.map((monitor) => monitor._id),
color: statusPage?.color,
logo: newLogo,
showCharts: statusPage?.showCharts ?? true,
showUptimePercentage: statusPage?.showUptimePercentage ?? true
};
});
setSelectedMonitors(statusPageMonitors);
+1 -1
View File
@@ -61,7 +61,7 @@ const BulkImport = () => {
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">{t("bulkImport.selectFileTips")}</Typography>
<Typography component="h2" variant="h2">{t("bulkImport.selectFileTips")}</Typography>
<Typography component="p">
<Trans
i18nKey="bulkImport.selectFileDescription"
+4 -4
View File
@@ -325,7 +325,7 @@ const Configure = () => {
</Stack>
<ConfigBox>
<Box>
<Typography component="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="h2" variant="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="p">
{t("distributedUptimeCreateSelectURL")}
</Typography>
@@ -371,7 +371,7 @@ const Configure = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">
<Typography component="h2" variant="h2">
{t("distributedUptimeCreateIncidentNotification")}
</Typography>
<Typography component="p">
@@ -439,7 +439,7 @@ const Configure = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("ignoreTLSError")}</Typography>
<Typography component="h2" variant="h2">{t("ignoreTLSError")}</Typography>
<Typography component="p">{t("ignoreTLSErrorDescription")}</Typography>
</Box>
<Stack>
@@ -459,7 +459,7 @@ const Configure = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">
<Typography component="h2" variant="h2">
{t("distributedUptimeCreateAdvancedSettings")}
</Typography>
</Box>
+5 -5
View File
@@ -261,7 +261,7 @@ const CreateMonitor = () => {
</Typography>
<ConfigBox>
<Box>
<Typography component="h2">{t("distributedUptimeCreateChecks")}</Typography>
<Typography component="h2" variant="h2">{t("distributedUptimeCreateChecks")}</Typography>
<Typography component="p">
{t("distributedUptimeCreateChecksDescription")}
</Typography>
@@ -342,7 +342,7 @@ const CreateMonitor = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="h2" variant="h2">{t("settingsGeneralSettings")}</Typography>
<Typography component="p">{t("uptimeCreateSelectURL")}</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
@@ -386,7 +386,7 @@ const CreateMonitor = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">
<Typography component="h2" variant="h2">
{t("distributedUptimeCreateIncidentNotification")}
</Typography>
<Typography component="p">
@@ -417,7 +417,7 @@ const CreateMonitor = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">{t("ignoreTLSError")}</Typography>
<Typography component="h2" variant="h2">{t("ignoreTLSError")}</Typography>
<Typography component="p">{t("ignoreTLSErrorDescription")}</Typography>
</Box>
<Stack>
@@ -437,7 +437,7 @@ const CreateMonitor = () => {
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">
<Typography component="h2" variant="h2">
{t("distributedUptimeCreateAdvancedSettings")}
</Typography>
</Box>
+6 -3
View File
@@ -1047,9 +1047,12 @@ class NetworkService {
form.url && fd.append("url", form.url);
form.timezone && fd.append("timezone", form.timezone);
form.color && fd.append("color", form.color);
form.showCharts && fd.append("showCharts", form.showCharts);
form.showUptimePercentage &&
fd.append("showUptimePercentage", form.showUptimePercentage);
if (form.showCharts !== undefined) {
fd.append("showCharts", String(form.showCharts));
}
if (form.showUptimePercentage !== undefined) {
fd.append("showUptimePercentage", String(form.showUptimePercentage));
}
form.monitors &&
form.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
+4 -1
View File
@@ -540,5 +540,8 @@
"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..."
},
"uptimeCreateSelectURL": "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."
"uptimeCreateSelectURL": "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.",
"sendTestEmail": "Send test email",
"emailSent": "Email sent successfully",
"failedToSendEmail": "Failed to send email"
}
+2 -1
View File
@@ -8,5 +8,6 @@ dist/redis/data/*
prod/mongo/data/*
prod/redis/data/*
dist/docker-compose-test.yaml
/dist-mono/mongo/data/*
/dist-mono/redis/data/*
*.env
prod/certbot/*
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# Change directory to root Server directory for correct Docker Context
cd "$(dirname "$0")"
cd ../..
# Define service names and their corresponding Dockerfiles in parallel arrays
services=("mono_mongo" "mono_redis" "mono_server")
dockerfiles=(
"./docker/dist-mono/mongoDB.Dockerfile"
"./docker/dist-mono/redis.Dockerfile"
"./docker/dist-mono/server.Dockerfile"
)
# Loop through each service and build the corresponding image
for i in "${!services[@]}"; do
service="${services[$i]}"
dockerfile="${dockerfiles[$i]}"
docker build -f "$dockerfile" -t "$service" .
# Check if the build succeeded
if [ $? -ne 0 ]; then
echo "Error building $service image. Exiting..."
exit 1
fi
done
echo "All images built successfully"
+40
View File
@@ -0,0 +1,40 @@
services:
server:
image: ghcr.io/bluewave-labs/checkmate:backend-dist-mono
restart: always
ports:
- "52345:52345"
environment:
- UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1
- UPTIME_APP_CLIENT_HOST=http://localhost
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db?replicaSet=rs0
- REDIS_URL=redis://redis:6379
- CLIENT_HOST=http://localhost
- JWT_SECRET=my_secret
depends_on:
- redis
- mongodb
redis:
image: ghcr.io/bluewave-labs/checkmate:redis-dist
restart: always
volumes:
- ./redis/data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 5s
mongodb:
image: ghcr.io/bluewave-labs/checkmate:mongo-dist
restart: always
command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"]
volumes:
- ./mongo/data:/data/db
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
+3
View File
@@ -0,0 +1,3 @@
FROM mongo
EXPOSE 27017
CMD ["mongod"]
+2
View File
@@ -0,0 +1,2 @@
FROM redis
EXPOSE 6379
+25
View File
@@ -0,0 +1,25 @@
FROM node:20-alpine AS frontend-build
WORKDIR /app/client
COPY client/package*.json ./
RUN npm install
COPY client ./
RUN npm run build
FROM node:20-alpine AS app
WORKDIR /app/server
COPY server ./
COPY --from=frontend-build /app/client/dist ./public
RUN npm install
RUN chmod +x ./scripts/inject-vars.sh
EXPOSE 52345
CMD ./scripts/inject-vars.sh && node ./index.js
+1
View File
@@ -2,6 +2,7 @@ node_modules
.env
*.log
*.sh
!scripts/inject-vars.sh
.nyc_output
coverage
.clinic
+2 -2
View File
@@ -23,7 +23,7 @@ class JobQueueController {
getJobs = async (req, res, next) => {
try {
const jobs = await this.jobQueue.getJobStats();
const jobs = await this.jobQueue.getJobs();
return res.success({
msg: this.stringService.queueGetMetrics,
data: jobs,
@@ -60,7 +60,7 @@ class JobQueueController {
flushQueue = async (req, res, next) => {
try {
const result = await this.jobQueue.flushQueue();
const result = await this.jobQueue.flushQueues();
return res.success({
msg: this.stringService.jobQueueFlush,
data: result,
+56 -2
View File
@@ -1,13 +1,14 @@
import { updateAppSettingsBodyValidation } from "../validation/joi.js";
import { handleValidationError, handleError } from "./controllerUtils.js";
import { sendTestEmailBodyValidation } from "../validation/joi.js";
const SERVICE_NAME = "SettingsController";
class SettingsController {
constructor(db, settingsService, stringService) {
constructor({ db, settingsService, stringService, emailService }) {
this.db = db;
this.settingsService = settingsService;
this.stringService = stringService;
this.emailService = emailService;
}
getAppSettings = async (req, res, next) => {
@@ -55,6 +56,59 @@ class SettingsController {
next(handleError(error, SERVICE_NAME, "updateAppSettings"));
}
};
sendTestEmail = async (req, res, next) => {
try {
await sendTestEmailBodyValidation.validateAsync(req.body);
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const {
to,
systemEmailHost,
systemEmailPort,
systemEmailAddress,
systemEmailPassword,
systemEmailUser,
systemEmailConnectionHost,
} = req.body;
const subject = this.stringService.testEmailSubject;
const context = { testName: "Monitoring System" };
const messageId = await this.emailService.buildAndSendEmail(
"testEmailTemplate",
context,
to,
subject,
{
systemEmailHost,
systemEmailPort,
systemEmailUser,
systemEmailAddress,
systemEmailPassword,
systemEmailConnectionHost,
}
);
if (!messageId) {
return res.error({
msg: "Failed to send test email.",
});
}
return res.success({
msg: this.stringService.sendTestEmail,
data: { messageId },
});
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
};
}
export default SettingsController;
+1 -1
View File
@@ -43,7 +43,7 @@ const createCheck = async (checkData) => {
const createChecks = async (checks) => {
try {
await Check.insertMany(checks);
await Check.insertMany(checks, { ordered: false });
} catch (error) {
error.service = SERVICE_NAME;
error.method = "createCheck";
@@ -128,9 +128,8 @@ const createDistributedChecks = async (checksData) => {
};
});
// Execute bulk operation
await DistributedUptimeCheck.bulkWrite(bulkOps, {
ordered: false, // Allow parallel processing
ordered: false,
});
} catch (error) {
error.service = SERVICE_NAME;
@@ -51,7 +51,7 @@ const createHardwareCheck = async (hardwareCheckData) => {
const createHardwareChecks = async (hardwareChecks) => {
try {
await HardwareCheck.insertMany(hardwareChecks);
await HardwareCheck.insertMany(hardwareChecks, { ordered: false });
return true;
} catch (error) {
error.service = SERVICE_NAME;
@@ -26,7 +26,7 @@ const createPageSpeedCheck = async (pageSpeedCheckData) => {
};
const createPageSpeedChecks = async (pageSpeedChecks) => {
try {
await PageSpeedCheck.insertMany(pageSpeedChecks);
await PageSpeedCheck.insertMany(pageSpeedChecks, { ordered: false });
return true;
} catch (error) {
error.service = SERVICE_NAME;
+31 -28
View File
@@ -46,7 +46,8 @@ import DiagnosticRoutes from "./routes/diagnosticRoute.js";
import DiagnosticController from "./controllers/diagnosticController.js";
//JobQueue service and dependencies
import JobQueue from "./service/jobQueue.js";
import JobQueue from "./service/JobQueue/JobQueue.js";
import JobQueueHelper from "./service/JobQueue/JobQueueHelper.js";
import { Queue, Worker } from "bullmq";
//Network service and dependencies
@@ -98,6 +99,8 @@ const openApiSpec = JSON.parse(
fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8")
);
const frontendPath = path.join(__dirname, "public");
let server;
const shutdown = async () => {
@@ -112,17 +115,13 @@ const shutdown = async () => {
service: SERVICE_NAME,
method: "shutdown",
});
// flush Redis
const redisService = ServiceRegistry.get(RedisService.SERVICE_NAME);
await redisService.flushall();
await ServiceRegistry.get(RedisService.SERVICE_NAME).flushRedis();
process.exit(1);
}, SHUTDOWN_TIMEOUT);
try {
server.close();
await ServiceRegistry.get(JobQueue.SERVICE_NAME).obliterate();
await ServiceRegistry.get(JobQueue.SERVICE_NAME).shutdown();
await ServiceRegistry.get(MongoDB.SERVICE_NAME).disconnect();
await ServiceRegistry.get(RedisService.SERVICE_NAME).flushall();
logger.info({ message: "Graceful shutdown complete" });
process.exit(0);
} catch (error) {
@@ -182,23 +181,23 @@ const startApp = async () => {
stringService
);
const redisService = await RedisService.createInstance({
logger,
IORedis,
SettingsService: settingsService,
});
const redisService = new RedisService({ Redis: IORedis, logger });
const jobQueue = new JobQueue({
db,
statusService,
networkService,
notificationService,
settingsService,
stringService,
logger,
const jobQueueHelper = new JobQueueHelper({
redisService,
Queue,
Worker,
redisService,
logger,
db,
networkService,
statusService,
notificationService,
});
const jobQueue = await JobQueue.create({
db,
jobQueueHelper,
logger,
stringService,
});
// Register services
@@ -241,11 +240,12 @@ const startApp = async () => {
ServiceRegistry.get(EmailService.SERVICE_NAME)
);
const settingsController = new SettingsController(
ServiceRegistry.get(MongoDB.SERVICE_NAME),
ServiceRegistry.get(SettingsService.SERVICE_NAME),
ServiceRegistry.get(StringService.SERVICE_NAME)
);
const settingsController = new SettingsController({
db: ServiceRegistry.get(MongoDB.SERVICE_NAME),
settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME),
stringService: ServiceRegistry.get(StringService.SERVICE_NAME),
emailService: ServiceRegistry.get(EmailService.SERVICE_NAME),
});
const checkController = new CheckController(
ServiceRegistry.get(MongoDB.SERVICE_NAME),
@@ -309,9 +309,8 @@ const startApp = async () => {
);
const notificationRoutes = new NotificationRoutes(notificationController);
const diagnosticRoutes = new DiagnosticRoutes(diagnosticController);
// Init job queue
await jobQueue.initJobQueue();
// Middleware
app.use(express.static(frontendPath));
app.use(responseHandler);
app.use(
cors({
@@ -357,6 +356,10 @@ const startApp = async () => {
status: "OK",
});
});
// FE routes
app.get("*", (req, res) => {
res.sendFile(path.join(frontendPath, "index.html"));
});
app.use(handleErrors);
};
+11 -4
View File
@@ -26,8 +26,9 @@
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.14",
"papaparse": "^5.5.2",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
@@ -5499,6 +5500,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/papaparse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz",
"integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7290,9 +7297,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
"integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
+2 -2
View File
@@ -4,8 +4,8 @@ import multer from "multer";
import { fetchMonitorCertificate } from "../controllers/controllerUtils.js";
const upload = multer({
storage: multer.memoryStorage() // Store file in memory as Buffer
});
storage: multer.memoryStorage(), // Store file in memory as Buffer
});
class MonitorRoutes {
constructor(monitorController) {
+5
View File
@@ -15,6 +15,11 @@ class SettingsRoutes {
isAllowed(["admin", "superadmin"]),
this.settingsController.updateAppSettings
);
this.router.post(
"/test-email",
isAllowed(["admin", "superadmin"]),
this.settingsController.sendTestEmail
);
}
getRouter() {
+7
View File
@@ -0,0 +1,7 @@
for i in $(env | grep UPTIME_APP_)
do
key=$(echo $i | cut -d '=' -f 1)
value=$(echo $i | cut -d '=' -f 2-)
echo $key=$value
find ./public/assets -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' +
done
+310
View File
@@ -0,0 +1,310 @@
const QUEUE_NAMES = ["uptime", "pagespeed", "hardware", "distributed"];
const SERVICE_NAME = "JobQueue";
const HEALTH_CHECK_INTERVAL = 10 * 60 * 1000; // 10 minutes
const QUEUE_LOOKUP = {
hardware: "hardware",
http: "uptime",
ping: "uptime",
port: "uptime",
docker: "uptime",
pagespeed: "pagespeed",
distributed_http: "distributed",
};
const getSchedulerId = (monitor) => `scheduler:${monitor.type}:${monitor._id}`;
class JobQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, jobQueueHelper, logger, stringService }) {
this.db = db;
this.jobQueueHelper = jobQueueHelper;
this.stringService = stringService;
this.logger = logger;
this.queues = {};
this.workers = [];
}
static async create({ db, jobQueueHelper, logger, stringService }) {
const instance = new JobQueue({ db, jobQueueHelper, logger, stringService });
await instance.init();
return instance;
}
async init() {
try {
await this.initQueues();
await this.initWorkers();
const monitors = await this.db.getAllMonitors();
await Promise.all(
monitors
.filter((monitor) => monitor.isActive)
.map(async (monitor) => {
try {
await this.addJob(monitor._id, monitor);
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "initJobQueue",
stack: error.stack,
});
}
})
);
this.healthCheckInterval = setInterval(async () => {
try {
const queueHealthChecks = await this.checkQueueHealth();
const queueIsStuck = queueHealthChecks.some((healthCheck) => healthCheck.stuck);
if (queueIsStuck) {
this.logger.warn({
message: "Queue is stuck",
service: SERVICE_NAME,
method: "periodicHealthCheck",
details: queueHealthChecks,
});
await this.flushQueues();
}
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "periodicHealthCheck",
stack: error.stack,
});
}
}, HEALTH_CHECK_INTERVAL);
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "initJobQueue",
stack: error.stack,
});
}
}
async initQueues() {
const readyPromises = [];
for (const queueName of QUEUE_NAMES) {
const q = this.jobQueueHelper.createQueue(queueName);
this.queues[queueName] = q;
readyPromises.push(q.waitUntilReady());
}
await Promise.all(readyPromises);
this.logger.info({
message: "Queues ready",
service: SERVICE_NAME,
method: "initQueues",
});
}
async initWorkers() {
const workerReadyPromises = [];
for (const queueName of QUEUE_NAMES) {
const worker = this.jobQueueHelper.createWorker(queueName, this.queues[queueName]);
this.workers.push(worker);
workerReadyPromises.push(worker.waitUntilReady());
}
await Promise.all(workerReadyPromises);
this.logger.info({
message: "Workers ready",
service: SERVICE_NAME,
method: "initWorkers",
});
}
async addJob(jobName, monitor) {
this.logger.info({
message: `Adding job ${monitor?.url ?? "No URL"}`,
service: SERVICE_NAME,
method: "addJob",
});
const queueName = QUEUE_LOOKUP[monitor.type];
const queue = this.queues[queueName];
if (typeof queue === "undefined") {
throw new Error(`Queue for ${monitor.type} not found`);
}
const jobTemplate = {
name: jobName,
data: monitor,
opts: {
attempts: 1,
backoff: {
type: "exponential",
delay: 1000,
},
removeOnComplete: true,
removeOnFail: false,
timeout: 1 * 60 * 1000,
},
};
const schedulerId = getSchedulerId(monitor);
await queue.upsertJobScheduler(
schedulerId,
{ every: monitor?.interval ?? 60000 },
jobTemplate
);
}
async deleteJob(monitor) {
try {
const queue = this.queues[QUEUE_LOOKUP[monitor.type]];
const schedulerId = getSchedulerId(monitor);
const wasDeleted = await queue.removeJobScheduler(schedulerId);
if (wasDeleted === true) {
this.logger.info({
message: this.stringService.jobQueueDeleteJob,
service: SERVICE_NAME,
method: "deleteJob",
details: `Deleted job ${monitor._id}`,
});
return true;
} else {
this.logger.error({
message: this.stringService.jobQueueDeleteJob,
service: SERVICE_NAME,
method: "deleteJob",
details: `Failed to delete job ${monitor._id}`,
});
return false;
}
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteJob") : null;
throw error;
}
}
async getJobs() {
try {
let stats = {};
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const queue = this.queues[name];
const jobs = await queue.getJobs();
const ret = await Promise.all(
jobs.map(async (job) => {
const state = await job.getState();
return { url: job.data.url, state, progress: job.progress };
})
);
stats[name] = { jobs: ret };
})
);
return stats;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getJobStats") : null;
throw error;
}
}
async getMetrics() {
try {
let metrics = {};
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const queue = this.queues[name];
const [waiting, active, failed, delayed, repeatableJobs] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
queue.getRepeatableJobs(),
]);
metrics[name] = {
waiting,
active,
failed,
delayed,
repeatableJobs: repeatableJobs.length,
};
})
);
return metrics;
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "getMetrics",
stack: error.stack,
});
}
}
async checkQueueHealth() {
const res = [];
for (const queueName of QUEUE_NAMES) {
const q = this.queues[queueName];
await q.waitUntilReady();
const lastJobProcessedTime = q.lastJobProcessedTime;
const currentTime = Date.now();
const timeDiff = currentTime - lastJobProcessedTime;
// Check for jobs
const jobCounts = await q.getJobCounts();
const hasJobs = Object.values(jobCounts).some((count) => count > 0);
res.push({
queueName,
timeSinceLastJob: timeDiff,
stuck: hasJobs && timeDiff > 10000,
jobCounts,
});
}
return res;
}
async flushQueues() {
try {
this.logger.warn({
message: "Flushing queues",
method: "flushQueues",
service: SERVICE_NAME,
});
for (const worker of this.workers) {
await worker.close();
}
this.workers = [];
for (const queue of Object.values(this.queues)) {
await queue.obliterate();
}
this.queue = {};
await this.init();
return true;
} catch (error) {
this.logger.warn({
message: `${error.message} - Flushing redis manually`,
service: SERVICE_NAME,
method: "flushQueues",
});
return await this.jobQueueHelper.flushRedis();
}
}
async shutdown() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
for (const worker of this.workers) {
await worker.close();
}
for (const queue of Object.values(this.queues)) {
await queue.obliterate();
}
}
}
export default JobQueue;
+318
View File
@@ -0,0 +1,318 @@
const SERVICE_NAME = "JobQueueHelper";
class JobQueueHelper {
constructor({
redisService,
Queue,
Worker,
logger,
db,
networkService,
statusService,
notificationService,
}) {
this.db = db;
this.redisService = redisService;
this.Queue = Queue;
this.Worker = Worker;
this.logger = logger;
this.networkService = networkService;
this.statusService = statusService;
this.notificationService = notificationService;
}
createQueue(queueName) {
const connection = this.redisService.getNewConnection();
const q = new this.Queue(queueName, {
connection,
});
q.lastJobProcessedTime = Date.now();
q.on("cleaned", (jobs, type) => {
this.logger.debug({
message: `Queue ${queueName} is cleaned with jobs: ${jobs} and type: ${type}`,
service: SERVICE_NAME,
method: "createQueue:cleaned",
});
});
q.on("error", (err) => {
this.logger.error({
message: `Queue ${queueName} is error with msg: ${err}`,
service: SERVICE_NAME,
method: "createQueue:error",
});
});
q.on("ioredis:close", () => {
this.logger.debug({
message: `Queue ${queueName} is ioredis:close`,
service: SERVICE_NAME,
method: "createQueue:ioredis:close",
});
});
q.on("paused", () => {
this.logger.debug({
message: `Queue ${queueName} is paused`,
service: SERVICE_NAME,
method: "createQueue:paused",
});
});
q.on("progress", (job, progress) => {
this.logger.debug({
message: `Queue ${queueName} is progress with msg: ${progress}`,
service: SERVICE_NAME,
method: "createQueue:progress",
});
});
q.on("removed", (job) => {
this.logger.debug({
message: `Queue ${queueName} is removed with msg: ${job}`,
service: SERVICE_NAME,
method: "createQueue:removed",
});
});
q.on("resumed", () => {
this.logger.debug({
message: `Queue ${queueName} is resumed`,
service: SERVICE_NAME,
method: "createQueue:resumed",
});
});
q.on("waiting", () => {
this.logger.debug({
message: `Queue ${queueName} is waiting`,
service: SERVICE_NAME,
method: "createQueue:waiting",
});
});
return q;
}
createWorker(queueName, queue) {
const connection = this.redisService.getNewConnection({
maxRetriesPerRequest: null,
});
const worker = new this.Worker(queueName, this.createJobHandler(queue), {
connection,
concurrency: 50,
});
worker.on("active", (job) => {
this.logger.debug({
message: `Worker ${queueName} is active`,
service: SERVICE_NAME,
method: "createWorker:active",
});
});
worker.on("closed", () => {
this.logger.debug({
message: `Worker ${queueName} is closed`,
service: SERVICE_NAME,
method: "createWorker:closed",
});
});
worker.on("closing", (msg) => {
this.logger.debug({
message: `Worker ${queueName} is closing with msg: ${msg}`,
service: SERVICE_NAME,
method: "createWorker:closing",
});
});
worker.on("completed", (job) => {
this.logger.debug({
message: `Worker ${queueName} is completed`,
service: SERVICE_NAME,
method: "createWorker:completed",
});
});
worker.on("drained", () => {
this.logger.debug({
message: `Worker ${queueName} is drained`,
service: SERVICE_NAME,
method: "createWorker:drained",
});
});
worker.on("error", (failedReason) => {
this.logger.error({
message: `Worker ${queueName} is error with msg: ${failedReason}`,
service: SERVICE_NAME,
method: "createWorker:error",
});
});
worker.on("failed", (job, error, prev) => {
this.logger.error({
message: `Worker ${queueName} is failed with msg: ${error.message}`,
service: error?.service ?? SERVICE_NAME,
method: error?.method ?? "createWorker:failed",
stack: error?.stack,
});
});
worker.on("ioredis:close", () => {
this.logger.debug({
message: `Worker ${queueName} is ioredis:close`,
service: SERVICE_NAME,
method: "createWorker:ioredis:close",
});
});
worker.on("paused", () => {
this.logger.debug({
message: `Worker ${queueName} is paused`,
service: SERVICE_NAME,
method: "createWorker:paused",
});
});
worker.on("progress", (job, progress) => {
this.logger.debug({
message: `Worker ${queueName} is progress with msg: ${progress}`,
service: SERVICE_NAME,
method: "createWorker:progress",
});
});
worker.on("ready", () => {
this.logger.debug({
message: `Worker ${queueName} is ready`,
service: SERVICE_NAME,
method: "createWorker:ready",
});
});
worker.on("resumed", () => {
this.logger.debug({
message: `Worker ${queueName} is resumed`,
service: SERVICE_NAME,
method: "createWorker:resumed",
});
});
worker.on("stalled", () => {
this.logger.warn({
message: `Worker ${queueName} is stalled`,
service: SERVICE_NAME,
method: "createWorker:stalled",
});
});
return worker;
}
async isInMaintenanceWindow(monitorId) {
const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId);
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
const now = new Date();
const repeatInterval = window.repeat || 0;
// If start is < now and end > now, we're in maintenance
if (start <= now && end >= now) return true;
// If maintenance window was set in the past with a repeat,
// we need to advance start and end to see if we are in range
while (start < now && repeatInterval !== 0) {
start.setTime(start.getTime() + repeatInterval);
end.setTime(end.getTime() + repeatInterval);
if (start <= now && end >= now) {
return true;
}
}
return false;
}
return acc;
}, false);
return maintenanceWindowIsActive;
}
createJobHandler(q) {
return async (job) => {
try {
// Update the last job processed time for this queue
q.lastJobProcessedTime = Date.now();
// Get all maintenance windows for this monitor
await job.updateProgress(0);
const monitorId = job.data._id;
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId);
// If a maintenance window is active, we're done
if (maintenanceWindowActive) {
await job.updateProgress(100);
this.logger.info({
message: `Monitor ${monitorId} is in maintenance window`,
service: SERVICE_NAME,
method: "createWorker",
});
return false;
}
// Get the current status
await job.updateProgress(30);
const networkResponse = await this.networkService.getStatus(job);
if (
job.data.type === "distributed_http" ||
job.data.type === "distributed_test"
) {
await job.updateProgress(100);
return true;
}
// If the network response is not found, we're done
if (!networkResponse) {
await job.updateProgress(100);
return false;
}
// Handle status change
await job.updateProgress(60);
const { monitor, statusChanged, prevStatus } =
await this.statusService.updateStatus(networkResponse);
// Handle notifications
await job.updateProgress(80);
this.notificationService
.handleNotifications({
...networkResponse,
monitor,
prevStatus,
statusChanged,
})
.catch((error) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "createJobHandler",
details: `Error sending notifications for job ${job.id}: ${error.message}`,
stack: error.stack,
});
});
await job.updateProgress(100);
return true;
} catch (error) {
await job.updateProgress(100);
throw error;
}
};
}
async flushRedis() {
try {
const connection = this.redisService.getNewConnection();
const flushResult = await connection.flushall();
return flushResult;
} catch (error) {
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "flushRedis",
});
return false;
}
}
}
export default JobQueueHelper;
+10 -2
View File
@@ -89,8 +89,16 @@ class EmailService {
* @param {string} subject - The subject of the email.
* @returns {Promise<string>} A promise that resolves to the messageId of the sent email.
*/
buildAndSendEmail = async (template, context, to, subject) => {
buildAndSendEmail = async (template, context, to, subject, transportConfig) => {
// TODO - Consider an update transporter method so this only needs to be recreated when smtp settings change
let config;
if (typeof transportConfig !== "undefined") {
config = transportConfig;
} else {
config = this.settingsService.getDBSettings();
}
const {
systemEmailHost,
systemEmailPort,
@@ -98,7 +106,7 @@ class EmailService {
systemEmailAddress,
systemEmailPassword,
systemEmailConnectionHost,
} = await this.settingsService.getDBSettings();
} = config;
const baseEmailConfig = {
host: systemEmailHost,
-814
View File
@@ -1,814 +0,0 @@
const QUEUE_NAMES = ["uptime", "pagespeed", "hardware", "distributed"];
const SERVICE_NAME = "JobQueue";
const JOBS_PER_WORKER = 5;
const HEALTH_CHECK_INTERVAL = 10 * 60 * 1000; // 10 minutes
const QUEUE_LOOKUP = {
hardware: "hardware",
http: "uptime",
ping: "uptime",
port: "uptime",
docker: "uptime",
pagespeed: "pagespeed",
distributed_http: "distributed",
};
const getSchedulerId = (monitor) => `scheduler:${monitor.type}:${monitor._id}`;
class NewJobQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor({
db,
statusService,
networkService,
notificationService,
settingsService,
stringService,
logger,
Queue,
Worker,
redisService,
}) {
this.connection = redisService.getConnection();
this.queues = {};
this.workers = {};
this.lastJobProcessedTime = {};
this.db = db;
this.networkService = networkService;
this.statusService = statusService;
this.notificationService = notificationService;
this.settingsService = settingsService;
this.logger = logger;
this.Worker = Worker;
this.stringService = stringService;
QUEUE_NAMES.forEach((name) => {
const q = new Queue(name, { connection: this.connection });
this.lastJobProcessedTime[q.name] = Date.now();
q.on("error", (error) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "queue:error",
stack: error.stack,
});
});
this.queues[name] = q;
this.workers[name] = [];
});
this.healthCheckInterval = setInterval(async () => {
try {
const health = await this.checkQueueHealth();
if (health.stuck === true) {
this.logger.error({
message: `Queue is stuck: ${health.stuckQueues.join(", ")}`,
service: SERVICE_NAME,
method: "healthCheckInterval",
});
await this.flushQueue();
}
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "periodicHealthCheck",
stack: error.stack,
});
}
}, HEALTH_CHECK_INTERVAL);
}
/**
* Initializes job queues by adding jobs for all active monitors
* @async
* @function initJobQueue
* @description Retrieves all monitors from the database and adds jobs for active ones to their respective queues
* @throws {Error} If there's an error retrieving monitors or adding jobs
* @returns {Promise<void>}
*/
async initJobQueue() {
await this.connection.flushall();
const monitors = await this.db.getAllMonitors();
await Promise.all(
monitors
.filter((monitor) => monitor.isActive)
.map(async (monitor) => {
try {
await this.addJob(monitor._id, monitor);
} catch (error) {
this.logger.error({
message: `Failed to add job for monitor ${monitor._id}:`,
service: SERVICE_NAME,
method: "initJobQueue",
stack: error.stack,
});
}
})
);
}
/**
* Checks if a monitor is currently in a maintenance window
* @async
* @param {string} monitorId - The ID of the monitor to check
* @returns {Promise<boolean>} Returns true if the monitor is in an active maintenance window, false otherwise
* @throws {Error} If there's an error retrieving maintenance windows from the database
* @description
* Retrieves all maintenance windows for a monitor and checks if any are currently active.
* A maintenance window is considered active if:
* 1. The window is marked as active AND
* 2. Either:
* - Current time falls between start and end times
* - For repeating windows: Current time falls between any repeated interval
*/
async isInMaintenanceWindow(monitorId) {
const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId);
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
const now = new Date();
const repeatInterval = window.repeat || 0;
// If start is < now and end > now, we're in maintenance
if (start <= now && end >= now) return true;
// If maintenance window was set in the past with a repeat,
// we need to advance start and end to see if we are in range
while (start < now && repeatInterval !== 0) {
start.setTime(start.getTime() + repeatInterval);
end.setTime(end.getTime() + repeatInterval);
if (start <= now && end >= now) {
return true;
}
}
return false;
}
return acc;
}, false);
return maintenanceWindowIsActive;
}
/**
* Creates a job processing handler for monitor checks
* @function createJobHandler
* @returns {Function} An async function that processes monitor check jobs
* @description
* Creates and returns a job handler that:
* 1. Checks if monitor is in maintenance window
* 2. If not in maintenance, performs network status check
* 3. Updates monitor status in database
* 4. Triggers notifications if status changed
*
* @param {Object} job - The job to process
* @param {Object} job.data - The monitor data
* @param {string} job.data._id - Monitor ID
* @param {string} job.id - Job ID
*
* @throws {Error} Logs errors but doesn't throw them to prevent job failure
* @returns {Promise<void>} Resolves when job processing is complete
*/
createJobHandler() {
return async (job) => {
try {
// Update the last job processed time for this queue
this.lastJobProcessedTime[job.queue.name] = Date.now();
// Get all maintenance windows for this monitor
await job.updateProgress(0);
const monitorId = job.data._id;
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId);
// If a maintenance window is active, we're done
if (maintenanceWindowActive) {
await job.updateProgress(100);
this.logger.info({
message: `Monitor ${monitorId} is in maintenance window`,
service: SERVICE_NAME,
method: "createWorker",
});
return false;
}
// Get the current status
await job.updateProgress(30);
const networkResponse = await this.networkService.getStatus(job);
if (
job.data.type === "distributed_http" ||
job.data.type === "distributed_test"
) {
return;
}
// Handle status change
await job.updateProgress(60);
const { monitor, statusChanged, prevStatus } =
await this.statusService.updateStatus(networkResponse);
// Handle notifications
await job.updateProgress(80);
this.notificationService
.handleNotifications({
...networkResponse,
monitor,
prevStatus,
statusChanged,
})
.catch((error) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "createJobHandler",
details: `Error sending notifications for job ${job.id}: ${error.message}`,
stack: error.stack,
});
});
await job.updateProgress(100);
return true;
} catch (error) {
this.logger.error({
message: error.message,
service: error.service ?? SERVICE_NAME,
method: error.method ?? "createJobHandler",
details: `Error processing job ${job.id}: ${error.message}`,
stack: error.stack,
});
throw error;
}
};
}
/**
* Creates a new worker for processing jobs in a queue
* @param {Queue} queue - The BullMQ queue to create a worker for
* @returns {Worker} A new BullMQ worker instance
* @description
* Creates and configures a new worker with:
* - Queue-specific job handler
* - Redis connection settings
* - Default worker options
* The worker processes jobs from the specified queue using the job handler
* created by createJobHandler()
*
* @throws {Error} If worker creation fails or connection is invalid
*/
createWorker(queue) {
try {
const worker = new this.Worker(queue.name, this.createJobHandler(), {
connection: this.connection,
concurrency: 5,
stalledInterval: 10000,
maxStalledCount: 1,
lockDuration: 60000,
});
worker.on("failed", (job, err) => {
this.logger.error({
message: `Job ${job.id} failed: ${err.message}`,
service: SERVICE_NAME,
method: "worker:failed",
stack: err.stack,
jobData: job.data,
});
});
worker.on("error", (job, err) => {
this.logger.error({
message: `Job ${job.id} error: ${err.message}`,
service: SERVICE_NAME,
method: "worker:error",
stack: err.stack,
jobData: job.data,
});
});
worker.on("stalled", (jobId) => {
this.logger.warn({
message: `Job ${jobId} stalled`,
service: SERVICE_NAME,
method: "worker:stalled",
});
});
return worker;
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "createWorker",
stack: error.stack,
});
error.service = SERVICE_NAME;
error.method = "createWorker";
throw error;
}
}
/**
* Gets stats related to the workers
* This is used for scaling workers right now
* In the future we will likely want to scale based on server performance metrics
* CPU Usage & memory usage, if too high, scale down workers.
* When to scale up? If jobs are taking too long to complete?
* @async
* @returns {Promise<WorkerStats>} - Returns the worker stats
*/
async getWorkerStats(queue) {
try {
const jobs = await queue.getRepeatableJobs();
const load = jobs.length / this.workers[queue.name].length;
return { jobs, load };
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getWorkerStats") : null;
throw error;
}
}
/**
* Scales workers up or down based on queue load
* @async
* @param {Object} workerStats - Statistics about current worker load
* @param {number} workerStats.load - Current load per worker
* @param {Array} workerStats.jobs - Array of current jobs
* @param {Queue} queue - The BullMQ queue to scale workers for
* @returns {Promise<boolean>} True if scaling occurred, false if no scaling was needed
* @throws {Error} If no workers array exists for the queue
* @description
* Scales workers based on these rules:
* - Maintains minimum of 5 workers
* - Adds workers if load exceeds JOBS_PER_WORKER
* - Removes workers if load is below JOBS_PER_WORKER
* - Creates initial workers if none exist
* Worker scaling is calculated based on excess jobs or excess capacity
*/
async scaleWorkers(workerStats, queue) {
const workers = this.workers[queue.name];
if (workers === undefined) {
throw new Error(`No workers found for ${queue.name}`);
}
if (workers.length === 0) {
// There are no workers, need to add one
for (let i = 0; i < 5; i++) {
const worker = this.createWorker(queue);
workers.push(worker);
}
return true;
}
if (workerStats.load > JOBS_PER_WORKER) {
// Find out how many more jobs we have than current workers can handle
const excessJobs = workerStats.jobs.length - workers.length * JOBS_PER_WORKER;
// Divide by jobs/worker to find out how many workers to add
const workersToAdd = Math.ceil(excessJobs / JOBS_PER_WORKER);
for (let i = 0; i < workersToAdd; i++) {
const worker = this.createWorker(queue);
workers.push(worker);
}
return true;
}
if (workerStats.load < JOBS_PER_WORKER) {
// Find out how much excess capacity we have
const workerCapacity = workers.length * JOBS_PER_WORKER;
const excessCapacity = workerCapacity - workerStats.jobs.length;
// Calculate how many workers to remove
let workersToRemove = Math.floor(excessCapacity / JOBS_PER_WORKER); // Make sure there are always at least 5
while (workersToRemove > 0 && workers.length > 5) {
const worker = workers.pop();
workersToRemove--;
await worker.close().catch((error) => {
// Catch the error instead of throwing it
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "scaleWorkers",
stack: error.stack,
});
});
}
return true;
}
return false;
}
/**
* Gets all jobs in the queue.
*
* @async
* @returns {Promise<Array<Job>>}
* @throws {Error} - Throws error if getting jobs fails
*/
async getJobs(queue) {
try {
const jobs = await queue.getRepeatableJobs();
return jobs;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getJobs") : null;
throw error;
}
}
/**
* Retrieves detailed statistics about jobs and workers for all queues
* @async
* @returns {Promise<Object>} Queue statistics object
* @throws {Error} If there's an error retrieving job information
* @description
* Returns an object with statistics for each queue including:
* - List of jobs with their URLs and current states
* - Number of workers assigned to the queue
*/
async getJobStats() {
try {
let stats = {};
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const queue = this.queues[name];
const jobs = await queue.getJobs();
const ret = await Promise.all(
jobs.map(async (job) => {
const state = await job.getState();
return { url: job.data.url, state, progress: job.progress };
})
);
stats[name] = { jobs: ret, workers: this.workers[name].length };
})
);
return stats;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getJobStats") : null;
throw error;
}
}
/**
* Adds both immediate and repeatable jobs to the appropriate queue
* @async
* @param {string} jobName - Name identifier for the job
* @param {Object} monitor - Job data and configuration
* @param {string} monitor.type - Type of monitor/queue ('uptime', 'pagespeed', 'hardware')
* @param {string} [monitor.url] - URL to monitor (optional)
* @param {number} [monitor.interval=60000] - Repeat interval in milliseconds
* @param {string} monitor._id - Monitor ID
* @throws {Error} If queue not found for payload type
* @throws {Error} If job addition fails
* @description
* 1. Identifies correct queue based on payload type
* 2. Adds immediate job execution
* 3. Adds repeatable job with specified interval
* 4. Scales workers based on updated queue load
* Jobs are configured with exponential backoff, single attempt,
* and automatic removal on completion
*/
async addJob(jobName, monitor) {
try {
this.logger.info({
message: `Adding job ${monitor?.url ?? "No URL"}`,
service: SERVICE_NAME,
method: "addJob",
});
// Find the correct queue
const queue = this.queues[QUEUE_LOOKUP[monitor.type]];
if (queue === undefined) {
throw new Error(`Queue for ${monitor.type} not found`);
}
const jobTemplate = {
name: jobName,
data: monitor,
opts: {
attempts: 1,
backoff: {
type: "exponential",
delay: 1000,
},
removeOnComplete: true,
removeOnFail: false,
timeout: 1 * 60 * 1000,
},
};
const schedulerId = getSchedulerId(monitor);
await queue.upsertJobScheduler(
schedulerId,
{ every: monitor?.interval ?? 60000 },
jobTemplate
);
const workerStats = await this.getWorkerStats(queue);
await this.scaleWorkers(workerStats, queue);
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "addJob") : null;
throw error;
}
}
/**
* Deletes a repeatable job from its queue and adjusts worker scaling
* @async
* @param {Object} monitor - Monitor object containing job details
* @param {string} monitor._id - ID of the monitor/job to delete
* @param {string} monitor.type - Type of monitor determining queue selection
* @param {number} monitor.interval - Job repeat interval in milliseconds
* @throws {Error} If queue not found for monitor type
* @throws {Error} If job deletion fails
* @description
* 1. Identifies correct queue based on monitor type
* 2. Removes repeatable job using monitor ID and interval
* 3. Logs success or failure of deletion
* 4. Updates worker scaling based on new queue load
* Returns void but logs operation result
*/
async deleteJob(monitor) {
try {
const queue = this.queues[QUEUE_LOOKUP[monitor.type]];
const schedulerId = getSchedulerId(monitor);
const wasDeleted = await queue.removeJobScheduler(schedulerId);
if (wasDeleted === true) {
this.logger.info({
message: this.stringService.jobQueueDeleteJob,
service: SERVICE_NAME,
method: "deleteJob",
details: `Deleted job ${monitor._id}`,
});
const workerStats = await this.getWorkerStats(queue);
await this.scaleWorkers(workerStats, queue);
} else {
this.logger.error({
message: this.stringService.jobQueueDeleteJob,
service: SERVICE_NAME,
method: "deleteJob",
details: `Failed to delete job ${monitor._id}`,
});
}
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteJob") : null;
throw error;
}
}
/**
* Retrieves comprehensive metrics for all queues
* @async
* @returns {Promise<Object.<string, QueueMetrics>>} Object with metrics for each queue
* @throws {Error} If metrics retrieval fails
* @description
* Collects the following metrics for each queue:
* - Number of waiting jobs
* - Number of active jobs
* - Number of completed jobs
* - Number of failed jobs
* - Number of delayed jobs
* - Number of repeatable jobs
* - Number of active workers
*
* @typedef {Object} QueueMetrics
* @property {number} waiting - Count of jobs waiting to be processed
* @property {number} active - Count of jobs currently being processed
* @property {number} completed - Count of successfully completed jobs
* @property {number} failed - Count of failed jobs
* @property {number} delayed - Count of delayed jobs
* @property {number} repeatableJobs - Count of repeatable job patterns
* @property {number} workers - Count of active workers for this queue
*/
async getMetrics() {
try {
let metrics = {};
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const queue = this.queues[name];
const workers = this.workers[name];
const [waiting, active, completed, failed, delayed, repeatableJobs] =
await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getCompletedCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
queue.getRepeatableJobs(),
]);
metrics[name] = {
waiting,
active,
completed,
failed,
delayed,
repeatableJobs: repeatableJobs.length,
workers: workers.length,
};
})
);
return metrics;
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "getMetrics",
stack: error.stack,
});
}
}
/**
* @async
* @returns {Promise<boolean>} - Returns true if obliteration is successful
*/
async obliterate() {
try {
this.logger.info({
message: "Attempting to obliterate job queue...",
service: SERVICE_NAME,
method: "obliterate",
});
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const queue = this.queues[name];
await queue.pause();
const jobs = await this.getJobs(queue);
// Remove all repeatable jobs
for (const job of jobs) {
await queue.removeRepeatableByKey(job.key);
await queue.remove(job.id);
}
})
);
// Close workers
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const workers = this.workers[name];
await Promise.all(
workers.map(async (worker) => {
await worker.close();
})
);
})
);
QUEUE_NAMES.forEach(async (name) => {
const queue = this.queues[name];
await queue.obliterate();
});
const metrics = await this.getMetrics();
this.logger.info({
message: this.stringService.jobQueueObliterate,
service: SERVICE_NAME,
method: "obliterate",
details: metrics,
});
return true;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "obliterate") : null;
throw error;
}
}
// **************************
// Queue Health Checks
// **************************
async getKeyValuePairs() {
try {
// Get all keys
const keys = await this.connection.keys("*");
if (keys.length === 0) {
return {}; // Return an empty object if no keys are found
}
// Get values for all keys
const values = await this.connection.mget(keys);
// Combine keys and values into an object
const keyValuePairs = keys.reduce((result, key, index) => {
result[key] = values[index];
return result;
}, {});
this.logger.info({
message: "Redis key-value",
service: SERVICE_NAME,
method: "flushQueue",
details: keyValuePairs,
});
return keyValuePairs;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getKeyValuePairs") : null;
throw error;
}
}
async flushQueue() {
try {
const keyValuePairs = await this.getKeyValuePairs();
this.logger.info({
message: "Before flush",
service: SERVICE_NAME,
method: "flushQueue",
details: keyValuePairs,
});
const flushResult = await this.connection.flushall();
const keyValuePairsAfter = await this.getKeyValuePairs();
this.logger.info({
message: "After flush",
service: SERVICE_NAME,
method: "flushQueue",
details: keyValuePairsAfter,
});
if (flushResult !== "OK") {
throw new Error("Failed to flush queue");
}
await this.initJobQueue();
return {
keyValuePairs,
flush: flushResult,
keyValuePairsAfter,
init: true,
};
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "flushQueue") : null;
throw error;
}
}
/**
* Gets metrics for a specific queue
* @async
* @function getQueueHealthMetrics
* @param {Queue} queue - The queue to get metrics for
* @returns {Promise<Object>} Queue metrics
*/
async getQueueHealthMetrics(queue) {
return await queue.getJobCounts();
}
getQueueIdleTimes() {
const now = Date.now();
const idleTimes = {};
Object.entries(this.lastJobProcessedTime).forEach(([queueName, lastProcessed]) => {
idleTimes[queueName] = now - lastProcessed;
});
return idleTimes;
}
async checkQueueHealth() {
try {
const currentTime = Date.now();
const stuckQueues = [];
const idleTimes = this.getQueueIdleTimes();
for (const queueName of QUEUE_NAMES) {
const queue = this.queues[queueName];
const jobCounts = await this.getQueueHealthMetrics(queue);
const hasJobs = Object.values(jobCounts).some((count) => count > 0);
const timeSinceLastProcessed = currentTime - this.lastJobProcessedTime[queueName];
const isStuck = hasJobs && timeSinceLastProcessed > HEALTH_CHECK_INTERVAL;
if (isStuck) {
stuckQueues.push(queueName);
}
}
const queueHealth = { stuck: false, stuckQueues, idleTimes };
if (stuckQueues.length > 0) {
queueHealth.stuck = true;
}
this.logger.info({
message: "Queue health check",
service: SERVICE_NAME,
method: "checkQueueHealth",
details: queueHealth,
});
return queueHealth;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "checkQueueHealth") : null;
throw error;
}
}
}
export default NewJobQueue;
+2 -8
View File
@@ -258,8 +258,8 @@ class NetworkService {
if (dbSettings?.pagespeedApiKey) {
pagespeedUrl += `&key=${dbSettings.pagespeedApiKey}`;
} else {
this.logger.info({
message: "Pagespeed API key not found",
this.logger.warn({
message: "Pagespeed API key not found, job not executed",
service: this.SERVICE_NAME,
method: "requestPagespeed",
details: { url },
@@ -437,12 +437,6 @@ class NetworkService {
throw new Error(response.data.message);
}
} catch (error) {
this.logger.error({
message: "Error in requestDistributedHttp",
service: this.SERVICE_NAME,
method: "requestDistributedHttp",
stack: error.stack,
});
error.service = this.SERVICE_NAME;
error.method = "requestDistributedHttp";
throw error;
+42 -55
View File
@@ -1,71 +1,58 @@
const SERVICE_NAME = "RedisService";
class RedisService {
static SERVICE_NAME = "RedisService";
constructor({ logger, IORedis, SettingsService }) {
static SERVICE_NAME = SERVICE_NAME;
constructor({ Redis, logger }) {
this.Redis = Redis;
this.connections = new Set();
this.logger = logger;
this.IORedis = IORedis;
this.SettingsService = SettingsService;
this.connection = null;
}
static async createInstance({ logger, IORedis, SettingsService }) {
const instance = new RedisService({ logger, IORedis, SettingsService });
await instance.connect();
return instance;
}
async connect() {
const settings = this.SettingsService.getSettings();
const { redisUrl } = settings;
this.connection = new this.IORedis(redisUrl, {
maxRetriesPerRequest: null,
getNewConnection(options = {}) {
const connection = new this.Redis(process.env.REDIS_URL, {
retryStrategy: (times) => {
if (times >= 5) {
throw new Error("Failed to connect to Redis");
}
this.logger.debug({
message: "Retrying Redis connection",
service: RedisService.SERVICE_NAME,
details: { times },
});
return Math.min(times * 100, 2000);
return null;
},
...options,
});
this.connections.add(connection);
return connection;
}
await new Promise((resolve, reject) => {
let errorOccurred = false;
this.connection.on("ready", () => {
if (!errorOccurred) {
this.logger.info({
message: "Redis connection established",
service: RedisService.SERVICE_NAME,
});
resolve();
}
});
this.connection.on("error", (err) => {
errorOccurred = true;
async closeAllConnections() {
const closePromises = Array.from(this.connections).map((conn) =>
conn.quit().catch((err) => {
this.logger.error({
message: "Redis connection error",
service: RedisService.SERVICE_NAME,
error: err,
message: "Error closing Redis connection",
service: SERVICE_NAME,
method: "closeAllConnections",
details: { error: err },
});
setTimeout(() => reject(err), 5000);
});
})
);
await Promise.all(closePromises);
this.connections.clear();
this.logger.info({
message: "All Redis connections closed",
service: SERVICE_NAME,
method: "closeAllConnections",
});
}
async flushall() {
this.logger.debug({
message: "Flushing all Redis data",
service: RedisService.SERVICE_NAME,
});
await this.connection.flushall();
}
getConnection() {
return this.connection;
async flushRedis() {
this.logger.info({
message: "Flushing Redis",
service: SERVICE_NAME,
method: "flushRedis",
});
const flushPromises = Array.from(this.connections).map((conn) => conn.flushall());
await Promise.all(flushPromises);
this.logger.info({
message: "Redis flushed",
service: SERVICE_NAME,
method: "flushRedis",
});
}
}
+7 -11
View File
@@ -121,10 +121,10 @@ class StatusService {
try {
const { monitorId, status, code } = networkResponse;
const monitor = await this.db.getMonitorById(monitorId);
// Update running stats
this.updateRunningStats({ monitor, networkResponse });
// No change in monitor status, return early
if (monitor.status === status)
return {
@@ -134,7 +134,7 @@ class StatusService {
code,
timestamp: new Date().getTime(),
};
// Monitor status changed, save prev status and update monitor
this.logger.info({
service: this.SERVICE_NAME,
@@ -144,11 +144,11 @@ class StatusService {
prevStatus: monitor.status,
newStatus: status,
});
const prevStatus = monitor.status;
monitor.status = status;
await monitor.save();
return {
monitor,
statusChanged: true,
@@ -157,12 +157,8 @@ class StatusService {
timestamp: new Date().getTime(),
};
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
message: error.message,
method: "updateStatus",
stack: error.stack,
});
error.service = this.SERVICE_NAME;
error.method = "updateStatus";
throw error;
}
};
+11
View File
@@ -591,6 +591,16 @@ const createAnnouncementValidation = joi.object({
userId: joi.string().required(),
});
const sendTestEmailBodyValidation = joi.object({
to: joi.string().required(),
systemEmailHost: joi.string(),
systemEmailPort: joi.number(),
systemEmailAddress: joi.string(),
systemEmailPassword: joi.string(),
systemEmailUser: joi.string(),
systemEmailConnectionHost: joi.string(),
});
export {
roleValidatior,
loginValidation,
@@ -654,4 +664,5 @@ export {
triggerNotificationBodyValidation,
webhookConfigValidation,
createAnnouncementValidation,
sendTestEmailBodyValidation,
};