Merge pull request #2595 from bluewave-labs/feat/diagnostics

feat: diagnostics tab
This commit is contained in:
Alexander Holliday authored and GitHub committed 2025-07-15 09:46:31 -07:00
commit f23f7df147
16 files changed
+561 -114

No files matched your search

@@ -1,8 +1,10 @@
import { useTheme } from "@emotion/react";
import { useEffect, useState, useMemo } from "react";
import Stack from "@mui/material/Stack";
import { Box, Typography } from "@mui/material";
import PropTypes from "prop-types";
import "./index.css";
import CircularProgress from "@mui/material/CircularProgress";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
@@ -16,6 +18,8 @@ const MAXIMUM_VALUE = 100;
* @param {number} [props.radius=60] - Radius of the gauge circle
* @param {number} [props.strokeWidth=15] - Width of the gauge stroke
* @param {number} [props.threshold=50] - Threshold for color change
* @param {number} [props.precision=1] - Precision of the progress percentage
* @param {string} [props.unit="%"] - Unit of progress
*
* @example
* <CustomGauge
@@ -27,7 +31,15 @@ const MAXIMUM_VALUE = 100;
*
* @returns {React.ReactElement} Rendered CustomGauge component
*/
const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold = 50 }) => {
const CustomGauge = ({
isLoading = false,
progress = 0,
radius = 70,
strokeWidth = 15,
threshold = 50,
precision = 1,
unit = "%",
}) => {
const theme = useTheme();
// Calculate the length of the stroke for the circle
const { circumference, totalSize, strokeLength } = useMemo(
@@ -54,8 +66,22 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
const fillColor =
progressWithinRange > threshold
? theme.palette.error.lowContrast // CAIO_REVIEW
: theme.palette.accent.main; // CAIO_REVIEW
? theme.palette.error.lowContrast
: theme.palette.accent.main;
if (isLoading) {
return (
<Stack
className="radial-chart"
width={radius}
height={radius}
alignItems="center"
justifyContent="center"
>
<CircularProgress color="accent" />
</Stack>
);
}
return (
<Box
@@ -71,7 +97,7 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
>
<circle
className="radial-chart-base"
stroke={theme.palette.secondary.light} // CAIO_REVIEW
stroke={theme.palette.secondary.light}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2} // Center the circle
@@ -103,7 +129,7 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
fill: theme.typography.h2.color,
}}
>
{`${progressWithinRange.toFixed(1)}%`}
{`${progressWithinRange.toFixed(precision)}${unit}`}
</Typography>
</Box>
);
@@ -112,8 +138,11 @@ const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold =
export default CustomGauge;
CustomGauge.propTypes = {
isLoading: PropTypes.bool,
progress: PropTypes.number,
radius: PropTypes.number,
strokeWidth: PropTypes.number,
threshold: PropTypes.number,
precision: PropTypes.number,
unit: PropTypes.string,
};
+76 -1
View File
@@ -32,4 +32,79 @@ const useFetchLogs = () => {
return [logs, isLoading, error];
};
export { useFetchLogs };
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];
};
const useFetchDiagnostics = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const [diagnostics, setDiagnostics] = useState(undefined);
useEffect(() => {
const fetchDiagnostics = async () => {
try {
setIsLoading(true);
const response = await networkService.getDiagnostics();
setDiagnostics(response.data.data);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
};
fetchDiagnostics();
}, []);
return [diagnostics, isLoading, error];
};
export { useFetchLogs, useFetchQueueData, useFlushQueue, useFetchDiagnostics };
-57
View File
@@ -1,57 +0,0 @@
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 };
@@ -0,0 +1,109 @@
import Stack from "@mui/material/Stack";
import Gauge from "../../../../../Components/Charts/CustomGauge";
import Typography from "@mui/material/Typography";
// Utils
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { getPercentage } from "../../utils/utils";
import { useTranslation } from "react-i18next";
const GaugeBox = ({ title, subtitle, children }) => {
const theme = useTheme();
return (
<Stack
alignItems="center"
p={theme.spacing(2)}
maxWidth={150}
width={150}
>
{children}
<Typography variant="h2">{title}</Typography>
<Typography variant="body2">{subtitle}</Typography>
</Stack>
);
};
GaugeBox.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
const Gauges = ({ diagnostics, isLoading }) => {
const heapTotalSize = getPercentage(
diagnostics?.v8HeapStats?.totalHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const heapUsedSize = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.heapSizeLimitBytes
);
const actualHeapUsed = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeBytes,
diagnostics?.v8HeapStats?.totalHeapSizeBytes
);
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
spacing={theme.spacing(4)}
flexWrap="wrap"
>
<GaugeBox
title={t("diagnosticsPage.gauges.heapAllocationTitle")}
subtitle={t("diagnosticsPage.gauges.heapAllocationSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={heapTotalSize}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.heapUsageTitle")}
subtitle={t("diagnosticsPage.gauges.heapUsageSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={heapUsedSize}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.heapUtilizationTitle")}
subtitle={t("diagnosticsPage.gauges.heapUtilizationSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={actualHeapUsed}
/>
</GaugeBox>
<GaugeBox
title={t("diagnosticsPage.gauges.instantCpuUsageTitle")}
subtitle={t("diagnosticsPage.gauges.instantCpuUsageSubtitle")}
>
<Gauge
isLoading={isLoading}
radius={100}
progress={diagnostics?.cpuUsage?.usagePercentage}
precision={2}
/>
</GaugeBox>
</Stack>
);
};
Gauges.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
export default Gauges;
@@ -0,0 +1,97 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CircularProgress from "@mui/material/CircularProgress";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { getHumanReadableDuration } from "../../../../../Utils/timeUtils";
import { formatBytes } from "../../utils/utils";
import { useTranslation } from "react-i18next";
const StatsCard = ({ title, value, unit = "", isLoading }) => {
const theme = useTheme();
return (
<Card sx={{ width: 150, maxWidth: 150, height: 80, maxHeight: 80 }}>
{isLoading ? (
<Stack
alignItems="center"
justifyContent="center"
height={80}
maxHeight={80}
>
<CircularProgress color="accent" />
</Stack>
) : (
<CardContent>
<Typography
variant="body1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Typography variant="body1">
{value} {unit}
</Typography>
</CardContent>
)}
</Card>
);
};
StatsCard.propTypes = {
title: PropTypes.string,
value: PropTypes.string,
unit: PropTypes.string,
isLoading: PropTypes.bool,
};
const Stats = ({ diagnostics, isLoading }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
flexWrap="wrap"
>
<StatsCard
title={t("diagnosticsPage.stats.eventLoopDelayTitle")}
value={getHumanReadableDuration(diagnostics?.eventLoopDelayMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.uptimeTitle")}
value={getHumanReadableDuration(diagnostics?.uptimeMs)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.usedHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.totalHeapSizeTitle")}
value={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
isLoading={isLoading}
/>
<StatsCard
title={t("diagnosticsPage.stats.osMemoryLimitTitle")}
value={formatBytes(diagnostics?.osStats?.totalMemoryBytes)}
isLoading={isLoading}
/>
</Stack>
);
};
Stats.propTypes = {
diagnostics: PropTypes.object,
isLoading: PropTypes.bool,
};
export default Stats;
@@ -0,0 +1,44 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Gauges from "./components/gauges";
import Stats from "./components/stats";
import Divider from "@mui/material/Divider";
import { useTheme } from "@emotion/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFetchDiagnostics } from "../../../Hooks/logHooks";
const Diagnostics = () => {
// Local state
// Hooks
const theme = useTheme();
const { t } = useTranslation();
const [diagnostics, isLoading, error] = useFetchDiagnostics();
// Setup
return (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="h2">{t("diagnosticsPage.diagnosticDescription")}</Typography>
</Box>
<Divider color={theme.palette.accent.main} />
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(10)}
>
<Gauges
diagnostics={diagnostics}
isLoading={isLoading}
/>
<Stats
diagnostics={diagnostics}
isLoading={isLoading}
/>
</Stack>
</Stack>
);
};
export default Diagnostics;
@@ -0,0 +1,16 @@
export const getPercentage = (value, total) => {
if (!value || !total) return 0;
return (value / total) * 100;
};
export const formatBytes = (bytes) => {
if (!bytes) return "N/A";
if (bytes === 0) return "0 Bytes";
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
+5 -1
View File
@@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Select from "../../../Components/Inputs/Select";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import { useFetchLogs } from "../../../Hooks/logHooks";
import { useTheme } from "@emotion/react";
@@ -51,12 +52,15 @@ const Logs = () => {
return (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="body">{t("logsPage.description")}</Typography>
<Typography variant="h2">{t("logsPage.description")}</Typography>
</Box>
<Divider color={theme.palette.accent.main} />
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
mt={theme.spacing(10)}
>
<Typography>{t("logsPage.logLevelSelect.title")}</Typography>
<Select
@@ -1,8 +1,6 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import StatBox from "../../../../../Components/StatBox";
import StatusBoxes from "../../../../../Components/StatusBoxes";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
@@ -24,20 +22,17 @@ const Metrics = ({ metrics = {} }) => {
});
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
</Stack>
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
);
};
export default Metrics;
+40 -31
View File
@@ -5,10 +5,12 @@ import Metrics from "./components/Metrics";
import FailedJobTable from "./components/FailedJobTable";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
// Utils
import { useState } from "react";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/queueHooks";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/logHooks";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
@@ -26,39 +28,46 @@ const QueueDetails = () => {
if (error || flushError) return <div>Error: {error.message}</div>;
return (
<Stack gap={theme.spacing(20)}>
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<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),
}}
<Stack gap={theme.spacing(4)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<Divider color={theme.palette.accent.main} />
<Stack
gap={theme.spacing(20)}
mt={theme.spacing(10)}
>
<Button
onClick={() => {
setTrigger(!trigger);
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<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),
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
<Button
onClick={() => {
setTrigger(!trigger);
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
+4 -1
View File
@@ -4,6 +4,7 @@ import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Queue from "./Queue";
import LogsComponent from "./Logs";
import Diagnostics from "./Diagnostics";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
@@ -14,7 +15,7 @@ const Logs = () => {
const theme = useTheme();
// Local state
const [value, setValue] = useState(0);
const [value, setValue] = useState(2);
// Handlers
const handleChange = (event, newValue) => {
@@ -31,9 +32,11 @@ const Logs = () => {
>
<Tab label={t("logsPage.tabs.logs")} />
<Tab label={t("logsPage.tabs.queue")} />
<Tab label={t("logsPage.tabs.diagnostics")} />
</Tabs>
{value === 0 && <LogsComponent />}
{value === 1 && <Queue />}
{value === 2 && <Diagnostics />}
</Stack>
);
};
+8
View File
@@ -1091,6 +1091,14 @@ class NetworkService {
},
});
}
async getDiagnostics() {
return this.axiosInstance.get(`/diagnostic/system`, {
headers: {
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;
+4
View File
@@ -85,6 +85,7 @@ export const getHumanReadableDuration = (ms) => {
hours: durationObj.hours(),
minutes: durationObj.minutes(),
seconds: durationObj.seconds(),
milliseconds: durationObj.milliseconds(),
};
const result = [];
@@ -101,6 +102,9 @@ export const getHumanReadableDuration = (ms) => {
if (result.length < 2 && parts.seconds > 0) {
result.push(`${parts.seconds}s`);
}
if (result.length < 2 && parts.milliseconds > 0 && parts.seconds < 1) {
result.push(`${parts.milliseconds.toFixed(2)}ms`);
}
if (result.length === 0) {
// fallback for durations < 1s
+24 -2
View File
@@ -443,7 +443,7 @@
"inviteNoTokenFound": "No invite token found",
"loginHere": "Login here",
"logsPage": {
"description": "This page shows the latest 1000 lines of logs from the Checkmate server",
"description": "System logs - last 1000 lines",
"logLevelSelect": {
"title": "Log level",
"values": {
@@ -456,13 +456,35 @@
},
"tabs": {
"logs": "Server logs",
"queue": "Job queue"
"queue": "Job queue",
"diagnostics": "Diagnostics"
},
"title": "Logs",
"toast": {
"fetchLogsSuccess": "Logs fetched successfully"
}
},
"diagnosticsPage": {
"diagnosticDescription": "System diagnostics",
"statsDescription": "System statistics",
"gauges": {
"heapAllocationTitle": "Heap allocation",
"heapAllocationSubtitle": "% of available memory",
"heapUsageTitle": "Heap usage",
"heapUsageSubtitle": "% of available memory",
"heapUtilizationTitle": "Heap utilization",
"heapUtilizationSubtitle": "% of allocated",
"instantCpuUsageTitle": "Instant CPU usage",
"instantCpuUsageSubtitle": "% of 1s used by CPU"
},
"stats": {
"eventLoopDelayTitle": "Event loop delay",
"uptimeTitle": "Uptime",
"usedHeapSizeTitle": "Used heap size",
"totalHeapSizeTitle": "Total heap size",
"osMemoryLimitTitle": "OS Memory Limit"
}
},
"low": "low",
"maintenance": "maintenance",
"maintenanceRepeat": "Maintenance Repeat",
@@ -1,6 +1,14 @@
import { handleError } from "./controllerUtils.js";
import v8 from "v8";
import os from "os";
const SERVICE_NAME = "diagnosticController";
const obs = new PerformanceObserver((items) => {
const entry = items.getEntries()[0];
performance.clearMarks();
});
obs.observe({ entryTypes: ["measure"] });
class DiagnosticController {
constructor(db) {
this.db = db;
@@ -44,5 +52,85 @@ class DiagnosticController {
next(handleError(error, SERVICE_NAME, "getDbStats"));
}
}
async getCPUUsage() {
try {
const startUsage = process.cpuUsage();
await new Promise((resolve) => setTimeout(resolve, 1000));
const endUsage = process.cpuUsage(startUsage);
const cpuUsage = {
userUsageMs: endUsage.user / 1000,
systemUsageMs: endUsage.system / 1000,
usagePercentage: ((endUsage.user + endUsage.system) / 1000 / 1000) * 100,
};
return cpuUsage;
} catch (error) {
return {
userUsageMs: 0,
systemUsageMs: 0,
};
}
}
getSystemStats = async (req, res, next) => {
try {
// Memory Usage
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const osStats = {
freeMemoryBytes: freeMemory, // bytes
totalMemoryBytes: totalMemory, // bytes
};
const used = process.memoryUsage();
const memoryUsage = {};
for (let key in used) {
memoryUsage[`${key}Mb`] = Math.round((used[key] / 1024 / 1024) * 100) / 100; // MB
}
// CPU Usage
const cpuMetrics = await this.getCPUUsage();
// V8 Heap Statistics
const heapStats = v8.getHeapStatistics();
const v8Metrics = {
totalHeapSizeBytes: heapStats.total_heap_size, // bytes
usedHeapSizeBytes: heapStats.used_heap_size, // bytes
heapSizeLimitBytes: heapStats.heap_size_limit, // bytes
};
// Event Loop Delay
let eventLoopDelay = 0;
performance.mark("start");
await new Promise((resolve) => setTimeout(resolve, 0));
performance.mark("end");
performance.measure("eventLoopDelay", "start", "end");
const entries = performance.getEntriesByName("eventLoopDelay");
if (entries.length > 0) {
eventLoopDelay = entries[0].duration;
}
// Uptime
const uptimeMs = process.uptime() * 1000; // ms
// Combine Metrics
const diagnostics = {
osStats,
memoryUsage,
cpuUsage: cpuMetrics,
v8HeapStats: v8Metrics,
eventLoopDelayMs: eventLoopDelay,
uptimeMs,
};
return res.success({
msg: "OK",
data: diagnostics,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMemoryUsage"));
}
};
}
export default DiagnosticController;
+1
View File
@@ -13,6 +13,7 @@ class DiagnosticRoutes {
);
this.router.post("/db/stats", this.diagnosticController.getDbStats);
this.router.get("/system", this.diagnosticController.getSystemStats);
}
getRouter() {