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"); }