diff --git a/client/package-lock.json b/client/package-lock.json index e2f33e645..46bdd42c8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -57,6 +57,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", "lint-staged": "^16.2.7", + "mongoose": "9.6.2", "prettier": "^3.3.3", "typescript": "5.9.3", "vite": "^6.4.2" @@ -84,6 +85,7 @@ "node_modules/@babel/core": { "version": "7.29.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -354,6 +356,7 @@ "node_modules/@emotion/react": { "version": "11.14.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -391,6 +394,7 @@ "node_modules/@emotion/styled": { "version": "11.14.1", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1107,6 +1111,16 @@ "supercluster": "^8.0.1" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", + "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.7", "license": "MIT", @@ -1160,6 +1174,7 @@ "node_modules/@mui/material": { "version": "7.3.7", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.7", @@ -1264,6 +1279,7 @@ "node_modules/@mui/system": { "version": "7.3.7", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.7", @@ -2304,6 +2320,7 @@ "version": "24.5.2", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -2319,6 +2336,7 @@ "node_modules/@types/react": { "version": "18.3.27", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2352,6 +2370,23 @@ "version": "0.0.6", "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -2392,6 +2427,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2672,6 +2708,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3006,6 +3043,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3020,6 +3058,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/bytewise": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", @@ -3437,7 +3485,8 @@ }, "node_modules/dayjs": { "version": "1.11.13", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -3832,6 +3881,7 @@ "version": "8.57.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4627,6 +4677,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -5222,6 +5273,16 @@ "node": ">=4.0" } }, + "node_modules/kareem": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.3.0.tgz", + "integrity": "sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -5412,6 +5473,7 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", @@ -5451,6 +5513,13 @@ "node": ">= 0.4" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5540,6 +5609,109 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mongodb": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz", + "integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.6.2.tgz", + "integrity": "sha512-7m8HntjkoRnwEmuPC0kdlwcZXJOQf4twumFj+PNzg/anqqZE2Er7hQslqyzy07mP3JcFjoTSgH5765PyqOXsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kareem": "3.3.0", + "mongodb": "~7.2", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -6078,6 +6250,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6089,6 +6262,7 @@ "node_modules/react-hook-form": { "version": "7.71.1", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6288,7 +6462,8 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-persist": { "version": "6.0.0", @@ -6438,6 +6613,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6713,6 +6889,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6823,6 +7006,16 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -7136,6 +7329,19 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -7249,6 +7455,7 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7384,6 +7591,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -7472,6 +7680,30 @@ "node": ">=0.10.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/client/package.json b/client/package.json index 8f7e70820..bd58ff3cc 100644 --- a/client/package.json +++ b/client/package.json @@ -66,6 +66,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", "lint-staged": "^16.2.7", + "mongoose": "9.6.2", "prettier": "^3.3.3", "typescript": "5.9.3", "vite": "^6.4.2" diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx index a377a5925..4130f67a7 100644 --- a/client/src/Components/design-elements/Gauge.tsx +++ b/client/src/Components/design-elements/Gauge.tsx @@ -1,30 +1,36 @@ import { BaseChart } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; +import Box, { type BoxProps } from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; -import { useMemo, useState, useEffect } from "react"; +import { forwardRef, useMemo, useState, useEffect } from "react"; import { getInfraGaugeColor } from "@/Utils/MonitorUtils"; const MINIMUM_VALUE = 0; const MAXIMUM_VALUE = 100; -export const Gauge = ({ - isLoading = false, - progress = 0, - radius = 70, - strokeWidth = 15, - precision = 1, - unit = "%", -}: { +interface GaugeProps extends Omit { isLoading?: boolean; progress?: number; radius?: number; strokeWidth?: number; precision?: number; unit?: string; -}) => { +} + +export const Gauge = forwardRef(function Gauge( + { + isLoading = false, + progress = 0, + radius = 70, + strokeWidth = 15, + precision = 1, + unit = "%", + ...rest + }, + ref +) { const theme = useTheme(); const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE)); @@ -51,17 +57,19 @@ export const Gauge = ({ const fillColor = getInfraGaugeColor(progressWithinRange, theme); if (isLoading) { - return; + return null; } return ( ); -}; +}); export const DetailGauge = ({ title, diff --git a/client/src/Components/design-elements/Tooltip.tsx b/client/src/Components/design-elements/Tooltip.tsx index f9bbbb92b..36ba84f48 100644 --- a/client/src/Components/design-elements/Tooltip.tsx +++ b/client/src/Components/design-elements/Tooltip.tsx @@ -6,7 +6,11 @@ import type { TooltipProps } from "@mui/material/Tooltip"; type StyledTooltipProps = TooltipProps; -export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => { +export const Tooltip = ({ + placement = "top", + slotProps, + ...props +}: StyledTooltipProps) => { const theme = useTheme(); return ( @@ -27,6 +31,7 @@ export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => boxShadow: theme.shadows[6], }, }, + ...slotProps, }} > {props.children} diff --git a/client/src/Pages/Logs/TabDiagnostics.tsx b/client/src/Pages/Logs/TabDiagnostics.tsx index 7b7ae943a..a2ebd45c9 100644 --- a/client/src/Pages/Logs/TabDiagnostics.tsx +++ b/client/src/Pages/Logs/TabDiagnostics.tsx @@ -1,12 +1,15 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Stats } from "@/Pages/Logs/components/Stats"; +import { MongoStats } from "@/Pages/Logs/components/MongoStats"; +import { MongoCollectionStats } from "./components/MongoCollectionStats"; import { StatGauges } from "@/Pages/Logs/components/StatGauges"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/UseApi"; import type { Diagnostics } from "@/Types/Diagnostics"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; export const TabDiagnostics = () => { const theme = useTheme(); @@ -19,12 +22,12 @@ export const TabDiagnostics = () => { } = useGet("/diagnostic/system", {}, { refreshInterval: 5000 }); return ( - - - + + + {t("pages.logs.diagnostics.sections.runtimeStats.title")} @@ -34,11 +37,11 @@ export const TabDiagnostics = () => { - - + + {t("pages.logs.diagnostics.sections.memoryCpu.title")} @@ -48,6 +51,30 @@ export const TabDiagnostics = () => { + + + {t("pages.logs.diagnostics.sections.mongoDBStats.title")} + + + {t("pages.logs.diagnostics.sections.mongoDBStats.description")} + + + + + + {t("pages.logs.diagnostics.sections.mongoDBCollectionStats.title")} + + + {t("pages.logs.diagnostics.sections.mongoDBCollectionStats.description")} + + + ); }; diff --git a/client/src/Pages/Logs/components/MongoCollectionStats.tsx b/client/src/Pages/Logs/components/MongoCollectionStats.tsx new file mode 100644 index 000000000..4f20fa0b6 --- /dev/null +++ b/client/src/Pages/Logs/components/MongoCollectionStats.tsx @@ -0,0 +1,104 @@ +import Box from "@mui/material/Box"; +import { StatBox, Gauge, Tooltip } from "@/Components/design-elements"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; + +import { useTranslation } from "react-i18next"; +import prettyBytes from "pretty-bytes"; +import { useTheme } from "@mui/material"; +import type { Diagnostics } from "@/Types/Diagnostics"; +import { LAYOUT } from "@/Utils/Theme/constants"; + +interface MongoStatsProps { + diagnostics: Diagnostics | null; +} + +export const MongoCollectionStats = ({ diagnostics }: MongoStatsProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const mongoStats = diagnostics?.mongoStats; + if (!mongoStats) { + return null; + } + + return ( + *": { width: "100% !important" }, + }} + > + {mongoStats.collections.map((colStat) => ( + + + + {/* {`Documents: ${colStat.documentCount}`} */} + + {t("pages.logs.diagnostics.mongoDBCollectionStats.documents", { + count: colStat.documentCount, + })} + + {colStat.bucketCount !== undefined && ( + + {t("pages.logs.diagnostics.mongoDBCollectionStats.bucketCount", { + count: colStat.bucketCount, + })} + + )} + + {t("pages.logs.diagnostics.mongoDBCollectionStats.storageSize", { + size: prettyBytes(colStat.storageSize), + })} + + + {t("pages.logs.diagnostics.mongoDBCollectionStats.indexSize", { + size: prettyBytes(colStat.totalIndexSize), + })} + + + {t("pages.logs.diagnostics.mongoDBCollectionStats.totalSize", { + size: prettyBytes(colStat.totalSize), + })} + + + + + + + + + + ))} + + ); +}; diff --git a/client/src/Pages/Logs/components/MongoStats.tsx b/client/src/Pages/Logs/components/MongoStats.tsx new file mode 100644 index 000000000..ed7b9cc53 --- /dev/null +++ b/client/src/Pages/Logs/components/MongoStats.tsx @@ -0,0 +1,54 @@ +import Box from "@mui/material/Box"; +import { StatBox } from "@/Components/design-elements"; + +import { useTranslation } from "react-i18next"; +import prettyBytes from "pretty-bytes"; +import { useTheme } from "@mui/material"; +import type { Diagnostics } from "@/Types/Diagnostics"; +import { LAYOUT } from "@/Utils/Theme/constants"; + +interface MongoStatsProps { + diagnostics: Diagnostics | null; +} + +export const MongoStats = ({ diagnostics }: MongoStatsProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const mongoStats = diagnostics?.mongoStats; + if (!mongoStats) { + return null; + } + + return ( + *": { width: "100% !important" }, + }} + > + + + + + + + ); +}; diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx index 9d71e3253..62cb96eb2 100644 --- a/client/src/Pages/Logs/index.tsx +++ b/client/src/Pages/Logs/index.tsx @@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next"; const LogsPage = () => { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState(1); + const [activeTab, setActiveTab] = useState(2); return ( ; networkService: INetworkService; emailService: IEmailService; bufferService: IBufferService; @@ -173,55 +174,76 @@ export const initializeServices = async ({ }): Promise => { // Create DB - const dbType = envSettings.dbType; - - let db: IDb | null = null; - - if (dbType === "mongodb") { - db = new MongoDB(logger, envSettings); - } - - if (!db) { - throw new AppError({ message: "Unsupported database type", status: 500 }); - } - + let db: IDb | null = null; + db = new MongoDB(logger, envSettings); await db.connect(); - let monitorsRepository: IMonitorsRepository; - let checksRepository: IChecksRepository; - let geoChecksRepository: IGeoChecksRepository; - let monitorStatsRepository: IMonitorStatsRepository; - let statusPagesRepository: IStatusPagesRepository; - let usersRepository: IUsersRepository; - let invitesRepository: IInvitesRepository; - let recoveryTokensRepository: IRecoveryTokensRepository; - let settingsRepository: ISettingsRepository; - let notificationsRepository: INotificationsRepository; - let tagsRepository: ITagsRepository; - let incidentsRepository: IIncidentsRepository; - let teamsRepository: ITeamsRepository; - let maintenanceWindowsRepository: IMaintenanceWindowsRepository; + // ** NOTE ** + // const dbType = envSettings.dbType; + // DB type has been fixed to MongoDB for now + + // if (dbType === "mongodb") { + // db = new MongoDB(logger, envSettings); + // } + + // if (!db) { + // throw new AppError({ message: "Unsupported database type", status: 500 }); + // } + + // await db.connect(); // Repositories - if (dbType === "mongodb") { - monitorsRepository = new MongoMonitorsRepository(); - checksRepository = new MongoChecksRepository(logger); - geoChecksRepository = new MongoGeoChecksRepository(logger); - monitorStatsRepository = new MongoMonitorStatsRepository(); - statusPagesRepository = new MongoStatusPagesRepository(); - usersRepository = new MongoUsersRepository(); - invitesRepository = new MongoInvitesRepository(); - recoveryTokensRepository = new MongoRecoveryTokensRepository(); - settingsRepository = new MongoSettingsRepository(); - notificationsRepository = new MongoNotificationsRepository(); - tagsRepository = new MongoTagsRepository(); - incidentsRepository = new MongoIncidentsRepository(); - teamsRepository = new MongoTeamsRepository(); - maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); - } else { - throw new AppError({ message: "Unsupported database type", status: 500 }); - } + // ** NOTE ** + // DB Type fixed to MongoDB for now + // let monitorsRepository: IMonitorsRepository; + // let checksRepository: IChecksRepository; + // let geoChecksRepository: IGeoChecksRepository; + // let monitorStatsRepository: IMonitorStatsRepository; + // let statusPagesRepository: IStatusPagesRepository; + // let usersRepository: IUsersRepository; + // let invitesRepository: IInvitesRepository; + // let recoveryTokensRepository: IRecoveryTokensRepository; + // let settingsRepository: ISettingsRepository; + // let notificationsRepository: INotificationsRepository; + // let tagsRepository: ITagsRepository; + // let incidentsRepository: IIncidentsRepository; + // let teamsRepository: ITeamsRepository; + // let maintenanceWindowsRepository: IMaintenanceWindowsRepository; + + // if (dbType === "mongodb") { + // monitorsRepository = new MongoMonitorsRepository(); + // checksRepository = new MongoChecksRepository(logger); + // geoChecksRepository = new MongoGeoChecksRepository(logger); + // monitorStatsRepository = new MongoMonitorStatsRepository(); + // statusPagesRepository = new MongoStatusPagesRepository(); + // usersRepository = new MongoUsersRepository(); + // invitesRepository = new MongoInvitesRepository(); + // recoveryTokensRepository = new MongoRecoveryTokensRepository(); + // settingsRepository = new MongoSettingsRepository(); + // notificationsRepository = new MongoNotificationsRepository(); + // tagsRepository = new MongoTagsRepository(); + // incidentsRepository = new MongoIncidentsRepository(); + // teamsRepository = new MongoTeamsRepository(); + // maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + // } else { + // throw new AppError({ message: "Unsupported database type", status: 500 }); + // } + + const monitorsRepository = new MongoMonitorsRepository(); + const checksRepository = new MongoChecksRepository(logger); + const geoChecksRepository = new MongoGeoChecksRepository(logger); + const monitorStatsRepository = new MongoMonitorStatsRepository(); + const statusPagesRepository = new MongoStatusPagesRepository(); + const usersRepository = new MongoUsersRepository(); + const invitesRepository = new MongoInvitesRepository(); + const recoveryTokensRepository = new MongoRecoveryTokensRepository(); + const settingsRepository = new MongoSettingsRepository(); + const notificationsRepository = new MongoNotificationsRepository(); + const tagsRepository = new MongoTagsRepository(); + const incidentsRepository = new MongoIncidentsRepository(); + const teamsRepository = new MongoTeamsRepository(); + const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); // Inject settings repository into settings service (now that DB is connected) (settingsService as SettingsService).setRepository(settingsRepository); @@ -340,7 +362,7 @@ export const initializeServices = async ({ teamsRepository, }); - const diagnosticService = new DiagnosticService(); + const diagnosticService = new DiagnosticService(db); const inviteService = new InviteService({ invitesRepository, settingsService, diff --git a/server/src/db/IDb.ts b/server/src/db/IDb.ts index 4c719999f..837ad4cf9 100644 --- a/server/src/db/IDb.ts +++ b/server/src/db/IDb.ts @@ -1,4 +1,5 @@ -export interface IDb { +export interface IDb { connect(): Promise; disconnect(): Promise; + getConnection(): Promise; } diff --git a/server/src/db/MongoDB.ts b/server/src/db/MongoDB.ts index 1284aef4d..4531ce5a9 100755 --- a/server/src/db/MongoDB.ts +++ b/server/src/db/MongoDB.ts @@ -1,3 +1,4 @@ +import { Mongoose } from "mongoose"; import mongoose from "mongoose"; import AppSettings from "@/db/models/AppSettings.js"; import { runMigrations } from "@/db/migration/index.js"; @@ -5,16 +6,19 @@ import { ILogger } from "@/utils/logger.js"; import { EnvConfig } from "@/service/system/settingsService.js"; const SERVICE_NAME = "MongoDB"; import { IDb } from "@/db/IDb.js"; +import { AppError } from "@/utils/AppError.js"; -class MongoDB implements IDb { +class MongoDB implements IDb { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; private envSettings: EnvConfig; + private db: Mongoose | null; constructor(logger: ILogger, envSettings: EnvConfig) { this.logger = logger; this.envSettings = envSettings; + this.db = null; } get serviceName() { @@ -24,7 +28,7 @@ class MongoDB implements IDb { connect = async () => { try { const connectionString = this.envSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; - await mongoose.connect(connectionString); + this.db = await mongoose.connect(connectionString); // If there are no AppSettings, create one // TODO why is this here? await AppSettings.findOneAndUpdate( {}, // empty filter to match any document @@ -63,6 +67,13 @@ class MongoDB implements IDb { } }; + getConnection = async (): Promise => { + if (this.db === null) { + throw new AppError({ message: "Database not connected", service: SERVICE_NAME, method: "getConnection" }); + } + return this.db; + }; + disconnect = async () => { try { this.logger.info({ message: "Disconnecting from MongoDB", service: SERVICE_NAME, method: "disconnect" }); diff --git a/server/src/service/business/diagnosticService.ts b/server/src/service/business/diagnosticService.ts index 45a5592f5..2d9968240 100644 --- a/server/src/service/business/diagnosticService.ts +++ b/server/src/service/business/diagnosticService.ts @@ -1,24 +1,50 @@ import v8 from "v8"; import os from "os"; +import { IDb } from "@/db/IDb.js"; +import { Mongoose } from "mongoose"; +import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "diagnosticService"; +export interface CollectionDiagnostics { + name: string; + documentCount: number; + storageSize: number; + totalIndexSize: number; + totalSize: number; + bucketCount?: number; +} + +export interface MongoDiagnostics { + readyState: number; + host: string; + port: number; + dbName: string; + totalSize: number; + collections: CollectionDiagnostics[]; +} + +export interface Diagnostics { + osStats: { totalMemoryBytes: number }; + cpuUsage: { usagePercentage: number }; + v8HeapStats: { + totalHeapSizeBytes: number; + usedHeapSizeBytes: number; + heapSizeLimitBytes: number; + }; + eventLoopDelayMs: number; + uptimeMs: number; + mongoStats: MongoDiagnostics; +} + export interface IDiagnosticService { - getCPUUsage(): Promise<{ userUsageMs: number; systemUsageMs: number; usagePercentage: number }>; - getSystemStats(): Promise<{ - osStats: { freeMemoryBytes: number; totalMemoryBytes: number }; - memoryUsage: Record; - cpuUsage: { userUsageMs: number; systemUsageMs: number; usagePercentage: number }; - v8HeapStats: { totalHeapSizeBytes: number; usedHeapSizeBytes: number; heapSizeLimitBytes: number }; - eventLoopDelayMs: number; - uptimeMs: number; - }>; + getSystemStats(): Promise; } export class DiagnosticService implements IDiagnosticService { static SERVICE_NAME = SERVICE_NAME; - constructor() { + constructor(private db: IDb) { /** * Performance Observer for monitoring system performance metrics. * Clears performance marks after each measurement to prevent memory leaks. @@ -34,75 +60,86 @@ export class DiagnosticService implements IDiagnosticService { return DiagnosticService.SERVICE_NAME; } - getCPUUsage = async () => { + private getCPUUsagePercentage = async (): Promise => { const startUsage = process.cpuUsage(); const timingPeriod = 1000; // measured in ms await new Promise((resolve) => setTimeout(resolve, timingPeriod)); const endUsage = process.cpuUsage(startUsage); - const cpuUsage = { - userUsageMs: endUsage.user / 1000, - systemUsageMs: endUsage.system / 1000, - usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100, - }; - return cpuUsage; + return ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100; }; - getSystemStats = async () => { - // Memory Usage - const totalMemory = os.totalmem(); - const freeMemory = os.freemem(); + private getMongoDBStats = async (): Promise => { + const mongo = await this.db.getConnection(); + const db = mongo.connection.db; + if (!db) { + throw new AppError({ message: "Database connection is not available", service: SERVICE_NAME, method: "getMongoDBStats" }); + } - const osStats = { - freeMemoryBytes: freeMemory, // bytes - totalMemoryBytes: totalMemory, // bytes + const dbStats = await db.stats(); + const rawCollections = await db.collections(); + + const collections: CollectionDiagnostics[] = await Promise.all( + rawCollections + .filter((collection) => !collection.collectionName.startsWith("system")) + .map(async (collection) => { + const stats = await db.command({ collStats: collection.collectionName }); + const documentCount = await db.collection(collection.collectionName).countDocuments({}); + const entry: CollectionDiagnostics = { + name: collection.collectionName, + documentCount, + storageSize: stats.storageSize, + totalIndexSize: stats.totalIndexSize, + totalSize: stats.totalSize, + }; + if (stats.timeseries?.bucketCount !== undefined) { + entry.bucketCount = stats.timeseries.bucketCount; + } + return entry; + }) + ); + + return { + readyState: mongo.connection.readyState, + host: mongo.connection.host, + port: mongo.connection.port, + dbName: mongo.connection.name, + totalSize: dbStats.totalSize, + collections, }; + }; - const used = process.memoryUsage(); + getSystemStats = async (): Promise => { + const osStats = { totalMemoryBytes: os.totalmem() }; - // In MB - const memoryUsage: Record = { - rss: Math.round((used.rss / 1024 / 1024) * 100) / 100, - heapTotal: Math.round((used.heapTotal / 1024 / 1024) * 100) / 100, - heapUsed: Math.round((used.heapUsed / 1024 / 1024) * 100) / 100, - external: Math.round((used.external / 1024 / 1024) * 100) / 100, - arrayBuffers: Math.round((used.arrayBuffers / 1024 / 1024) * 100) / 100, - }; + const cpuUsage = { usagePercentage: await this.getCPUUsagePercentage() }; - // 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 + const v8HeapStats = { + totalHeapSizeBytes: heapStats.total_heap_size, + usedHeapSizeBytes: heapStats.used_heap_size, + heapSizeLimitBytes: heapStats.heap_size_limit, }; - // Event Loop Delay - let eventLoopDelay = 0; + let eventLoopDelayMs = 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 && entries[0] !== undefined) { - eventLoopDelay = entries[0].duration; + eventLoopDelayMs = entries[0].duration; } - // Uptime - const uptimeMs = process.uptime() * 1000; // ms + const uptimeMs = process.uptime() * 1000; + const mongoStats = await this.getMongoDBStats(); - // Combine Metrics - const diagnostics = { + return { osStats, - memoryUsage, - cpuUsage: cpuMetrics, - v8HeapStats: v8Metrics, - eventLoopDelayMs: eventLoopDelay, + cpuUsage, + v8HeapStats, + eventLoopDelayMs, uptimeMs, + mongoStats, }; - - return diagnostics; }; } diff --git a/server/test/unit/services/diagnosticService.test.ts b/server/test/unit/services/diagnosticService.test.ts index a76f28893..ae57613a1 100644 --- a/server/test/unit/services/diagnosticService.test.ts +++ b/server/test/unit/services/diagnosticService.test.ts @@ -1,5 +1,49 @@ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts"; +import type { IDb } from "../../../src/db/IDb.ts"; +import type { Mongoose } from "mongoose"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +const makeDb = (overrides?: { + collections?: Array<{ collectionName: string }>; + collStats?: Record; + dbStats?: Record; + countDocuments?: number; + noDb?: boolean; +}): IDb => { + const collections = overrides?.collections ?? []; + const collStats = overrides?.collStats ?? { + storageSize: 100, + totalIndexSize: 50, + totalSize: 150, + }; + const dbStats = overrides?.dbStats ?? { totalSize: 1000 }; + const countDocuments = overrides?.countDocuments ?? 42; + + const dbApi = overrides?.noDb + ? null + : { + stats: jest.fn<() => Promise>>().mockResolvedValue(dbStats), + collections: jest.fn<() => Promise>>().mockResolvedValue(collections), + command: jest.fn<() => Promise>>().mockResolvedValue(collStats), + collection: jest.fn().mockReturnValue({ + countDocuments: jest.fn<() => Promise>().mockResolvedValue(countDocuments), + }), + }; + + return { + getConnection: jest.fn<() => Promise>().mockResolvedValue({ + connection: { + db: dbApi, + readyState: 1, + host: "localhost", + port: 27017, + name: "uptime_db", + }, + } as unknown as Mongoose), + } as unknown as IDb; +}; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -18,7 +62,7 @@ describe("DiagnosticService", () => { }); it("returns diagnosticService from instance getter", () => { - const service = new DiagnosticService(); + const service = new DiagnosticService(makeDb()); expect(service.serviceName).toBe("diagnosticService"); }); }); @@ -37,12 +81,11 @@ describe("DiagnosticService", () => { }) as any; const clearMarksSpy = jest.spyOn(performance, "clearMarks"); - const service = new DiagnosticService(); + const service = new DiagnosticService(makeDb()); expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] }); expect(capturedCallback).not.toBeNull(); - // Invoke the callback manually const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList; capturedCallback!(mockEntryList); @@ -55,68 +98,88 @@ describe("DiagnosticService", () => { }); }); - // ── getCPUUsage ───────────────────────────────────────────────────────── - - describe("getCPUUsage", () => { - it("returns cpu usage metrics after timing period", async () => { - const service = new DiagnosticService(); - const promise = service.getCPUUsage(); - jest.advanceTimersByTime(1000); - const result = await promise; - - expect(result).toHaveProperty("userUsageMs"); - expect(result).toHaveProperty("systemUsageMs"); - expect(result).toHaveProperty("usagePercentage"); - expect(typeof result.userUsageMs).toBe("number"); - expect(typeof result.systemUsageMs).toBe("number"); - expect(typeof result.usagePercentage).toBe("number"); - }); - }); - // ── getSystemStats ────────────────────────────────────────────────────── describe("getSystemStats", () => { - it("returns all diagnostic sections", async () => { - const service = new DiagnosticService(); + it("returns only the slimmed diagnostic shape (no serverStatus/serverInfo/memoryUsage)", async () => { + const service = new DiagnosticService(makeDb()); const promise = service.getSystemStats(); - // getCPUUsage has 1s timeout, then event loop delay has 0ms timeout + // getCPUUsagePercentage waits 1s, then event loop delay waits 0ms jest.advanceTimersByTime(1000); - // Need to flush the microtask queue and advance again for the nested setTimeout(0) await jest.advanceTimersByTimeAsync(10); const result = await promise; - expect(result).toHaveProperty("osStats"); - expect(result.osStats).toHaveProperty("freeMemoryBytes"); - expect(result.osStats).toHaveProperty("totalMemoryBytes"); - - expect(result).toHaveProperty("memoryUsage"); - expect(result.memoryUsage).toHaveProperty("rss"); - expect(result.memoryUsage).toHaveProperty("heapTotal"); - expect(result.memoryUsage).toHaveProperty("heapUsed"); - expect(result.memoryUsage).toHaveProperty("external"); - expect(result.memoryUsage).toHaveProperty("arrayBuffers"); - - expect(result).toHaveProperty("cpuUsage"); - expect(result.cpuUsage).toHaveProperty("userUsageMs"); - - expect(result).toHaveProperty("v8HeapStats"); - expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes"); - expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes"); - expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes"); + expect(Object.keys(result).sort()).toEqual(["cpuUsage", "eventLoopDelayMs", "mongoStats", "osStats", "uptimeMs", "v8HeapStats"].sort()); + expect(result.osStats).toEqual({ totalMemoryBytes: expect.any(Number) }); + expect(result.cpuUsage).toEqual({ usagePercentage: expect.any(Number) }); + expect(result.v8HeapStats).toEqual({ + totalHeapSizeBytes: expect.any(Number), + usedHeapSizeBytes: expect.any(Number), + heapSizeLimitBytes: expect.any(Number), + }); expect(typeof result.eventLoopDelayMs).toBe("number"); - expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); - expect(typeof result.uptimeMs).toBe("number"); expect(result.uptimeMs).toBeGreaterThan(0); }); + it("returns slimmed mongoStats with `collections` (not `collectionStats`) and no serverStatus/serverInfo", async () => { + const db = makeDb({ + collections: [{ collectionName: "monitors" }, { collectionName: "checks" }], + collStats: { storageSize: 200, totalIndexSize: 80, totalSize: 280 }, + dbStats: { totalSize: 9999 }, + countDocuments: 7, + }); + const service = new DiagnosticService(db); + const promise = service.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + + expect(Object.keys(result.mongoStats).sort()).toEqual(["collections", "dbName", "host", "port", "readyState", "totalSize"].sort()); + expect(result.mongoStats.totalSize).toBe(9999); + expect(result.mongoStats.collections).toHaveLength(2); + expect(result.mongoStats.collections[0]).toEqual({ + name: "monitors", + documentCount: 7, + storageSize: 200, + totalIndexSize: 80, + totalSize: 280, + }); + }); + + it("includes bucketCount on timeseries collections, omits it elsewhere", async () => { + const db = makeDb({ + collections: [{ collectionName: "checks" }], + collStats: { storageSize: 1, totalIndexSize: 2, totalSize: 3, timeseries: { bucketCount: 12 } }, + }); + const service = new DiagnosticService(db); + const promise = service.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + + expect(result.mongoStats.collections[0]?.bucketCount).toBe(12); + + const db2 = makeDb({ collections: [{ collectionName: "monitors" }] }); + const service2 = new DiagnosticService(db2); + const promise2 = service2.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result2 = await promise2; + expect(result2.mongoStats.collections[0]).not.toHaveProperty("bucketCount"); + }); + + it("throws AppError when the mongo connection has no db handle", async () => { + jest.useRealTimers(); + const service = new DiagnosticService(makeDb({ noDb: true })); + await expect(service.getSystemStats()).rejects.toThrow("Database connection is not available"); + jest.useFakeTimers(); + }); + it("reads eventLoopDelay from real performance entries", async () => { jest.useRealTimers(); - const service = new DiagnosticService(); - - // Call getSystemStats with real timers — slow but guarantees real perf entries + const service = new DiagnosticService(makeDb()); const result = await service.getSystemStats(); - expect(typeof result.eventLoopDelayMs).toBe("number"); expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); jest.useFakeTimers(); @@ -126,7 +189,7 @@ describe("DiagnosticService", () => { { label: "empty entries array", entries: [] }, { label: "undefined entry in array", entries: [undefined] }, ])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => { - const service = new DiagnosticService(); + const service = new DiagnosticService(makeDb()); const originalGetEntries = performance.getEntriesByName; const originalMeasure = performance.measure; const originalMark = performance.mark;