From 2d432e0a3d8111a6ba711cedccb4f006f71d732c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 13:31:13 -0700 Subject: [PATCH 01/11] add mongo stats to diagnostic service --- server/src/config/services.ts | 111 +++++++++++------- server/src/db/IDb.ts | 3 +- server/src/db/MongoDB.ts | 15 ++- .../src/service/business/diagnosticService.ts | 47 +++++++- 4 files changed, 127 insertions(+), 49 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 09c97e1fc..363e699d9 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -121,7 +121,7 @@ import { IMaintenanceWindowsRepository, } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; -import { AppError } from "@/utils/AppError.js"; +// import { AppError } from "@/utils/AppError.js"; export type InitializedServices = { settingsService: ISettingsService; @@ -173,55 +173,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 }); - } - + 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 +361,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..49c691175 100644 --- a/server/src/service/business/diagnosticService.ts +++ b/server/src/service/business/diagnosticService.ts @@ -1,5 +1,8 @@ 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"; @@ -18,7 +21,7 @@ export interface IDiagnosticService { 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. @@ -47,6 +50,44 @@ export class DiagnosticService implements IDiagnosticService { return cpuUsage; }; + private getMongoDBStats = async () => { + 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 readyState = mongo.connection.readyState; + const host = mongo.connection.host; + const port = mongo.connection.port; + const dbName = mongo.connection.name; + + const serverStatus = await db.admin().serverStatus(); + const serverInfo = await db.admin().serverInfo(); + + const stats = await db.stats(); + const collections = await db.collections(); + + const collectionStats = await Promise.all( + (collections || []).map(async (collection) => { + const stats = await db.command({ collStats: collection.collectionName }); + const documentCount = await db.collection(collection.collectionName).countDocuments({}); + return { name: collection.collectionName, documentCount, ...stats }; + }) + ); + + return { + readyState, + host, + port, + dbName, + serverStatus, + serverInfo, + stats, + collectionStats, + }; + }; + getSystemStats = async () => { // Memory Usage const totalMemory = os.totalmem(); @@ -93,6 +134,9 @@ export class DiagnosticService implements IDiagnosticService { // Uptime const uptimeMs = process.uptime() * 1000; // ms + // Mongo + const mongoStats = await this.getMongoDBStats(); + // Combine Metrics const diagnostics = { osStats, @@ -101,6 +145,7 @@ export class DiagnosticService implements IDiagnosticService { v8HeapStats: v8Metrics, eventLoopDelayMs: eventLoopDelay, uptimeMs, + mongoStats, }; return diagnostics; From bf43632482bc05f2c6b5b4ebc523eb108fc8d36c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 13:31:44 -0700 Subject: [PATCH 02/11] add mongo types --- client/package-lock.json | 236 ++++++++++++++++++++++++++++++++++++++- client/package.json | 1 + 2 files changed, 235 insertions(+), 2 deletions(-) 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" From 20167d537b69dca0c5f63308960d8f1bb220daa5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 13:32:05 -0700 Subject: [PATCH 03/11] update diagnostics type --- client/src/Types/Diagnostics.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/client/src/Types/Diagnostics.ts b/client/src/Types/Diagnostics.ts index fe2ea6f16..001c3d64a 100644 --- a/client/src/Types/Diagnostics.ts +++ b/client/src/Types/Diagnostics.ts @@ -1,3 +1,5 @@ +import type { ConnectionStates, mongo } from "mongoose"; + export interface Diagnostics { osStats: OsStats; memoryUsage: MemoryUsage; @@ -5,6 +7,7 @@ export interface Diagnostics { v8HeapStats: V8HeapStats; eventLoopDelayMs: number; uptimeMs: number; + mongoStats: MongoStats; } export interface OsStats { @@ -31,3 +34,14 @@ export interface V8HeapStats { usedHeapSizeBytes: number; heapSizeLimitBytes: number; } + +export interface MongoStats { + readyState: ConnectionStates; + host: string; + port: number; + dbName: string; + serverStatus: mongo.Document; + serverInfo: mongo.Document; + stats: mongo.Document; + collectionStats: mongo.Document[]; +} From 2c89086b68b1d4bdd93ef27bcb229ffe3423c19c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 13:32:30 -0700 Subject: [PATCH 04/11] add mongo stats component --- .../src/Pages/Logs/components/MongoStats.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 client/src/Pages/Logs/components/MongoStats.tsx diff --git a/client/src/Pages/Logs/components/MongoStats.tsx b/client/src/Pages/Logs/components/MongoStats.tsx new file mode 100644 index 000000000..807a90fa7 --- /dev/null +++ b/client/src/Pages/Logs/components/MongoStats.tsx @@ -0,0 +1,83 @@ +import Box from "@mui/material/Box"; +import { StatBox } from "@/Components/design-elements"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; + +import prettyBytes from "pretty-bytes"; +import prettyMilliseconds from "pretty-ms"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { Diagnostics } from "@/Types/Diagnostics"; +import { LAYOUT } from "@/Utils/Theme/constants"; + +interface MongoStatsProps { + diagnostics: Diagnostics | null; +} + +const PLACEHOLDER = "—"; + +export const MongoStats = ({ diagnostics }: MongoStatsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const mongoStats = diagnostics?.mongoStats; + if (!mongoStats) { + return null; + } + + console.log("Mongo Stats:", mongoStats); // Debug log to check the structure of mongoStats + + return ( + *": { width: "100% !important" }, + }} + > + + + + + + {mongoStats.collectionStats.map((colStat) => { + console.log(colStat); + return ( + + + {colStat.documentCount !== undefined && ( + {`Documents: ${colStat.documentCount}`} + )} + {colStat.timeseries?.bucketCount !== undefined && ( + {`Bucket count: ${colStat.timeseries.bucketCount}`} + )} + {`Storage size: ${prettyBytes(colStat.storageSize)}`} + {`Index size: ${prettyBytes(colStat.totalIndexSize)}`} + {`Total size: ${prettyBytes(colStat.totalSize)}`} + + + ); + })} + + ); +}; From 290adf024c6d9a6fadbe256de293d4ff86c6ced4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 13:32:48 -0700 Subject: [PATCH 05/11] add mongo stats --- client/src/Pages/Logs/TabDiagnostics.tsx | 10 ++++++++-- client/src/Pages/Logs/index.tsx | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Logs/TabDiagnostics.tsx b/client/src/Pages/Logs/TabDiagnostics.tsx index 7b7ae943a..2f534dbfa 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 { StatGauges } from "@/Pages/Logs/components/StatGauges"; +import { StatBox } from "@/Components/design-elements"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/UseApi"; import type { Diagnostics } from "@/Types/Diagnostics"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const TabDiagnostics = () => { const theme = useTheme(); @@ -24,7 +27,7 @@ export const TabDiagnostics = () => { {t("pages.logs.diagnostics.sections.runtimeStats.title")} @@ -38,7 +41,7 @@ export const TabDiagnostics = () => { {t("pages.logs.diagnostics.sections.memoryCpu.title")} @@ -48,6 +51,9 @@ export const TabDiagnostics = () => { + + + ); }; 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 ( Date: Tue, 26 May 2026 14:53:33 -0700 Subject: [PATCH 06/11] tighten server types --- server/src/config/services.ts | 5 +- .../src/service/business/diagnosticService.ts | 158 ++++++++--------- .../unit/services/diagnosticService.test.ts | 163 ++++++++++++------ 3 files changed, 191 insertions(+), 135 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 363e699d9..4eb6c1081 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -1,3 +1,4 @@ +import { Mongoose } from "mongoose"; import MongoDB from "../db/MongoDB.js"; import { IDb } from "@/db/IDb.js"; import { @@ -125,7 +126,7 @@ import { ILogger } from "@/utils/logger.js"; export type InitializedServices = { settingsService: ISettingsService; - db: IDb; + db: IDb; networkService: INetworkService; emailService: IEmailService; bufferService: IBufferService; @@ -173,7 +174,7 @@ export const initializeServices = async ({ }): Promise => { // Create DB - let db: IDb | null = null; + let db: IDb | null = null; db = new MongoDB(logger, envSettings); await db.connect(); diff --git a/server/src/service/business/diagnosticService.ts b/server/src/service/business/diagnosticService.ts index 49c691175..2d9968240 100644 --- a/server/src/service/business/diagnosticService.ts +++ b/server/src/service/business/diagnosticService.ts @@ -6,16 +6,39 @@ 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 { @@ -37,117 +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; }; - private getMongoDBStats = async () => { + 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 readyState = mongo.connection.readyState; - const host = mongo.connection.host; - const port = mongo.connection.port; - const dbName = mongo.connection.name; + const dbStats = await db.stats(); + const rawCollections = await db.collections(); - const serverStatus = await db.admin().serverStatus(); - const serverInfo = await db.admin().serverInfo(); - - const stats = await db.stats(); - const collections = await db.collections(); - - const collectionStats = await Promise.all( - (collections || []).map(async (collection) => { - const stats = await db.command({ collStats: collection.collectionName }); - const documentCount = await db.collection(collection.collectionName).countDocuments({}); - return { name: collection.collectionName, documentCount, ...stats }; - }) + 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, - host, - port, - dbName, - serverStatus, - serverInfo, - stats, - collectionStats, + readyState: mongo.connection.readyState, + host: mongo.connection.host, + port: mongo.connection.port, + dbName: mongo.connection.name, + totalSize: dbStats.totalSize, + collections, }; }; - getSystemStats = async () => { - // Memory Usage - const totalMemory = os.totalmem(); - const freeMemory = os.freemem(); + getSystemStats = async (): Promise => { + const osStats = { totalMemoryBytes: os.totalmem() }; - const osStats = { - freeMemoryBytes: freeMemory, // bytes - totalMemoryBytes: totalMemory, // bytes - }; + const cpuUsage = { usagePercentage: await this.getCPUUsagePercentage() }; - const used = process.memoryUsage(); - - // 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, - }; - - // 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 - - // Mongo + 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; From f4b14e7df988c61686b63590c7955d3208d4dbcf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 14:53:56 -0700 Subject: [PATCH 07/11] update clinet side diagnostics type --- client/src/Types/Diagnostics.ts | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/client/src/Types/Diagnostics.ts b/client/src/Types/Diagnostics.ts index 001c3d64a..be71c9a8c 100644 --- a/client/src/Types/Diagnostics.ts +++ b/client/src/Types/Diagnostics.ts @@ -1,8 +1,5 @@ -import type { ConnectionStates, mongo } from "mongoose"; - export interface Diagnostics { osStats: OsStats; - memoryUsage: MemoryUsage; cpuUsage: CpuUsage; v8HeapStats: V8HeapStats; eventLoopDelayMs: number; @@ -11,21 +8,10 @@ export interface Diagnostics { } export interface OsStats { - freeMemoryBytes: number; totalMemoryBytes: number; } -export interface MemoryUsage { - rss: number; - heapTotal: number; - heapUsed: number; - external: number; - arrayBuffers: number; -} - export interface CpuUsage { - userUsageMs: number; - systemUsageMs: number; usagePercentage: number; } @@ -36,12 +22,19 @@ export interface V8HeapStats { } export interface MongoStats { - readyState: ConnectionStates; + readyState: number; host: string; port: number; dbName: string; - serverStatus: mongo.Document; - serverInfo: mongo.Document; - stats: mongo.Document; - collectionStats: mongo.Document[]; + totalSize: number; + collections: CollectionStats[]; +} + +export interface CollectionStats { + name: string; + documentCount: number; + storageSize: number; + totalIndexSize: number; + totalSize: number; + bucketCount?: number; } From d18a53eec2293b252caf081f9bad656156bcc48a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 14:54:30 -0700 Subject: [PATCH 08/11] add forward ref to gauage for tooltip --- .../src/Components/design-elements/Gauge.tsx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) 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, From 806c7764b1f4db0c6bb64cb6967ce36be8e324ba Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 14:54:54 -0700 Subject: [PATCH 09/11] add slotprops prop to tooltip --- client/src/Components/design-elements/Tooltip.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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} From 949beff4f924cc619e3314c0dfa256b3b0f2117e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 14:55:22 -0700 Subject: [PATCH 10/11] add mongo collection stats component, reorder layout --- client/src/Pages/Logs/TabDiagnostics.tsx | 24 +++++- .../Logs/components/MongoCollectionStats.tsx | 80 +++++++++++++++++++ .../src/Pages/Logs/components/MongoStats.tsx | 33 +------- 3 files changed, 103 insertions(+), 34 deletions(-) create mode 100644 client/src/Pages/Logs/components/MongoCollectionStats.tsx diff --git a/client/src/Pages/Logs/TabDiagnostics.tsx b/client/src/Pages/Logs/TabDiagnostics.tsx index 2f534dbfa..8b641420e 100644 --- a/client/src/Pages/Logs/TabDiagnostics.tsx +++ b/client/src/Pages/Logs/TabDiagnostics.tsx @@ -2,14 +2,13 @@ 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 { StatBox } from "@/Components/design-elements"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/UseApi"; import type { Diagnostics } from "@/Types/Diagnostics"; -import { LAYOUT } from "@/Utils/Theme/constants"; export const TabDiagnostics = () => { const theme = useTheme(); @@ -52,8 +51,29 @@ export const TabDiagnostics = () => { + + {t("MongoDB Stats")} + + + {t("Information about MongoDB")} + + + + {t("MongoDB Collection Stats")} + + + {t("Information about Collections")} + + + ); }; diff --git a/client/src/Pages/Logs/components/MongoCollectionStats.tsx b/client/src/Pages/Logs/components/MongoCollectionStats.tsx new file mode 100644 index 000000000..e215b0bc4 --- /dev/null +++ b/client/src/Pages/Logs/components/MongoCollectionStats.tsx @@ -0,0 +1,80 @@ +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 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 mongoStats = diagnostics?.mongoStats; + if (!mongoStats) { + return null; + } + + return ( + *": { width: "100% !important" }, + }} + > + {mongoStats.collections.map((colStat) => ( + + + + {`Documents: ${colStat.documentCount}`} + {colStat.bucketCount !== undefined && ( + {`Bucket count: ${colStat.bucketCount}`} + )} + {`Storage size: ${prettyBytes(colStat.storageSize)}`} + {`Index size: ${prettyBytes(colStat.totalIndexSize)}`} + {`Total size: ${prettyBytes(colStat.totalSize)}`} + + + + + + + + + ))} + + ); +}; diff --git a/client/src/Pages/Logs/components/MongoStats.tsx b/client/src/Pages/Logs/components/MongoStats.tsx index 807a90fa7..00263e7a4 100644 --- a/client/src/Pages/Logs/components/MongoStats.tsx +++ b/client/src/Pages/Logs/components/MongoStats.tsx @@ -1,12 +1,8 @@ import Box from "@mui/material/Box"; import { StatBox } from "@/Components/design-elements"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import prettyBytes from "pretty-bytes"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; -import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; import { LAYOUT } from "@/Utils/Theme/constants"; @@ -14,10 +10,7 @@ interface MongoStatsProps { diagnostics: Diagnostics | null; } -const PLACEHOLDER = "—"; - export const MongoStats = ({ diagnostics }: MongoStatsProps) => { - const { t } = useTranslation(); const theme = useTheme(); const mongoStats = diagnostics?.mongoStats; @@ -25,8 +18,6 @@ export const MongoStats = ({ diagnostics }: MongoStatsProps) => { return null; } - console.log("Mongo Stats:", mongoStats); // Debug log to check the structure of mongoStats - return ( { /> - {mongoStats.collectionStats.map((colStat) => { - console.log(colStat); - return ( - - - {colStat.documentCount !== undefined && ( - {`Documents: ${colStat.documentCount}`} - )} - {colStat.timeseries?.bucketCount !== undefined && ( - {`Bucket count: ${colStat.timeseries.bucketCount}`} - )} - {`Storage size: ${prettyBytes(colStat.storageSize)}`} - {`Index size: ${prettyBytes(colStat.totalIndexSize)}`} - {`Total size: ${prettyBytes(colStat.totalSize)}`} - - - ); - })} ); }; From 397541a789565c1bec4aee2e1033f34dbd0ea2e8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 26 May 2026 15:09:33 -0700 Subject: [PATCH 11/11] extract strings --- client/src/Pages/Logs/TabDiagnostics.tsx | 23 +++++----- .../Logs/components/MongoCollectionStats.tsx | 44 ++++++++++++++----- .../src/Pages/Logs/components/MongoStats.tsx | 12 ++--- client/src/locales/en.json | 23 ++++++++++ 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/client/src/Pages/Logs/TabDiagnostics.tsx b/client/src/Pages/Logs/TabDiagnostics.tsx index 8b641420e..a2ebd45c9 100644 --- a/client/src/Pages/Logs/TabDiagnostics.tsx +++ b/client/src/Pages/Logs/TabDiagnostics.tsx @@ -9,6 +9,7 @@ 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(); @@ -21,9 +22,9 @@ export const TabDiagnostics = () => { } = useGet("/diagnostic/system", {}, { refreshInterval: 5000 }); return ( - - - + + + { - - + + { - + - {t("MongoDB Stats")} + {t("pages.logs.diagnostics.sections.mongoDBStats.title")} - {t("Information about MongoDB")} + {t("pages.logs.diagnostics.sections.mongoDBStats.description")} - + - {t("MongoDB Collection Stats")} + {t("pages.logs.diagnostics.sections.mongoDBCollectionStats.title")} - {t("Information about Collections")} + {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 index e215b0bc4..4f20fa0b6 100644 --- a/client/src/Pages/Logs/components/MongoCollectionStats.tsx +++ b/client/src/Pages/Logs/components/MongoCollectionStats.tsx @@ -3,6 +3,7 @@ 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"; @@ -14,6 +15,7 @@ interface MongoStatsProps { export const MongoCollectionStats = ({ diagnostics }: MongoStatsProps) => { const theme = useTheme(); + const { t } = useTranslation(); const mongoStats = diagnostics?.mongoStats; if (!mongoStats) { @@ -41,17 +43,42 @@ export const MongoCollectionStats = ({ diagnostics }: MongoStatsProps) => { justifyContent={"space-between"} > - {`Documents: ${colStat.documentCount}`} + {/* {`Documents: ${colStat.documentCount}`} */} + + {t("pages.logs.diagnostics.mongoDBCollectionStats.documents", { + count: colStat.documentCount, + })} + {colStat.bucketCount !== undefined && ( - {`Bucket count: ${colStat.bucketCount}`} + + {t("pages.logs.diagnostics.mongoDBCollectionStats.bucketCount", { + count: colStat.bucketCount, + })} + )} - {`Storage size: ${prettyBytes(colStat.storageSize)}`} - {`Index size: ${prettyBytes(colStat.totalIndexSize)}`} - {`Total size: ${prettyBytes(colStat.totalSize)}`} + + {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 index 00263e7a4..ed7b9cc53 100644 --- a/client/src/Pages/Logs/components/MongoStats.tsx +++ b/client/src/Pages/Logs/components/MongoStats.tsx @@ -1,6 +1,7 @@ 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"; @@ -12,6 +13,7 @@ interface MongoStatsProps { export const MongoStats = ({ diagnostics }: MongoStatsProps) => { const theme = useTheme(); + const { t } = useTranslation(); const mongoStats = diagnostics?.mongoStats; if (!mongoStats) { @@ -28,23 +30,23 @@ export const MongoStats = ({ diagnostics }: MongoStatsProps) => { }} > diff --git a/client/src/locales/en.json b/client/src/locales/en.json index fc1e7ea7d..42b4bf34f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -952,6 +952,14 @@ "memoryCpu": { "title": "Memory & CPU", "description": "How the process is using its allocated memory and host CPU." + }, + "mongoDBStats": { + "title": "MongoDB stats", + "description": "Snapshot of MongoDB metrics" + }, + "mongoDBCollectionStats": { + "title": "MongoDB collection stats", + "description": "Stats for MongoDB collections" } }, "stats": { @@ -961,6 +969,21 @@ "totalHeapSize": "Total heap size", "osMemoryLimit": "OS memory limit" }, + "mongoDBStats": { + "readyState": "Ready state", + "host": "Host", + "port": "Port", + "dbName": "Database name", + "storageSize": "Storage size" + }, + "mongoDBCollectionStats": { + "documents": "Documents: {{count}}", + "bucketCount": "Bucket count: {{count}}", + "storageSize": "Storage size: {{size}}", + "indexSize": "Index size: {{size}}", + "totalSize": "Total size: {{size}}", + "dbUsageTooltip": "{{percentage}}% of DB storage" + }, "gauges": { "heapAllocation": "Heap allocation", "heapUsage": "Heap usage",