From 33b57b2934c74a85426861256755197d3cbf10de Mon Sep 17 00:00:00 2001 From: "masel.io" Date: Thu, 7 May 2026 14:52:37 +0200 Subject: [PATCH 1/2] feat: authorizedOnly toggle + per-interval latency graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `authorizedOnly` advanced option (default off). When off — the new default — the explorer lists every database and collection the server returns instead of post-filtering by `connectionStatus`. ConnectionService.isAuthorizedOnly drives the gating in DatabaseService.listDatabases / listCollections / serverStats. - Latency dashboard: opLatencies returns cumulative latency / ops, so `latency / ops` converged to a long-run mean and the graph froze. ServerStats now exposes the raw counters and the renderer takes per-sample deltas, so the chart and ms display reflect recent traffic. - Install electron-updater (used by UpdaterService merged from develop). - Pull in prettier pass across the rest of the working tree. --- package-lock.json | 33 +++----- src/main/services/ConnectionService.ts | 10 +++ src/main/services/DatabaseService.ts | 37 ++++++--- src/main/stores/ConnectionsRepository.ts | 1 + .../connections/ConnectionFormDialog.tsx | 11 +++ .../dashboard/ConnectionDashboard.tsx | 79 +++++++++++++------ src/shared/schemas.ts | 1 + src/shared/types.ts | 34 +++++++- 8 files changed, 149 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2336ee1..17b71a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,7 +111,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -820,6 +819,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -841,6 +841,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -857,6 +858,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -871,6 +873,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -3222,7 +3225,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3234,7 +3236,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3332,7 +3333,6 @@ "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", @@ -3705,7 +3705,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3739,7 +3738,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4338,7 +4336,6 @@ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -4532,7 +4529,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5170,7 +5166,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5446,7 +5443,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -5860,6 +5856,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -5880,6 +5877,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6217,7 +6215,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8148,7 +8145,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8776,6 +8772,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -8788,7 +8785,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -9638,7 +9634,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9811,6 +9806,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -9828,6 +9824,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -9982,7 +9979,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9995,7 +9991,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11313,6 +11308,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -11377,6 +11373,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -11523,7 +11520,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11732,7 +11728,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11920,7 +11915,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12498,7 +12492,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/main/services/ConnectionService.ts b/src/main/services/ConnectionService.ts index cb94807..f3b1c26 100644 --- a/src/main/services/ConnectionService.ts +++ b/src/main/services/ConnectionService.ts @@ -94,6 +94,16 @@ export class ConnectionService { return client } + /** + * Per-connection toggle: when true, the explorer should only list + * databases / collections the authenticated user has privileges on. + * Reads from the in-memory repo cache, so it's effectively sync. + */ + async isAuthorizedOnly(id: string): Promise { + const stored = await this.repo.getStored(id) + return stored?.authorizedOnly === true + } + private async materializeFromInput(input: ConnectionInput, existingId?: string): Promise { const formPassword = input.password !== undefined && input.password.length > 0 ? input.password : undefined diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 221f5a7..35e26dd 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -10,6 +10,14 @@ export class DatabaseService { async listDatabases(connectionId: string): Promise { const client = this.connections.getClient(connectionId) + const authOnly = await this.connections.isAuthorizedOnly(connectionId) + if (!authOnly) { + const result = (await client.db('admin').admin().listDatabases()) as { + databases: Array<{ name: string; sizeOnDisk?: number; empty?: boolean }> + } + return result.databases + } + const [result, allowed] = await Promise.all([ client.db('admin').admin().listDatabases({ authorizedDatabases: true }) as Promise<{ databases: Array<{ name: string; sizeOnDisk?: number; empty?: boolean }> @@ -22,9 +30,10 @@ export class DatabaseService { async listCollections(connectionId: string, db: string): Promise { const client = this.connections.getClient(connectionId) + const authOnly = await this.connections.isAuthorizedOnly(connectionId) const cursor = client .db(db) - .listCollections({}, { nameOnly: true, authorizedCollections: true }) + .listCollections({}, { nameOnly: true, authorizedCollections: authOnly }) const items = await cursor.toArray() return items.map((info) => ({ name: info.name as string, @@ -118,9 +127,10 @@ export class DatabaseService { async serverStats(connectionId: string): Promise { const client = this.connections.getClient(connectionId) const admin = client.db('admin') + const authOnly = await this.connections.isAuthorizedOnly(connectionId) const [status, dbs] = await Promise.all([ admin.command({ serverStatus: 1 }) as Promise>, - admin.admin().listDatabases({ authorizedDatabases: true }) as Promise<{ + admin.admin().listDatabases(authOnly ? { authorizedDatabases: true } : {}) as Promise<{ databases: Array<{ name: string; sizeOnDisk?: number; empty?: boolean }> totalSize?: number }> @@ -148,9 +158,9 @@ export class DatabaseService { const opLat = status['opLatencies'] as Record | undefined const latencies = opLat ? { - reads: averageLatency(opLat['reads']), - writes: averageLatency(opLat['writes']), - commands: averageLatency(opLat['commands']) + reads: rawLatency(opLat['reads']), + writes: rawLatency(opLat['writes']), + commands: rawLatency(opLat['commands']) } : undefined @@ -284,12 +294,19 @@ async function authorizedDatabases(client: MongoClient): Promise | ' } } -function averageLatency(node: unknown): { avgMicros: number; ops: number } { - if (typeof node !== 'object' || node === null) return { avgMicros: 0, ops: 0 } +/** + * Pull the raw cumulative latency / ops counters out of the + * `opLatencies.{reads,writes,commands}` block. The renderer derives a + * *recent* per-op average by diffing consecutive samples — a cumulative + * ratio computed here would barely move once the server has been up. + */ +function rawLatency(node: unknown): { latencyMicros: number; ops: number } { + if (typeof node !== 'object' || node === null) return { latencyMicros: 0, ops: 0 } const r = node as Record - const latency = numberOr(r['latency'], 0) - const ops = numberOr(r['ops'], 0) - return { avgMicros: ops > 0 ? latency / ops : 0, ops } + return { + latencyMicros: numberOr(r['latency'], 0), + ops: numberOr(r['ops'], 0) + } } function tickets(node: unknown): { available: number; out: number; total: number } { diff --git a/src/main/stores/ConnectionsRepository.ts b/src/main/stores/ConnectionsRepository.ts index 80167f2..cfa3ee3 100644 --- a/src/main/stores/ConnectionsRepository.ts +++ b/src/main/stores/ConnectionsRepository.ts @@ -139,6 +139,7 @@ export class ConnectionsRepository { ...(input.readPreference !== undefined ? { readPreference: input.readPreference } : {}), ...(input.uuidEncoding !== undefined ? { uuidEncoding: input.uuidEncoding } : {}), ...(input.timezone !== undefined ? { timezone: input.timezone } : {}), + ...(input.authorizedOnly !== undefined ? { authorizedOnly: input.authorizedOnly } : {}), ...(input.maxPoolSize !== undefined ? { maxPoolSize: input.maxPoolSize } : {}), ...(input.minPoolSize !== undefined ? { minPoolSize: input.minPoolSize } : {}), ...(input.connectTimeoutMS !== undefined ? { connectTimeoutMS: input.connectTimeoutMS } : {}), diff --git a/src/renderer/src/features/connections/ConnectionFormDialog.tsx b/src/renderer/src/features/connections/ConnectionFormDialog.tsx index e0a2feb..881defa 100644 --- a/src/renderer/src/features/connections/ConnectionFormDialog.tsx +++ b/src/renderer/src/features/connections/ConnectionFormDialog.tsx @@ -49,6 +49,7 @@ type FormState = { readPreference: ReadPreference | 'default' uuidEncoding: UuidEncoding timezone: string + authorizedOnly: boolean maxPoolSize: string minPoolSize: string connectTimeoutMS: string @@ -72,6 +73,7 @@ const emptyForm = (): FormState => ({ readPreference: 'default', uuidEncoding: 'default', timezone: 'UTC', + authorizedOnly: false, maxPoolSize: '', minPoolSize: '', connectTimeoutMS: '', @@ -95,6 +97,7 @@ const fromConnection = (conn: ConnectionConfig): FormState => ({ readPreference: conn.readPreference ?? 'default', uuidEncoding: conn.uuidEncoding ?? 'default', timezone: conn.timezone ?? 'UTC', + authorizedOnly: conn.authorizedOnly ?? false, maxPoolSize: conn.maxPoolSize !== undefined ? String(conn.maxPoolSize) : '', minPoolSize: conn.minPoolSize !== undefined ? String(conn.minPoolSize) : '', connectTimeoutMS: conn.connectTimeoutMS !== undefined ? String(conn.connectTimeoutMS) : '', @@ -129,6 +132,7 @@ function buildInput(form: FormState): ConnectionInput { if (form.timezone.trim().length > 0 && form.timezone.trim() !== 'UTC') { input.timezone = form.timezone.trim() } + if (form.authorizedOnly) input.authorizedOnly = true const maxPool = parseInt(form.maxPoolSize) if (maxPool !== undefined) input.maxPoolSize = maxPool const minPool = parseInt(form.minPoolSize) @@ -461,6 +465,13 @@ export function ConnectionFormDialog({ open, onOpenChange, connection }: Props) onChange={(v) => update('timezone', v)} /> + update('authorizedOnly', v)} + />