diff --git a/client/src/Components/Link/index.jsx b/client/src/Components/Link/index.jsx
index 7a623a3b0..987c56f37 100644
--- a/client/src/Components/Link/index.jsx
+++ b/client/src/Components/Link/index.jsx
@@ -1,4 +1,6 @@
import { Link as MuiLink, useTheme } from "@mui/material";
+import { Link as RouterLink } from "react-router-dom";
+
import PropTypes from "prop-types";
/**
@@ -10,7 +12,7 @@ import PropTypes from "prop-types";
* @returns {JSX.Element}
*/
-const Link = ({ level, label, url }) => {
+const Link = ({ level, label, url, external = true }) => {
const theme = useTheme();
const levelConfig = {
@@ -49,11 +51,12 @@ const Link = ({ level, label, url }) => {
const { sx, color } = levelConfig[level];
return (
{label}
@@ -64,6 +67,7 @@ Link.propTypes = {
url: PropTypes.string.isRequired,
level: PropTypes.oneOf(["primary", "secondary", "tertiary", "error"]),
label: PropTypes.string.isRequired,
+ external: PropTypes.bool,
};
export default Link;
diff --git a/client/src/Components/Table/TableUtils.js b/client/src/Components/Table/TableUtils.js
new file mode 100644
index 000000000..a9f95a5b7
--- /dev/null
+++ b/client/src/Components/Table/TableUtils.js
@@ -0,0 +1,11 @@
+export const createHeaderFactory = (getCellSx = () => {}) => {
+ return ({ id, content, onClick = () => {}, render = () => {} }) => {
+ return {
+ id,
+ content,
+ onClick,
+ getCellSx,
+ render,
+ };
+ };
+};
diff --git a/client/src/Hooks/queueHooks.js b/client/src/Hooks/queueHooks.js
new file mode 100644
index 000000000..f7a25b3d3
--- /dev/null
+++ b/client/src/Hooks/queueHooks.js
@@ -0,0 +1,57 @@
+import { useState, useEffect } from "react";
+import { networkService } from "../main";
+import { createToast } from "../Utils/toastUtils";
+
+const useFetchQueueData = (trigger) => {
+ const [jobs, setJobs] = useState(undefined);
+ const [metrics, setMetrics] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(undefined);
+
+ useEffect(() => {
+ const fetchJobs = async () => {
+ try {
+ setIsLoading(true);
+ const response = await networkService.getQueueData();
+ if (response.status === 200) {
+ setJobs(response.data.data.jobs);
+ setMetrics(response.data.data.metrics);
+ }
+ } catch (error) {
+ setError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchJobs();
+ }, [trigger]);
+
+ return [jobs, metrics, isLoading, error];
+};
+
+const useFlushQueue = () => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(undefined);
+
+ const flushQueue = async (trigger, setTrigger) => {
+ try {
+ setIsLoading(true);
+ await networkService.flushQueue();
+ createToast({
+ body: "Queue flushed",
+ });
+ } catch (error) {
+ setError(error);
+ createToast({
+ body: error.message,
+ });
+ } finally {
+ setIsLoading(false);
+ setTrigger(!trigger);
+ }
+ };
+ return [flushQueue, isLoading, error];
+};
+
+export { useFetchQueueData, useFlushQueue };
diff --git a/client/src/Pages/Queue/components/FailedJobTable/index.jsx b/client/src/Pages/Queue/components/FailedJobTable/index.jsx
new file mode 100644
index 000000000..d7284f9da
--- /dev/null
+++ b/client/src/Pages/Queue/components/FailedJobTable/index.jsx
@@ -0,0 +1,82 @@
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import DataTable from "../../../../Components/Table";
+
+import { useNavigate } from "react-router-dom";
+import { useTheme } from "@emotion/react";
+import { TypeToPathMap } from "../../../../Utils/monitorUtils";
+import PropTypes from "prop-types";
+import { useTranslation } from "react-i18next";
+
+const FailedJobTable = ({ metrics = {} }) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const jobsWithFailures = metrics?.jobsWithFailures;
+ const navigate = useNavigate();
+
+ const headers = [
+ {
+ id: "monitorId",
+ content: t("queuePage.failedJobTable.monitorIdHeader"),
+ render: (row) => {
+ return row.monitorId;
+ },
+ },
+ {
+ id: "monitorUrl",
+ content: t("queuePage.failedJobTable.monitorUrlHeader"),
+ render: (row) => {
+ return row.monitorUrl;
+ },
+ },
+ {
+ id: "failCount",
+ content: t("queuePage.failedJobTable.failCountHeader"),
+ render: (row) => {
+ return row.failCount;
+ },
+ },
+ {
+ id: "failedAt",
+ content: t("queuePage.failedJobTable.failedAtHeader"),
+ render: (row) => {
+ return row.failedAt;
+ },
+ },
+ {
+ id: "failReason",
+ content: t("queuePage.failedJobTable.failReasonHeader"),
+ render: (row) => {
+ return row.failReason;
+ },
+ },
+ ];
+ return (
+
+ {t("queuePage.failedJobTable.title")}
+ {
+ const path = TypeToPathMap[row.monitorType];
+ navigate(`/${path}/${row.monitorId}`);
+ },
+ rowSX: {
+ cursor: "pointer",
+ "&:hover td": {
+ backgroundColor: theme.palette.tertiary.main,
+ transition: "background-color .3s ease",
+ },
+ },
+ }}
+ />
+
+ );
+};
+
+FailedJobTable.propTypes = {
+ metrics: PropTypes.object,
+};
+
+export default FailedJobTable;
diff --git a/client/src/Pages/Queue/components/JobTable/index.jsx b/client/src/Pages/Queue/components/JobTable/index.jsx
new file mode 100644
index 000000000..f4aa0768c
--- /dev/null
+++ b/client/src/Pages/Queue/components/JobTable/index.jsx
@@ -0,0 +1,125 @@
+import Stack from "@mui/material/Stack";
+import DataTable from "../../../../Components/Table";
+import Typography from "@mui/material/Typography";
+// Utils
+import PropTypes from "prop-types";
+import { useTheme } from "@emotion/react";
+import { useNavigate } from "react-router-dom";
+import { TypeToPathMap } from "../../../../Utils/monitorUtils";
+import { useTranslation } from "react-i18next";
+import { createHeaderFactory } from "../../../../Components/Table/TableUtils";
+
+const JobTable = ({ jobs = [] }) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const buildSx = (row) => {
+ if (row.lockedAt) {
+ return {
+ color: `${theme.palette.success.main} !important`,
+ };
+ }
+ if (!row.active) {
+ return {
+ color: `${theme.palette.warning.main} !important`,
+ };
+ }
+
+ if (row.failCount > 0 && row.lastFailedAt >= row.lastFinishedAt) {
+ return {
+ color: `${theme.palette.error.main} !important`,
+ };
+ }
+
+ return {};
+ };
+
+ const createHeader = createHeaderFactory(buildSx);
+ const headersData = [
+ {
+ id: "id",
+ content: t("queuePage.jobTable.idHeader"),
+ render: (row) => row.monitorId,
+ },
+ {
+ id: "url",
+ content: t("queuePage.jobTable.urlHeader"),
+ render: (row) => row.monitorUrl,
+ },
+ {
+ id: "type",
+ content: t("queuePage.jobTable.typeHeader"),
+ render: (row) => row.monitorType,
+ },
+ {
+ id: "active",
+ content: t("queuePage.jobTable.activeHeader"),
+ render: (row) => row.active.toString(),
+ },
+ {
+ id: "runCount",
+ content: t("queuePage.jobTable.runCountHeader"),
+ render: (row) => row.runCount,
+ },
+ {
+ id: "failCount",
+ content: t("queuePage.jobTable.failCountHeader"),
+ render: (row) => row.failCount,
+ },
+ {
+ id: "lastRun",
+ content: t("queuePage.jobTable.lastRunHeader"),
+ render: (row) => row.lastRunAt || "-",
+ },
+ {
+ id: "lockedAt",
+ content: t("queuePage.jobTable.lockedAtHeader"),
+ render: (row) => row.lockedAt || "-",
+ },
+
+ {
+ id: "lastFinish",
+ content: t("queuePage.jobTable.lastFinishedAtHeader"),
+ render: (row) => row.lastFinishedAt || "-",
+ },
+ {
+ id: "lastRunTook",
+ content: t("queuePage.jobTable.lastRunTookHeader"),
+ render: (row) => {
+ const value = row.lastRunTook ? row.lastRunTook + " ms" : "-";
+ return value;
+ },
+ },
+ ];
+
+ const headers = headersData.map((header) => createHeader(header));
+
+ return (
+
+ {t("queuePage.jobTable.title")}
+ {
+ const path = TypeToPathMap[row.monitorType];
+ navigate(`/${path}/${row.monitorId}`);
+ },
+ rowSX: {
+ cursor: "pointer",
+ "&:hover td": {
+ backgroundColor: theme.palette.tertiary.main,
+ transition: "background-color .3s ease",
+ },
+ },
+ }}
+ />
+
+ );
+};
+
+JobTable.propTypes = {
+ jobs: PropTypes.array,
+};
+
+export default JobTable;
diff --git a/client/src/Pages/Queue/components/MetricsTable/index.jsx b/client/src/Pages/Queue/components/MetricsTable/index.jsx
new file mode 100644
index 000000000..818b4e543
--- /dev/null
+++ b/client/src/Pages/Queue/components/MetricsTable/index.jsx
@@ -0,0 +1,60 @@
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import DataTable from "../../../../Components/Table";
+
+// Utils
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@emotion/react";
+import PropTypes from "prop-types";
+
+const camelToTitle = (str) => {
+ return str
+ .replace(/([A-Z])/g, " $1")
+ .toLowerCase()
+ .replace(/^./, (m) => m.toUpperCase());
+};
+
+const Metrics = ({ metrics = {} }) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const keys = Object.keys(metrics);
+
+ const headers = [
+ {
+ id: "metric",
+ content: t("queuePage.metricsTable.metricHeader"),
+ render: (row) => {
+ return {row.key};
+ },
+ },
+ {
+ id: "value",
+ content: t("queuePage.metricsTable.valueHeader"),
+ render: (row) => {
+ return {row.value};
+ },
+ },
+ ];
+
+ const data = keys
+ .filter((key) => key !== "jobsWithFailures")
+ .map((key) => {
+ return { key: camelToTitle(key), value: metrics[key] };
+ });
+
+ return (
+
+ {t("queuePage.metricsTable.title")}
+
+
+ );
+};
+
+Metrics.propTypes = {
+ metrics: PropTypes.object,
+};
+
+export default Metrics;
diff --git a/client/src/Pages/Queue/index.jsx b/client/src/Pages/Queue/index.jsx
new file mode 100644
index 000000000..eb732560c
--- /dev/null
+++ b/client/src/Pages/Queue/index.jsx
@@ -0,0 +1,69 @@
+// Components
+import Stack from "@mui/material/Stack";
+import Breadcrumbs from "../../Components/Breadcrumbs";
+import JobTable from "./components/JobTable";
+import MetricsTable from "./components/MetricsTable";
+import FailedJobTable from "./components/FailedJobTable";
+import ButtonGroup from "@mui/material/ButtonGroup";
+import Button from "@mui/material/Button";
+
+// Utils
+import { useState } from "react";
+import { useFetchQueueData, useFlushQueue } from "../../Hooks/queueHooks";
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@emotion/react";
+
+const QueueDetails = () => {
+ // Local state
+ const [trigger, setTrigger] = useState(false);
+
+ // Hooks
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const [jobs, metrics, isLoading, error] = useFetchQueueData(trigger);
+ const [flushQueue, isFlushing, flushError] = useFlushQueue();
+
+ const BREADCRUMBS = [{ name: t("queuePage.title"), path: "/queue" }];
+ if (isLoading) return
Loading...
;
+ if (error || flushError) return Error: {error.message}
;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default QueueDetails;
diff --git a/client/src/Pages/Settings/SettingsDev.jsx b/client/src/Pages/Settings/SettingsDev.jsx
new file mode 100644
index 000000000..721a17d5c
--- /dev/null
+++ b/client/src/Pages/Settings/SettingsDev.jsx
@@ -0,0 +1,37 @@
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import ConfigBox from "../../Components/ConfigBox";
+// Utils
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import Link from "../../Components/Link";
+
+const SettingsDev = ({ isAdmin, HEADER_SX }) => {
+ const { t } = useTranslation();
+
+ if (!isAdmin) return null;
+
+ return (
+
+
+
+ {t("settingsDev")}
+
+ {t("settingsDevDescription")}
+
+
+
+
+
+ );
+};
+
+export default SettingsDev;
diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx
index 76eaccd6c..ef57a16c7 100644
--- a/client/src/Pages/Settings/index.jsx
+++ b/client/src/Pages/Settings/index.jsx
@@ -8,6 +8,7 @@ import SettingsPagespeed from "./SettingsPagespeed";
import SettingsDemoMonitors from "./SettingsDemoMonitors";
import SettingsAbout from "./SettingsAbout";
import SettingsEmail from "./SettingsEmail";
+import SettingsDev from "./SettingsDev";
import Button from "@mui/material/Button";
// Utils
import { settingsValidation } from "../../Validation/validation";
@@ -208,6 +209,10 @@ const Settings = () => {
emailPasswordHasBeenReset={emailPasswordHasBeenReset}
setEmailPasswordHasBeenReset={setEmailPasswordHasBeenReset}
/>
+
{
const AdminCheckedRegister = withAdminCheck(AuthRegister);
@@ -186,6 +187,10 @@ const Routes = () => {
path="account/team"
element={}
/>
+ }
+ />
{
return url;
};
+
+export const TypeToPathMap = {
+ http: "uptime",
+ port: "uptime",
+ docker: "uptime",
+ ping: "uptime",
+ hardware: "infrastructure",
+ pagespeed: "pagespeed",
+};
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 795a64576..d3a274a32 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -30,6 +30,9 @@
"settingsRemoveAllMonitorsDialogTitle": "Do you want to remove all monitors?",
"settingsRemoveAllMonitorsDialogConfirm": "Yes, remove all monitors",
"settingsAbout": "About",
+ "settingsDev": "Developer",
+ "settingsDevDescription": "Developer settings",
+ "settingsDevViewJobQueueDetails": "View job queue details",
"settingsDevelopedBy": "Developed by Bluewave Labs.",
"settingsSave": "Save",
"settingsSuccessSaved": "Settings saved successfully",
@@ -753,6 +756,36 @@
"settingsEmailPool": "Pool - Enable connection pooling",
"sendTestNotifications": "Send test notifications",
"selectAll": "Select all",
+ "queuePage": {
+ "title": "Queue",
+ "refreshButton": "Refresh",
+ "flushButton": "Flush queue",
+ "jobTable": {
+ "title": "Jobs currently in queue",
+ "idHeader": "Monitor ID",
+ "urlHeader": "URL",
+ "typeHeader": "Type",
+ "activeHeader": "Active",
+ "lockedAtHeader": "Locked at",
+ "runCountHeader": "Run count",
+ "failCountHeader": "Fail count",
+ "lastRunHeader": "Last run at",
+ "lastFinishedAtHeader": "Last finished at",
+ "lastRunTookHeader": "Last run took"
+ },
+ "metricsTable": {
+ "title": "Queue metrics",
+ "metricHeader": "Metric",
+ "valueHeader": "Value"
+ },
+ "failedJobTable": {
+ "title": "Failed jobs",
+ "monitorIdHeader": "Monitor ID",
+ "monitorUrlHeader": "Monitor URL",
+ "failCountHeader": "Fail count",
+ "failedAtHeader": "Last failed at",
+ "failReasonHeader": "Fail reason"
+ }
"export": {
"title": "Export Monitors",
"success": "Monitors exported successfully!",
diff --git a/server/controllers/queueController.js b/server/controllers/queueController.js
index b1a7badee..968106a5a 100755
--- a/server/controllers/queueController.js
+++ b/server/controllers/queueController.js
@@ -25,7 +25,7 @@ class JobQueueController {
try {
const jobs = await this.jobQueue.getJobs();
return res.success({
- msg: this.stringService.queueGetMetrics,
+ msg: this.stringService.queueGetJobs,
data: jobs,
});
} catch (error) {
@@ -34,6 +34,20 @@ class JobQueueController {
}
};
+ getAllMetrics = async (req, res, next) => {
+ try {
+ const jobs = await this.jobQueue.getJobs();
+ const metrics = await this.jobQueue.getMetrics();
+ return res.success({
+ msg: this.stringService.queueGetAllMetrics,
+ data: { jobs, metrics },
+ });
+ } catch (error) {
+ next(handleError(error, SERVICE_NAME, "getAllMetrics"));
+ return;
+ }
+ };
+
addJob = async (req, res, next) => {
try {
await this.jobQueue.addJob(Math.random().toString(36).substring(7));
diff --git a/server/locales/en.json b/server/locales/en.json
index a693c6eef..74296d0a5 100755
--- a/server/locales/en.json
+++ b/server/locales/en.json
@@ -123,6 +123,7 @@
"monitorCertificate": "Got monitor certificate successfully",
"monitorDemoAdded": "Successfully added demo monitors",
"queueGetMetrics": "Got metrics successfully",
+ "queueGetJobs": "Got jobs successfully",
"queueAddJob": "Job added successfully",
"queueObliterate": "Queue obliterated",
"jobQueueDeleteJobSuccess": "Job removed successfully",
diff --git a/server/routes/queueRoute.js b/server/routes/queueRoute.js
index dc9b40917..c40d21528 100755
--- a/server/routes/queueRoute.js
+++ b/server/routes/queueRoute.js
@@ -19,6 +19,12 @@ class QueueRoutes {
this.queueController.getJobs
);
+ this.router.get(
+ "/all-metrics",
+ isAllowed(["admin", "superadmin"]),
+ this.queueController.getAllMetrics
+ );
+
this.router.post(
"/jobs",
isAllowed(["admin", "superadmin"]),
diff --git a/server/service/PulseQueue/PulseQueue.js b/server/service/PulseQueue/PulseQueue.js
index 0452985a0..50a959559 100644
--- a/server/service/PulseQueue/PulseQueue.js
+++ b/server/service/PulseQueue/PulseQueue.js
@@ -57,6 +57,9 @@ class PulseQueue {
job.unique({ "data.monitor._id": monitor._id });
job.attrs.jobId = monitorId.toString();
job.repeatEvery(`${intervalInSeconds} seconds`);
+ if (monitor.isActive === false) {
+ job.disable();
+ }
await job.save();
};
@@ -136,6 +139,8 @@ class PulseQueue {
const jobs = await this.pulse.jobs();
const metrics = jobs.reduce(
(acc, job) => {
+ acc.totalRuns += job.attrs.runCount || 0;
+ acc.totalFailures += job.attrs.failCount || 0;
acc.jobs++;
if (job.attrs.failCount > 0 && job.attrs.failedAt >= job.attrs.lastFinishedAt) {
acc.failingJobs++;
@@ -147,13 +152,22 @@ class PulseQueue {
acc.jobsWithFailures.push({
monitorId: job.attrs.data.monitor._id,
monitorUrl: job.attrs.data.monitor.url,
+ monitorType: job.attrs.data.monitor.type,
+ failedAt: job.attrs.failedAt,
failCount: job.attrs.failCount,
failReason: job.attrs.failReason,
});
}
return acc;
},
- { jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [] }
+ {
+ jobs: 0,
+ activeJobs: 0,
+ failingJobs: 0,
+ jobsWithFailures: [],
+ totalRuns: 0,
+ totalFailures: 0,
+ }
);
return metrics;
};
@@ -164,10 +178,18 @@ class PulseQueue {
return {
monitorId: job.attrs.data.monitor._id,
monitorUrl: job.attrs.data.monitor.url,
+ monitorType: job.attrs.data.monitor.type,
+ active: !job.attrs.disabled,
lockedAt: job.attrs.lockedAt,
runCount: job.attrs.runCount || 0,
failCount: job.attrs.failCount || 0,
failReason: job.attrs.failReason,
+ lastRunAt: job.attrs.lastRunAt,
+ lastFinishedAt: job.attrs.lastFinishedAt,
+ lastRunTook: job.attrs.lockedAt
+ ? null
+ : job.attrs.lastFinishedAt - job.attrs.lastRunAt,
+ lastFailedAt: job.attrs.failedAt,
};
});
};
diff --git a/server/service/stringService.js b/server/service/stringService.js
index b02254255..499aa769f 100755
--- a/server/service/stringService.js
+++ b/server/service/stringService.js
@@ -276,6 +276,10 @@ class StringService {
return this.translationService.getTranslation("queueGetMetrics");
}
+ get queueGetJobs() {
+ return this.translationService.getTranslation("queueGetJobs");
+ }
+
get queueAddJob() {
return this.translationService.getTranslation("queueAddJob");
}