Merge pull request #2495 from bluewave-labs/feat/dev-settings

feat: dev settings
This commit is contained in:
Alexander Holliday
2025-06-21 18:00:34 +08:00
committed by GitHub
18 changed files with 562 additions and 6 deletions

View File

@@ -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 (
<MuiLink
href={url}
component={external ? "a" : RouterLink}
to={external ? undefined : url}
href={external ? url : undefined}
sx={{ width: "fit-content", ...sx }}
color={color}
target="_blank"
rel="noreferrer"
{...(external && { target: "_blank", rel: "noreferrer" })}
>
{label}
</MuiLink>
@@ -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;

View File

@@ -0,0 +1,11 @@
export const createHeaderFactory = (getCellSx = () => {}) => {
return ({ id, content, onClick = () => {}, render = () => {} }) => {
return {
id,
content,
onClick,
getCellSx,
render,
};
};
};

View File

@@ -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 };

View File

@@ -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 (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.failedJobTable.title")}</Typography>
<DataTable
headers={headers}
data={jobsWithFailures}
config={{
onRowClick: (row) => {
const path = TypeToPathMap[row.monitorType];
navigate(`/${path}/${row.monitorId}`);
},
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
}}
/>
</Stack>
);
};
FailedJobTable.propTypes = {
metrics: PropTypes.object,
};
export default FailedJobTable;

View File

@@ -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 (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.jobTable.title")}</Typography>
<DataTable
headers={headers}
data={jobs}
config={{
onRowClick: (row) => {
const path = TypeToPathMap[row.monitorType];
navigate(`/${path}/${row.monitorId}`);
},
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
}}
/>
</Stack>
);
};
JobTable.propTypes = {
jobs: PropTypes.array,
};
export default JobTable;

View File

@@ -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 <Typography>{row.key}</Typography>;
},
},
{
id: "value",
content: t("queuePage.metricsTable.valueHeader"),
render: (row) => {
return <Typography>{row.value}</Typography>;
},
},
];
const data = keys
.filter((key) => key !== "jobsWithFailures")
.map((key) => {
return { key: camelToTitle(key), value: metrics[key] };
});
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<DataTable
headers={headers}
data={data}
/>
</Stack>
);
};
Metrics.propTypes = {
metrics: PropTypes.object,
};
export default Metrics;

View File

@@ -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 <div>Loading...</div>;
if (error || flushError) return <div>Error: {error.message}</div>;
return (
<Stack gap={theme.spacing(20)}>
<Breadcrumbs list={BREADCRUMBS} />
<JobTable jobs={jobs} />
<MetricsTable metrics={metrics} />
<FailedJobTable metrics={metrics} />
<ButtonGroup
variant="contained"
color="accent"
sx={{
position: "sticky",
bottom: 0,
zIndex: 1000,
backgroundColor: theme.palette.primary.main,
p: theme.spacing(4),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.spacing(2),
}}
>
<Button
onClick={() => {
setTrigger(!trigger);
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
</Stack>
);
};
export default QueueDetails;

View File

@@ -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 (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("settingsDev")}
</Typography>
<Typography sx={HEADER_SX}>{t("settingsDevDescription")}</Typography>
</Box>
<Box>
<Link
level="secondary"
label={t("settingsDevViewJobQueueDetails")}
url="/queue"
external={false}
/>
</Box>
</ConfigBox>
);
};
export default SettingsDev;

View File

@@ -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}
/>
<SettingsDev
isAdmin={isAdmin}
HEADER_SX={HEADING_SX}
/>
<SettingsAbout />
<Stack
direction="row"

View File

@@ -51,6 +51,7 @@ import ProtectedRoute from "../Components/ProtectedRoute";
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance";
import withAdminCheck from "../Components/HOC/withAdminCheck";
import BulkImport from "../Pages/Uptime/BulkImport";
import Queue from "../Pages/Queue";
const Routes = () => {
const AdminCheckedRegister = withAdminCheck(AuthRegister);
@@ -186,6 +187,10 @@ const Routes = () => {
path="account/team"
element={<Account open={"team"} />}
/>
<Route
path="queue"
element={<Queue />}
/>
</Route>
<Route

View File

@@ -1023,6 +1023,18 @@ class NetworkService {
return this.axiosInstance.put(`/notifications/${id}`, notification);
}
async getQueueData() {
return this.axiosInstance.get(`/queue/all-metrics`, {
headers: {
"Content-Type": "application/json",
},
});
}
async flushQueue() {
return this.axiosInstance.post(`/queue/flush`);
async exportMonitors() {
const response = await this.axiosInstance.get("/monitors/export", {
responseType: "blob",

View File

@@ -36,3 +36,12 @@ export const parseDomainName = (url) => {
return url;
};
export const TypeToPathMap = {
http: "uptime",
port: "uptime",
docker: "uptime",
ping: "uptime",
hardware: "infrastructure",
pagespeed: "pagespeed",
};

View File

@@ -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!",

View File

@@ -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));

View File

@@ -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",

View File

@@ -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"]),

View File

@@ -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,
};
});
};

View File

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