add diagnostic page

This commit is contained in:
Alex Holliday
2025-07-10 16:08:04 -07:00
parent cc26601c06
commit 1cbec8220d
13 changed files with 279 additions and 64 deletions

View File

@@ -54,8 +54,8 @@ 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;
return (
<Box

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

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

View File

@@ -0,0 +1,74 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
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";
const GaugeBox = ({ title, subtitle, children }) => {
const theme = useTheme();
return (
<Stack
alignItems="center"
p={theme.spacing(2)}
>
{children}
<Typography variant="h2">{title}</Typography>
<Typography variant="body2">{subtitle}</Typography>
</Stack>
);
};
const Gauges = ({ diagnostics }) => {
const heapTotalSize = getPercentage(
diagnostics?.v8HeapStats?.totalHeapSizeMb,
diagnostics?.v8HeapStats?.heapSizeLimitMb
);
const heapUsedSize = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeMb,
diagnostics?.v8HeapStats?.heapSizeLimitMb
);
const actualHeapUsed = getPercentage(
diagnostics?.v8HeapStats?.usedHeapSizeMb,
diagnostics?.v8HeapStats?.totalHeapSizeMb
);
const theme = useTheme();
return (
<Stack
direction="row"
spacing={theme.spacing(4)}
>
<GaugeBox
title="Heap Allocation"
subtitle="% of available memory"
>
<Gauge progress={heapTotalSize} />
</GaugeBox>
<GaugeBox
title="Heap Usage"
subtitle="% of available memory"
>
<Gauge progress={heapUsedSize} />
</GaugeBox>
<GaugeBox
title="Heap Utilization"
subtitle="% of Allocated"
>
<Gauge progress={actualHeapUsed} />
</GaugeBox>
</Stack>
);
};
Gauges.propTypes = {
diagnostics: PropTypes.object.isRequired,
};
export default Gauges;

View File

@@ -0,0 +1,4 @@
export const getPercentage = (value, total) => {
if (!value || !total) return 0;
return (value / total) * 100;
};

View File

@@ -0,0 +1,30 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Gauges from "./components/gauges";
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();
console.log(diagnostics);
// Setup
return (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="h2">{t("diagnosticsPage.description")}</Typography>
</Box>
<Gauges diagnostics={diagnostics} />
</Stack>
);
};
export default Diagnostics;

View File

@@ -51,7 +51,7 @@ const Logs = () => {
return (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="body">{t("logsPage.description")}</Typography>
<Typography variant="h2">{t("logsPage.description")}</Typography>
</Box>
<Stack
direction="row"

View File

@@ -8,7 +8,7 @@ import Button from "@mui/material/Button";
// 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";

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

View File

@@ -1091,6 +1091,14 @@ class NetworkService {
},
});
}
async getDiagnostics() {
return this.axiosInstance.get(`/diagnostic/system`, {
headers: {
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;

View File

@@ -451,13 +451,17 @@
},
"tabs": {
"logs": "Server logs",
"queue": "Job queue"
"queue": "Job queue",
"diagnostics": "Diagnostics"
},
"title": "Logs",
"toast": {
"fetchLogsSuccess": "Logs fetched successfully"
}
},
"diagnosticsPage": {
"description": "System diagnostics"
},
"low": "low",
"maintenance": "maintenance",
"maintenanceRepeat": "Maintenance Repeat",

View File

@@ -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,70 @@ class DiagnosticController {
next(handleError(error, SERVICE_NAME, "getDbStats"));
}
}
async getSystemStats(req, res, next) {
try {
// Memory Usage
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const osStats = {
freeMemoryMb: freeMemory / 1024 / 1024, // MB
totalMemoryMb: totalMemory / 1024 / 1024, // MB
};
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 cpuUsage = process.cpuUsage();
const cpuMetrics = {
userUsageMs: cpuUsage.user / 1000, // ms
systemUsageMs: cpuUsage.system / 1000, // ms
};
// V8 Heap Statistics
const heapStats = v8.getHeapStatistics();
const v8Metrics = {
totalHeapSizeMb: heapStats.total_heap_size / 1024 / 1024, // MB
usedHeapSizeMb: heapStats.used_heap_size / 1024 / 1024, // MB
heapSizeLimitMb: heapStats.heap_size_limit / 1024 / 1024, // MB
};
// 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 uptime = process.uptime(); // seconds
// Combine Metrics
const diagnostics = {
osStats,
memoryUsage,
cpuUsage: cpuMetrics,
v8HeapStats: v8Metrics,
eventLoopDelayMs: eventLoopDelay,
uptimeSeconds: uptime,
};
return res.success({
msg: "OK",
data: diagnostics,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getMemoryUsage"));
}
}
}
export default DiagnosticController;

View File

@@ -13,6 +13,7 @@ class DiagnosticRoutes {
);
this.router.post("/db/stats", this.diagnosticController.getDbStats);
this.router.get("/system", this.diagnosticController.getSystemStats);
}
getRouter() {