diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d968baf3cd0..dfc51e7aa46 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,5 +1,5 @@ The following third-party software is used by and included in **Mongodb Compass**. -This document was automatically generated on Wed Sep 18 2024. +This document was automatically generated on Mon Sep 23 2024. ## List of dependencies diff --git a/docs/tracking-plan.md b/docs/tracking-plan.md index c3c11289ace..91fc5606256 100644 --- a/docs/tracking-plan.md +++ b/docs/tracking-plan.md @@ -1,7 +1,7 @@ # Compass Tracking Plan -Generated on Wed, Sep 18, 2024 at 11:44 AM +Generated on Mon, Sep 23, 2024 at 01:02 PM ## Table of Contents diff --git a/package-lock.json b/package-lock.json index b92e4d9eb32..fc87f160cf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13398,9 +13398,9 @@ "dev": true }, "node_modules/@types/numeral": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.2.tgz", - "integrity": "sha512-A8F30k2gYJ/6e07spSCPpkuZu79LCnkPTvqmIWQzNGcrzwFKpVOydG41lNt5wZXjSI149qjyzC2L1+F2PD/NUA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.5.tgz", + "integrity": "sha512-kH8I7OSSwQu9DS9JYdFWbuvhVzvFRoCPCkGxNwoGgaPeDfEPJlcxNvEOypZhQ3XXHsGbfIuYcxcJxKUfJHnRfw==", "dev": true }, "node_modules/@types/papaparse": { @@ -43609,6 +43609,7 @@ "@mongodb-js/compass-telemetry": "^1.1.7", "@mongodb-js/compass-user-data": "^0.3.7", "@mongodb-js/compass-utils": "^0.6.12", + "@mongodb-js/connection-info": "^0.8.0", "@mongodb-js/devtools-connect": "^3.2.10", "@mongodb-js/devtools-proxy-support": "^0.3.9", "@mongodb-js/oidc-plugin": "^1.1.1", @@ -44039,7 +44040,6 @@ "hadron-app-registry": "^9.2.6", "mongodb-collection-model": "^5.23.3", "mongodb-ns": "^2.4.2", - "numeral": "^2.0.6", "react": "^17.0.2", "react-redux": "^8.1.3", "redux": "^4.2.1", @@ -44054,7 +44054,6 @@ "@types/chai": "^4.2.21", "@types/chai-dom": "^0.0.10", "@types/mocha": "^9.0.0", - "@types/numeral": "^2.0.2", "@types/react": "^17.0.5", "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", @@ -44080,14 +44079,6 @@ "node": ">=0.3.1" } }, - "packages/compass-collection/node_modules/numeral": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", - "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", - "engines": { - "node": "*" - } - }, "packages/compass-collection/node_modules/sinon": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", @@ -44455,6 +44446,7 @@ "mongodb-data-service": "^22.23.3", "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.2.3", + "numeral": "^2.0.6", "prop-types": "^15.7.2", "react": "^17.0.2", "reflux": "^0.4.1", @@ -44484,6 +44476,14 @@ "typescript": "^5.0.4" } }, + "packages/compass-crud/node_modules/numeral": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", + "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", + "engines": { + "node": "*" + } + }, "packages/compass-database": { "name": "@mongodb-js/compass-database", "version": "3.19.1", @@ -45446,6 +45446,7 @@ "hadron-app-registry": "^9.2.6", "lodash": "^4.17.21", "mongodb": "^6.8.0", + "mongodb-collection-model": "^5.23.3", "mongodb-data-service": "^22.23.3", "mongodb-query-parser": "^4.2.3", "numeral": "^2.0.6", @@ -45456,17 +45457,20 @@ "semver": "^7.6.2" }, "devDependencies": { + "@mongodb-js/atlas-service": "^0.28.2", "@mongodb-js/eslint-config-compass": "^1.1.7", "@mongodb-js/mocha-config-compass": "^1.4.2", "@mongodb-js/prettier-config-compass": "^1.0.2", "@mongodb-js/testing-library-compass": "^1.0.1", "@mongodb-js/tsconfig-compass": "^1.0.5", + "@types/numeral": "^2.0.5", "chai": "^4.2.0", "depcheck": "^1.4.1", "electron": "^30.5.1", "electron-mocha": "^12.2.0", "eslint": "^7.25.0", "mocha": "^10.2.0", + "mongodb-ns": "^2.4.2", "nyc": "^15.1.0", "react-dom": "^17.0.2", "sinon": "^9.2.3", @@ -55807,6 +55811,7 @@ "@mongodb-js/compass-telemetry": "^1.1.7", "@mongodb-js/compass-user-data": "^0.3.7", "@mongodb-js/compass-utils": "^0.6.12", + "@mongodb-js/connection-info": "^0.8.0", "@mongodb-js/devtools-connect": "^3.2.10", "@mongodb-js/devtools-proxy-support": "^0.3.9", "@mongodb-js/eslint-config-compass": "^1.1.7", @@ -56046,7 +56051,6 @@ "@types/chai": "^4.2.21", "@types/chai-dom": "^0.0.10", "@types/mocha": "^9.0.0", - "@types/numeral": "^2.0.2", "@types/react": "^17.0.5", "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", @@ -56059,7 +56063,6 @@ "mocha": "^10.2.0", "mongodb-collection-model": "^5.23.3", "mongodb-ns": "^2.4.2", - "numeral": "^2.0.6", "nyc": "^15.1.0", "prettier": "^2.7.1", "react": "^17.0.2", @@ -56078,11 +56081,6 @@ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true }, - "numeral": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", - "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==" - }, "sinon": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", @@ -56435,6 +56433,7 @@ "mongodb-instance-model": "^12.24.3", "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.2.3", + "numeral": "^2.0.6", "nyc": "^15.1.0", "prop-types": "^15.7.2", "react": "^17.0.2", @@ -56443,6 +56442,13 @@ "semver": "^7.6.2", "sinon": "^8.1.1", "typescript": "^5.0.4" + }, + "dependencies": { + "numeral": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", + "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==" + } } }, "@mongodb-js/compass-databases-collections": { @@ -56976,6 +56982,7 @@ "@mongodb-js/compass-indexes": { "version": "file:packages/compass-indexes", "requires": { + "@mongodb-js/atlas-service": "^0.28.2", "@mongodb-js/compass-app-stores": "^7.28.0", "@mongodb-js/compass-components": "^1.29.4", "@mongodb-js/compass-connections": "^1.42.0", @@ -56992,6 +56999,7 @@ "@mongodb-js/shell-bson-parser": "^1.1.2", "@mongodb-js/testing-library-compass": "^1.0.1", "@mongodb-js/tsconfig-compass": "^1.0.5", + "@types/numeral": "^2.0.5", "bson": "^6.7.0", "chai": "^4.2.0", "compass-preferences-model": "^2.28.3", @@ -57003,7 +57011,9 @@ "lodash": "^4.17.21", "mocha": "^10.2.0", "mongodb": "^6.8.0", + "mongodb-collection-model": "^5.23.3", "mongodb-data-service": "^22.23.3", + "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.2.3", "numeral": "^2.0.6", "nyc": "^15.1.0", @@ -64246,9 +64256,9 @@ "dev": true }, "@types/numeral": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.2.tgz", - "integrity": "sha512-A8F30k2gYJ/6e07spSCPpkuZu79LCnkPTvqmIWQzNGcrzwFKpVOydG41lNt5wZXjSI149qjyzC2L1+F2PD/NUA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.5.tgz", + "integrity": "sha512-kH8I7OSSwQu9DS9JYdFWbuvhVzvFRoCPCkGxNwoGgaPeDfEPJlcxNvEOypZhQ3XXHsGbfIuYcxcJxKUfJHnRfw==", "dev": true }, "@types/papaparse": { diff --git a/packages/atlas-service/package.json b/packages/atlas-service/package.json index b210659fa2f..e7bef43fa9e 100644 --- a/packages/atlas-service/package.json +++ b/packages/atlas-service/package.json @@ -78,6 +78,7 @@ "@mongodb-js/compass-telemetry": "^1.1.7", "@mongodb-js/compass-user-data": "^0.3.7", "@mongodb-js/compass-utils": "^0.6.12", + "@mongodb-js/connection-info": "^0.8.0", "@mongodb-js/devtools-connect": "^3.2.10", "@mongodb-js/devtools-proxy-support": "^0.3.9", "@mongodb-js/oidc-plugin": "^1.1.1", diff --git a/packages/atlas-service/src/atlas-service.ts b/packages/atlas-service/src/atlas-service.ts index e0b716746c8..9069dde11ef 100644 --- a/packages/atlas-service/src/atlas-service.ts +++ b/packages/atlas-service/src/atlas-service.ts @@ -8,6 +8,12 @@ import { } from './util'; import type { Logger } from '@mongodb-js/compass-logging'; import type { PreferencesAccess } from 'compass-preferences-model'; +import type { AtlasClusterMetadata } from '@mongodb-js/connection-info'; +import type { + AutomationAgentRequestTypes, + AutomationAgentResponse, +} from './make-automation-agent-op-request'; +import { makeAutomationAgentOpRequest } from './make-automation-agent-op-request'; export type AtlasServiceOptions = { defaultHeaders?: Record; @@ -35,19 +41,25 @@ export class AtlasService { cloudEndpoint(path?: string): string { return encodeURI(`${this.config.cloudBaseUrl}${path ? `/${path}` : ''}`); } + regionalizedCloudEndpoint( + _atlasMetadata: Pick, + path?: string + ): string { + // TODO: eventually should apply the regional url logic + // https://github.com/10gen/mms/blob/9f858bb987aac6aa80acfb86492dd74c89cbb862/client/packages/project/common/ajaxPrefilter.ts#L34-L49 + return this.cloudEndpoint(path); + } driverProxyEndpoint(path?: string): string { return encodeURI(`${this.config.wsBaseUrl}${path ? `/${path}` : ''}`); } - async fetch(url: RequestInfo, init?: RequestInit): Promise { + async fetch(url: RequestInfo | URL, init?: RequestInit): Promise { throwIfNetworkTrafficDisabled(this.preferences); throwIfAborted(init?.signal as AbortSignal); this.logger.log.info( this.logger.mongoLogId(1_001_000_297), 'AtlasService', 'Making a fetch', - { - url, - } + { url } ); try { const res = await fetch(url, { @@ -74,16 +86,13 @@ export class AtlasService { this.logger.mongoLogId(1_001_000_298), 'AtlasService', 'Fetch errored', - { - url, - err, - } + { url, err } ); throw err; } } async authenticatedFetch( - url: RequestInfo, + url: RequestInfo | URL, init?: RequestInit ): Promise { const authHeaders = await this.authService.getAuthHeaders(); @@ -95,4 +104,30 @@ export class AtlasService { }, }); } + automationAgentFetch( + atlasMetadata: Pick< + AtlasClusterMetadata, + 'projectId' | 'clusterUniqueId' | 'regionalBaseUrl' | 'metricsType' + >, + opType: OpType, + opBody: Omit< + AutomationAgentRequestTypes[OpType], + 'clusterId' | 'serverlessId' + > + ): Promise> { + const opBodyClusterId = + atlasMetadata.metricsType === 'serverless' + ? { serverlessId: atlasMetadata.clusterUniqueId } + : { clusterId: atlasMetadata.clusterUniqueId }; + return makeAutomationAgentOpRequest( + this.authenticatedFetch.bind(this), + this.regionalizedCloudEndpoint(atlasMetadata), + atlasMetadata.projectId, + opType, + Object.assign( + opBodyClusterId, + opBody + ) as AutomationAgentRequestTypes[OpType] + ); + } } diff --git a/packages/atlas-service/src/make-automation-agent-op-request.spec.ts b/packages/atlas-service/src/make-automation-agent-op-request.spec.ts new file mode 100644 index 00000000000..c989d62fca7 --- /dev/null +++ b/packages/atlas-service/src/make-automation-agent-op-request.spec.ts @@ -0,0 +1,124 @@ +import Sinon from 'sinon'; +import { makeAutomationAgentOpRequest } from './make-automation-agent-op-request'; +import { expect } from 'chai'; + +describe('makeAutomationAgentOpRequest', function () { + const successSpecs = [ + [ + 'succeeds if backend returned requestId and response', + { _id: 'abc', requestType: 'listIndexStats' }, + { + _id: 'abc', + requestType: 'listIndexStats', + response: [{ indexName: 'test' }], + }, + ], + ] as const; + + const failSpecs = [ + [ + 'fails if initial request fails', + new Error('NetworkError'), + {}, + /NetworkError/, + ], + [ + 'fails if await response fails', + { _id: 'abc', requestType: 'listIndexStats' }, + new Error('NetworkError'), + /NetworkError/, + ], + [ + 'fails if backend did not return requestId', + {}, + {}, + /Got unexpected backend response/, + ], + [ + 'fails if backend returned requestId but no response', + { _id: 'abc', requestType: 'listIndexStats' }, + {}, + /Got unexpected backend response/, + ], + ] as const; + + function getMockFetch( + requestResponse: Record | Error, + awaitResponse: Record | Error + ) { + return Sinon.stub() + .onFirstCall() + .callsFake(() => { + return requestResponse instanceof Error + ? Promise.reject(requestResponse) + : Promise.resolve({ + ok: true, + staus: 200, + json() { + return Promise.resolve(requestResponse); + }, + }); + }) + .onSecondCall() + .callsFake(() => { + return awaitResponse instanceof Error + ? Promise.reject(awaitResponse) + : Promise.resolve({ + ok: true, + staus: 200, + json() { + return Promise.resolve(awaitResponse); + }, + }); + }); + } + + function getRequestBodyFromFnCall(call: Sinon.SinonSpyCall) { + return JSON.parse(call.args[1].body); + } + + for (const [ + successSpecName, + requestResponse, + awaitResponse, + ] of successSpecs) { + it(successSpecName, async function () { + const fetchFn = getMockFetch(requestResponse, awaitResponse); + const res = await makeAutomationAgentOpRequest( + fetchFn, + 'http://example.com', + 'abc', + 'listIndexStats', + { clusterId: 'abc', db: 'db', collection: 'coll' } + ); + expect(getRequestBodyFromFnCall(fetchFn.firstCall)).to.deep.eq({ + clusterId: 'abc', + collection: 'coll', + db: 'db', + }); + expect(res).to.deep.eq(awaitResponse.response); + }); + } + + for (const [ + failSpecName, + requestResponse, + awaitResponse, + errorMessage, + ] of failSpecs) { + it(failSpecName, async function () { + try { + await makeAutomationAgentOpRequest( + getMockFetch(requestResponse, awaitResponse), + 'http://example.com', + 'abc', + 'listIndexStats', + { clusterId: 'abc', db: 'db', collection: 'coll' } + ); + expect.fail('Expected makeAutomationAgentOpRequest call to fail'); + } catch (err) { + expect((err as any).message).to.match(errorMessage); + } + }); + } +}); diff --git a/packages/atlas-service/src/make-automation-agent-op-request.ts b/packages/atlas-service/src/make-automation-agent-op-request.ts new file mode 100644 index 00000000000..1d0c5c0def1 --- /dev/null +++ b/packages/atlas-service/src/make-automation-agent-op-request.ts @@ -0,0 +1,186 @@ +type ClusterOrServerlessId = + | { serverlessId?: never; clusterId: string } + | { serverlessId: string; clusterId?: never }; + +export type AutomationAgentRequestTypes = { + listIndexStats: ClusterOrServerlessId & { + db: string; + collection: string; + }; + index: ClusterOrServerlessId & { + db: string; + collection: string; + keys: string; + options: string; + collationOptions: string; + }; + dropIndex: ClusterOrServerlessId & { + db: string; + collection: string; + name: string; + }; +}; + +type AutomationAgentRequestOpTypes = keyof AutomationAgentRequestTypes; + +type AutomationAgentRequestResponse< + OpType extends AutomationAgentRequestOpTypes +> = { + _id: string; + requestType: OpType; +}; + +function assertAutomationAgentRequestResponse< + OpType extends AutomationAgentRequestOpTypes +>( + json: any, + opType: OpType +): asserts json is AutomationAgentRequestResponse { + if ( + Object.prototype.hasOwnProperty.call(json, '_id') && + Object.prototype.hasOwnProperty.call(json, 'requestType') && + json.requestType === opType + ) { + return; + } + throw new Error( + 'Got unexpected backend response for automation agent request' + ); +} + +export type AutomationAgentAwaitResponseTypes = { + listIndexStats: { + collName: string; + dbName: string; + indexName: string; + indexProperties: { label: string; properties: Record }[]; + indexType: { label: string }; + keys: { name: string; value: string | number }; + sizeBytes: number; + status: 'rolling build' | 'building' | 'exists'; + }[]; + dropIndex: never[]; +}; + +type AutomationAgentAwaitOpTypes = keyof AutomationAgentAwaitResponseTypes; + +type AutomationAgentAwaitResponse = + { + _id: string; + requestID: string; + requestType: OpType; + response: AutomationAgentAwaitResponseTypes[OpType]; + type: OpType; + }; + +function assertAutomationAgentAwaitResponse< + OpType extends AutomationAgentAwaitOpTypes +>( + json: any, + opType: OpType +): asserts json is AutomationAgentAwaitResponse { + if ( + Object.prototype.hasOwnProperty.call(json, '_id') && + Object.prototype.hasOwnProperty.call(json, 'requestType') && + Object.prototype.hasOwnProperty.call(json, 'response') && + json.requestType === opType + ) { + return; + } + throw new Error( + 'Got unexpected backend response for automation agent request await' + ); +} + +type PickAwaitResponse = + AutomationAgentAwaitResponse['response']; + +/** + * Helper type that maps whatever is returned by automation agent in response + * prop as follows: + * + * empty array -> undefined + * array with one item -> unwrapped item + * array of items -> array of items + */ +export type UnwrappedAutomationAgentAwaitResponse< + OpType extends AutomationAgentAwaitOpTypes +> = PickAwaitResponse extends never[] + ? undefined + : PickAwaitResponse extends [infer UnwrappedResponse] + ? UnwrappedResponse + : PickAwaitResponse extends Array + ? PickAwaitResponse + : never; + +function unwrapAutomationAgentAwaitResponse( + json: any, + opType: 'listIndexStats' +): UnwrappedAutomationAgentAwaitResponse<'listIndexStats'>; +function unwrapAutomationAgentAwaitResponse( + json: any, + opType: 'dropIndex' +): UnwrappedAutomationAgentAwaitResponse<'dropIndex'>; +function unwrapAutomationAgentAwaitResponse(json: any, opType: string): never; +function unwrapAutomationAgentAwaitResponse( + json: any, + opType: string +): unknown { + if (opType === 'dropIndex') { + assertAutomationAgentAwaitResponse(json, opType); + // `dropIndex` returns an empty array, so returning undefined here is just a + // bit more explicit than returning `json.response[0]` instead + return undefined; + } + if (opType === 'listIndexStats') { + assertAutomationAgentAwaitResponse(json, opType); + return json.response; + } + throw new Error(`Unsupported await response type: ${opType}`); +} + +export type AutomationAgentResponse< + OpType extends AutomationAgentRequestOpTypes +> = OpType extends AutomationAgentAwaitOpTypes + ? UnwrappedAutomationAgentAwaitResponse + : undefined; + +async function makeAutomationAgentOpRequest< + OpType extends AutomationAgentRequestOpTypes +>( + fetchFn: typeof fetch, + baseUrl: string, + projectId: string, + opType: OpType, + opBody: AutomationAgentRequestTypes[OpType] +): Promise> { + const requestUrl = + baseUrl + encodeURI(`/explorer/v1/groups/${projectId}/requests/${opType}`); + // Tell automation agent to run the op first, this will return the id that we + // can use to track the job result + const requestRes = await fetchFn(requestUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(opBody), + }); + // Rolling index creation request doesn't return anything that we can "await" + // on (a successful response is already an acknowledgement that request to + // create an index was registered), so we just end here + if (opType === 'index') { + return undefined as AutomationAgentResponse; + } + const requestJson = await requestRes.json(); + assertAutomationAgentRequestResponse(requestJson, opType); + const awaitUrl = + baseUrl + + encodeURI( + `/explorer/v1/groups/${projectId}/requests/${requestJson._id}/types/${opType}/await` + ); + const awaitRes = await fetchFn(awaitUrl, { method: 'GET' }); + const awaitJson = await awaitRes.json(); + return unwrapAutomationAgentAwaitResponse(awaitJson, opType); +} + +export { makeAutomationAgentOpRequest }; diff --git a/packages/collection-model/index.d.ts b/packages/collection-model/index.d.ts index c0c415ef5a5..a1059c38dcc 100644 --- a/packages/collection-model/index.d.ts +++ b/packages/collection-model/index.d.ts @@ -83,14 +83,16 @@ interface CollectionProps { properties: { id: string; options?: unknown }[]; } +type CollectionDataService = Pick; + interface Collection extends CollectionProps { fetch(opts: { - dataService: DataService; + dataService: CollectionDataService; fetchInfo?: boolean; force?: boolean; }): Promise; fetchMetadata(opts: { - dataService: DataService; + dataService: CollectionDataService; }): Promise; on(evt: string, fn: (...args: any) => void); off(evt: string, fn: (...args: any) => void); @@ -102,7 +104,7 @@ interface Collection extends CollectionProps { } interface CollectionCollection extends Array { - fetch(opts: { dataService: DataService; fetchInfo?: boolean }): Promise; + fetch(opts: { dataService: CollectionDataService; fetchInfo?: boolean }): Promise; toJSON(opts?: { derived: boolean }): Array; at(index: number): Collection | undefined; get(id: string, key?: '_id' | 'name'): Collection | undefined; diff --git a/packages/compass-aggregations/src/index.ts b/packages/compass-aggregations/src/index.ts index fbe9d4df6d1..386fef6a51e 100644 --- a/packages/compass-aggregations/src/index.ts +++ b/packages/compass-aggregations/src/index.ts @@ -1,3 +1,4 @@ +import React from 'react'; import { registerHadronPlugin } from 'hadron-app-registry'; import { AggregationsPlugin } from './plugin'; import { activateAggregationsPlugin } from './stores/store'; @@ -28,11 +29,14 @@ import { atlasAuthServiceLocator } from '@mongodb-js/atlas-service/provider'; import { atlasAiServiceLocator } from '@mongodb-js/compass-generative-ai/provider'; import { pipelineStorageLocator } from '@mongodb-js/my-queries-storage/provider'; import { connectionRepositoryAccessLocator } from '@mongodb-js/compass-connections/provider'; +import { AggregationsTabTitle } from './plugin-title'; -export const CompassAggregationsHadronPlugin = registerHadronPlugin( +const CompassAggregationsHadronPlugin = registerHadronPlugin( { name: 'CompassAggregations', - component: AggregationsPlugin, + component: function AggregationsProvider({ children }) { + return React.createElement(React.Fragment, null, children); + }, activate: activateAggregationsPlugin, }, { @@ -57,7 +61,9 @@ export const CompassAggregationsHadronPlugin = registerHadronPlugin( export const CompassAggregationsPlugin = { name: 'Aggregations' as const, - component: CompassAggregationsHadronPlugin, + provider: CompassAggregationsHadronPlugin, + content: AggregationsPlugin, + header: AggregationsTabTitle, }; export const CreateViewPlugin = registerHadronPlugin( diff --git a/packages/compass-aggregations/src/plugin-title.tsx b/packages/compass-aggregations/src/plugin-title.tsx new file mode 100644 index 00000000000..a2f32cae660 --- /dev/null +++ b/packages/compass-aggregations/src/plugin-title.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export function AggregationsTabTitle() { + return
Aggregations
; +} diff --git a/packages/compass-aggregations/test/configure-store.ts b/packages/compass-aggregations/test/configure-store.ts index 369c889abb9..a211c17dea9 100644 --- a/packages/compass-aggregations/test/configure-store.ts +++ b/packages/compass-aggregations/test/configure-store.ts @@ -5,7 +5,7 @@ import type { import { mockDataService } from './mocks/data-service'; import { AtlasAuthService } from '@mongodb-js/atlas-service/provider'; import { createPluginTestHelpers } from '@mongodb-js/testing-library-compass'; -import { CompassAggregationsHadronPlugin } from '../src/index'; +import { CompassAggregationsPlugin } from '../src/index'; import type { DataService } from '@mongodb-js/compass-connections/provider'; import React from 'react'; import { PipelineStorageProvider } from '@mongodb-js/my-queries-storage/provider'; @@ -45,7 +45,7 @@ function getMockedPluginArgs( const atlasAuthService = new MockAtlasAuthService(); const atlasAiService = new MockAtlasAiService(); return [ - CompassAggregationsHadronPlugin.withMockServices({ + CompassAggregationsPlugin.provider.withMockServices({ atlasAuthService, atlasAiService, collection: { diff --git a/packages/compass-collection/package.json b/packages/compass-collection/package.json index a108e40be97..c04f15526a3 100644 --- a/packages/compass-collection/package.json +++ b/packages/compass-collection/package.json @@ -60,7 +60,6 @@ "hadron-app-registry": "^9.2.6", "mongodb-collection-model": "^5.23.3", "mongodb-ns": "^2.4.2", - "numeral": "^2.0.6", "react": "^17.0.2", "react-redux": "^8.1.3", "redux": "^4.2.1", @@ -75,7 +74,6 @@ "@types/chai": "^4.2.21", "@types/chai-dom": "^0.0.10", "@types/mocha": "^9.0.0", - "@types/numeral": "^2.0.2", "@types/react": "^17.0.5", "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", diff --git a/packages/compass-collection/src/components/collection-header/collection-header.tsx b/packages/compass-collection/src/components/collection-header/collection-header.tsx index 80a79eb299f..ac96847092f 100644 --- a/packages/compass-collection/src/components/collection-header/collection-header.tsx +++ b/packages/compass-collection/src/components/collection-header/collection-header.tsx @@ -14,10 +14,8 @@ import React, { useMemo } from 'react'; import toNS from 'mongodb-ns'; import { usePreference } from 'compass-preferences-model/provider'; import CollectionHeaderActions from '../collection-header-actions'; -import type { CollectionState } from '../../modules/collection-tab'; import { CollectionBadge } from './badges'; import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; -import { connect } from 'react-redux'; import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; import { getConnectionTitle } from '@mongodb-js/connection-info'; @@ -177,11 +175,3 @@ export const CollectionHeader: React.FunctionComponent< ); }; - -const ConnectedCollectionHeader = connect((state: CollectionState) => { - return { - stats: state.stats, - }; -})(CollectionHeader); - -export default ConnectedCollectionHeader; diff --git a/packages/compass-collection/src/components/collection-header/index.ts b/packages/compass-collection/src/components/collection-header/index.ts index bffd33cbad1..66f50752afe 100644 --- a/packages/compass-collection/src/components/collection-header/index.ts +++ b/packages/compass-collection/src/components/collection-header/index.ts @@ -1,2 +1,2 @@ -import CollectionHeader from './collection-header'; +import { CollectionHeader } from './collection-header'; export default CollectionHeader; diff --git a/packages/compass-collection/src/components/collection-tab-provider.tsx b/packages/compass-collection/src/components/collection-tab-provider.tsx index fc46dfd2a98..b4a1fe94266 100644 --- a/packages/compass-collection/src/components/collection-tab-provider.tsx +++ b/packages/compass-collection/src/components/collection-tab-provider.tsx @@ -5,13 +5,15 @@ import type { CollectionSubtab } from '@mongodb-js/compass-workspaces'; export interface CollectionTabPlugin { name: CollectionSubtab; - component: HadronPluginComponent; + provider: HadronPluginComponent; + content: React.FunctionComponent; + header: React.FunctionComponent; } type CollectionTabComponentsProviderValue = { tabs: CollectionTabPlugin[]; - modals: CollectionTabPlugin['component'][]; - queryBar: CollectionTabPlugin['component']; + modals: CollectionTabPlugin['content'][]; + queryBar: CollectionTabPlugin['content']; }; const defaultComponents: CollectionTabComponentsProviderValue = { diff --git a/packages/compass-collection/src/components/collection-tab.tsx b/packages/compass-collection/src/components/collection-tab.tsx index 3d14a772922..4d9df0da573 100644 --- a/packages/compass-collection/src/components/collection-tab.tsx +++ b/packages/compass-collection/src/components/collection-tab.tsx @@ -1,12 +1,7 @@ import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { type CollectionState, selectTab } from '../modules/collection-tab'; -import { - css, - ErrorBoundary, - spacing, - TabNavBar, -} from '@mongodb-js/compass-components'; +import { css, ErrorBoundary, TabNavBar } from '@mongodb-js/compass-components'; import CollectionHeader from './collection-header'; import { useLogger } from '@mongodb-js/compass-logging/provider'; import { @@ -16,10 +11,6 @@ import { } from './collection-tab-provider'; import type { CollectionTabOptions } from '../stores/collection-tab'; import type { CollectionMetadata } from 'mongodb-collection-model'; -import { - CollectionDocumentsStats, - CollectionIndexesStats, -} from './collection-tab-stats'; import type { CollectionSubtab } from '@mongodb-js/compass-workspaces'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { useConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; @@ -58,31 +49,9 @@ const collectionModalContainerStyles = css({ zIndex: 100, }); -const tabTitleWithStatsStyles = css({ - display: 'flex', - gap: spacing[2], -}); -const TabTitleWithStats = ({ - title, - statsComponent, - 'data-testid': dataTestId, -}: { - title: string; - statsComponent: React.ReactNode; - 'data-testid'?: string; -}) => { - return ( -
- {title} - {statsComponent} -
- ); -}; - // Props from redux type ConnectionTabConnectedProps = { collectionMetadata: CollectionMetadata; - stats: CollectionState['stats']; onTabClick: (tab: CollectionSubtab) => void; }; @@ -116,6 +85,61 @@ type CollectionTabProps = Omit & ConnectionTabConnectedProps & ConnectionTabExpectedProps; +function WithErrorBoundary({ + children, + name, + type, +}: { + children: React.ReactNode; + name: string; + type: 'content' | 'header'; +}) { + const { log, mongoLogId } = useLogger('COMPASS-COLLECTION-TAB-UI'); + return ( + { + log.error( + mongoLogId(1001000107), + 'Collection Workspace', + 'Rendering collection tab failed', + { name, type, error: error.stack, errorInfo } + ); + }} + > + {children} + + ); +} + +function useCollectionTabs(props: CollectionMetadata) { + const pluginTabs = useCollectionSubTabs(); + return pluginTabs.map( + ({ name, content: Content, provider: Provider, header: Header }) => { + // `pluginTabs` never change in runtime so it's safe to call the hook here + // eslint-disable-next-line react-hooks/rules-of-hooks + Provider.useActivate(props); + return { + name, + content: ( + + + + + + ), + title: ( + + +
+ + + ), + }; + } + ); +} + const CollectionTabWithMetadata: React.FunctionComponent< CollectionTabProps > = ({ @@ -128,11 +152,9 @@ const CollectionTabWithMetadata: React.FunctionComponent< collectionMetadata, subTab: currentTab, onTabClick, - stats, }) => { const track = useTelemetry(); const connectionInfoRef = useConnectionInfoRef(); - const { log, mongoLogId } = useLogger('COMPASS-COLLECTION-TAB-UI'); useEffect(() => { const activeSubTabName = currentTab ? trackingIdForTabName(currentTab) @@ -148,7 +170,6 @@ const CollectionTabWithMetadata: React.FunctionComponent< ); } }, [currentTab, track, connectionInfoRef]); - const pluginTabs = useCollectionSubTabs(); const pluginModals = useCollectionScopedModals(); const pluginProps = { @@ -161,16 +182,7 @@ const CollectionTabWithMetadata: React.FunctionComponent< editViewName: editViewName, }; - const tabs = pluginTabs.map(({ name, component: Component }) => { - // `pluginTabs` never change in runtime so it's safe to call the hook here - // eslint-disable-next-line react-hooks/rules-of-hooks - Component.useActivate(pluginProps); - - return { - name, - component: , - }; - }); + const tabs = useCollectionTabs(pluginProps); const activeTabIndex = tabs.findIndex((tab) => tab.name === currentTab); return ( @@ -183,58 +195,11 @@ const CollectionTabWithMetadata: React.FunctionComponent< tab.name)} - tabLabels={tabs.map((tab) => { - // We don't show stats, when the collection is a timeseries or a view - // or when the view is being edited - const hideStats = - collectionMetadata.isTimeSeries || - collectionMetadata.sourceName || - editViewName; - if (hideStats) { - return tab.name; - } - if (tab.name === 'Documents') { - return ( - } - /> - ); - } - if (tab.name === 'Indexes') { - return ( - } - /> - ); - } - return tab.name; - })} - views={tabs.map((tab) => { - return ( - { - log.error( - mongoLogId(1001000107), - 'Collection Workspace', - 'Rendering collection tab failed', - { name: tab.name, error: error.stack, errorInfo } - ); - }} - > - {tab.component} - - ); - })} activeTabIndex={activeTabIndex} onTabClicked={(id) => { onTabClick(tabs[id].name); }} + tabs={tabs} />
@@ -283,7 +248,6 @@ const ConnectedCollectionTab = connect( return { namespace: state.namespace, collectionMetadata: state.metadata, - stats: state.stats, }; }, { diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index 451c819f3ce..0edeba38b05 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -1,6 +1,5 @@ import type { Reducer, AnyAction, Action } from 'redux'; import type { CollectionMetadata } from 'mongodb-collection-model'; -import type Collection from 'mongodb-collection-model'; import type { ThunkAction } from 'redux-thunk'; import type AppRegistry from 'hadron-app-registry'; import type { workspacesServiceLocator } from '@mongodb-js/compass-workspaces/provider'; @@ -28,53 +27,14 @@ type CollectionThunkAction = ThunkAction< export type CollectionState = { workspaceTabId: string; namespace: string; - stats: Pick< - Collection, - | 'document_count' - | 'index_count' - | 'index_size' - | 'status' - | 'avg_document_size' - | 'storage_size' - | 'free_storage_size' - > | null; metadata: CollectionMetadata | null; editViewName?: string; }; -export function pickCollectionStats( - collection: Collection -): CollectionState['stats'] { - const { - document_count, - index_count, - index_size, - status, - avg_document_size, - storage_size, - free_storage_size, - } = collection.toJSON(); - return { - document_count, - index_count, - index_size, - status, - avg_document_size, - storage_size, - free_storage_size, - }; -} - enum CollectionActions { - CollectionStatsFetched = 'compass-collection/CollectionStatsFetched', CollectionMetadataFetched = 'compass-collection/CollectionMetadataFetched', } -interface CollectionStatsFetchedAction { - type: CollectionActions.CollectionStatsFetched; - collection: Collection; -} - interface CollectionMetadataFetchedAction { type: CollectionActions.CollectionMetadataFetched; metadata: CollectionMetadata; @@ -85,22 +45,10 @@ const reducer: Reducer = ( // TODO(COMPASS-7782): use hook to get the workspace tab id instead workspaceTabId: '', namespace: '', - stats: null, metadata: null, }, action ) => { - if ( - isAction( - action, - CollectionActions.CollectionStatsFetched - ) - ) { - return { - ...state, - stats: pickCollectionStats(action.collection), - }; - } if ( isAction( action, @@ -115,12 +63,6 @@ const reducer: Reducer = ( return state; }; -export const collectionStatsFetched = ( - collection: Collection -): CollectionStatsFetchedAction => { - return { type: CollectionActions.CollectionStatsFetched, collection }; -}; - export const collectionMetadataFetched = ( metadata: CollectionMetadata ): CollectionMetadataFetchedAction => { diff --git a/packages/compass-collection/src/stores/collection-tab.ts b/packages/compass-collection/src/stores/collection-tab.ts index f634ce5d17f..b0b10534fbb 100644 --- a/packages/compass-collection/src/stores/collection-tab.ts +++ b/packages/compass-collection/src/stores/collection-tab.ts @@ -3,10 +3,8 @@ import type { DataService } from '@mongodb-js/compass-connections/provider'; import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import reducer, { - collectionMetadataFetched, - collectionStatsFetched, - pickCollectionStats, selectTab, + collectionMetadataFetched, } from '../modules/collection-tab'; import type { Collection } from '@mongodb-js/compass-app-stores/provider'; import type { ActivateHelpers } from 'hadron-app-registry'; @@ -59,7 +57,6 @@ export function activatePlugin( workspaceTabId: tabId, namespace, metadata: null, - stats: pickCollectionStats(collectionModel), editViewName, }, applyMiddleware( @@ -83,16 +80,6 @@ export function activatePlugin( store.dispatch(selectTab('Aggregations')); }); - on(collectionModel, 'change:status', (model: Collection, status: string) => { - if (status === 'ready') { - store.dispatch(collectionStatsFetched(model)); - } - }); - - on(localAppRegistry, 'refresh-collection-stats', () => { - void collectionModel.fetch({ dataService, force: true }); - }); - void collectionModel.fetchMetadata({ dataService }).then((metadata) => { store.dispatch(collectionMetadataFetched(metadata)); }); diff --git a/packages/compass-components/src/components/tab-nav-bar.spec.tsx b/packages/compass-components/src/components/tab-nav-bar.spec.tsx index a1a0fe0b89a..9d400606c51 100644 --- a/packages/compass-components/src/components/tab-nav-bar.spec.tsx +++ b/packages/compass-components/src/components/tab-nav-bar.spec.tsx @@ -21,18 +21,32 @@ describe('TabNavBar Component', function () { describe('when rendered with tabs', function () { beforeEach(function () { - const views = [ - , - , - , - , + const tabs = [ + { + name: 'one', + title: 'one', + content: , + }, + { + name: 'two', + title: 'two', + content: , + }, + { + name: 'three', + title: 'three', + content: , + }, + { + name: 'four', + title:

four

, + content: , + }, ]; onTabClickedSpy = sinon.spy(); render( four

]} - views={views} + tabs={tabs} aria-label="Test tabs label" onTabClicked={onTabClickedSpy} activeTabIndex={2} diff --git a/packages/compass-components/src/components/tab-nav-bar.tsx b/packages/compass-components/src/components/tab-nav-bar.tsx index 5d32bfdc0d5..7b1b6a36595 100644 --- a/packages/compass-components/src/components/tab-nav-bar.tsx +++ b/packages/compass-components/src/components/tab-nav-bar.tsx @@ -33,18 +33,16 @@ const tabStyles = css({ minHeight: 0, }); -const hiddenStyles = css({ - display: 'none', -}); - type TabNavBarProps = { 'data-testid'?: string; 'aria-label': string; activeTabIndex: number; - tabNames: string[]; - tabLabels: React.ReactNode[]; - views: React.ReactElement[]; onTabClicked: (tabIndex: number) => void; + tabs: Array<{ + name: string; + content: React.ReactNode; + title: React.ReactNode; + }>; }; /** @@ -56,10 +54,8 @@ function TabNavBar({ 'data-testid': dataTestId, 'aria-label': ariaLabel, activeTabIndex, - tabNames, - tabLabels, - views, onTabClicked, + tabs, }: TabNavBarProps): React.ReactElement | null { const darkMode = useDarkMode(); @@ -78,36 +74,31 @@ function TabNavBar({ setSelected={onTabClicked} selected={activeTabIndex} > - {tabLabels.map((tab, idx) => ( - {tab}} - /> - ))} + {tabs.map(({ name, title }, idx) => { + return ( + + ); + })}
- {views.map( - (view, idx) => - idx === activeTabIndex && ( + {tabs.map(({ name, content }, idx) => { + if (idx === activeTabIndex) { + return (
- {view} + {content}
- ) - )} + ); + } + })} ); } diff --git a/packages/compass-connections/src/hooks/use-connection-supports.spec.ts b/packages/compass-connections/src/hooks/use-connection-supports.spec.ts index 846b386f5b4..88ec3ec4015 100644 --- a/packages/compass-connections/src/hooks/use-connection-supports.spec.ts +++ b/packages/compass-connections/src/hooks/use-connection-supports.spec.ts @@ -23,6 +23,8 @@ const mockConnections: ConnectionInfo[] = [ metricsId: 'metricsId', metricsType: 'host', instanceSize: 'M10', + clusterType: 'REPLICASET', + clusterUniqueId: 'clusterUniqueId', }, }, { @@ -38,6 +40,8 @@ const mockConnections: ConnectionInfo[] = [ metricsId: 'metricsId', metricsType: 'replicaSet', instanceSize: 'M0', + clusterType: 'REPLICASET', + clusterUniqueId: 'clusterUniqueId', }, }, { @@ -53,6 +57,8 @@ const mockConnections: ConnectionInfo[] = [ metricsId: 'metricsId', metricsType: 'serverless', instanceSize: 'SERVERLESS_V2', + clusterType: 'REPLICASET', + clusterUniqueId: 'clusterUniqueId', }, }, { @@ -68,6 +74,8 @@ const mockConnections: ConnectionInfo[] = [ metricsId: 'metricsId', metricsType: 'replicaSet', instanceSize: 'M10', + clusterType: 'REPLICASET', + clusterUniqueId: 'clusterUniqueId', }, }, { @@ -83,79 +91,132 @@ const mockConnections: ConnectionInfo[] = [ metricsId: 'metricsId', metricsType: 'cluster', instanceSize: 'M10', + clusterType: 'SHARDED', + clusterUniqueId: 'clusterUniqueId', + }, + }, + { + id: 'dedicated-geo-sharded', + connectionOptions: { + connectionString: 'mongodb://foo', + }, + atlasMetadata: { + orgId: 'orgId', + projectId: 'projectId', + clusterName: 'clusterName', + regionalBaseUrl: 'https://example.com', + metricsId: 'metricsId', + metricsType: 'cluster', + instanceSize: 'M30', + clusterType: 'GEOSHARDED', + clusterUniqueId: 'clusterUniqueId', }, }, ]; describe('useConnectionSupports', function () { - it('should return false if the connection does not exist', function () { - const { result } = renderHookWithConnections( - () => useConnectionSupports('does-not-exist', 'rollingIndexCreation'), - { - connections: mockConnections, - } - ); - expect(result.current).to.equal(false); - }); + context('rollingIndexCreation', function () { + it('should return false if the connection does not exist', function () { + const { result } = renderHookWithConnections( + () => useConnectionSupports('does-not-exist', 'rollingIndexCreation'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(false); + }); - it('should return false if the connection has no atlasMetadata', function () { - const { result } = renderHookWithConnections( - () => useConnectionSupports('no-atlasMetadata', 'rollingIndexCreation'), - { - connections: mockConnections, - } - ); - expect(result.current).to.equal(false); - }); + it('should return false if the connection has no atlasMetadata', function () { + const { result } = renderHookWithConnections( + () => useConnectionSupports('no-atlasMetadata', 'rollingIndexCreation'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(false); + }); - it('should return false for host cluster type', function () { - const { result } = renderHookWithConnections( - () => useConnectionSupports('host-cluster', 'rollingIndexCreation'), - { - connections: mockConnections, - } - ); - expect(result.current).to.equal(false); - }); + it('should return false for host cluster type', function () { + const { result } = renderHookWithConnections( + () => useConnectionSupports('host-cluster', 'rollingIndexCreation'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(false); + }); - it('should return false for serverless cluster type', function () { - const { result } = renderHookWithConnections( - () => useConnectionSupports('serverless-cluster', 'rollingIndexCreation'), - { - connections: mockConnections, - } - ); - expect(result.current).to.equal(false); - }); + it('should return false for serverless cluster type', function () { + const { result } = renderHookWithConnections( + () => + useConnectionSupports('serverless-cluster', 'rollingIndexCreation'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(false); + }); - it('should return false for free/shared tier clusters', function () { - const { result } = renderHookWithConnections( - () => useConnectionSupports('free-cluster', 'rollingIndexCreation'), - { - connections: mockConnections, - } - ); - expect(result.current).to.equal(false); - }); + it('should return false for free/shared tier clusters', function () { + const { result } = renderHookWithConnections( + () => useConnectionSupports('free-cluster', 'rollingIndexCreation'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(false); + }); + + it('should return true for dedicated replicaSet clusters', function () { + const { result } = renderHookWithConnections( + () => + useConnectionSupports('dedicated-replicaSet', 'rollingIndexCreation'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(true); + }); - it('should return true for dedicated replicaSet clusters', function () { - const { result } = renderHookWithConnections( - () => - useConnectionSupports('dedicated-replicaSet', 'rollingIndexCreation'), - { - connections: mockConnections, - } - ); - expect(result.current).to.equal(true); + it('should return true for dedicated sharded clusters', function () { + const { result } = renderHookWithConnections( + () => + useConnectionSupports('dedicated-sharded', 'rollingIndexCreation'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(true); + }); }); + context('globalWrites', function () { + it('should return false if the connection does not exist', function () { + const { result } = renderHookWithConnections( + () => useConnectionSupports('does-not-exist', 'globalWrites'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(false); + }); + it('should return false if the connection has no atlasMetadata', function () { + const { result } = renderHookWithConnections( + () => useConnectionSupports('no-atlasMetadata', 'globalWrites'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(false); + }); - it('should return true for dedicated sharded clusters', function () { - const { result } = renderHookWithConnections( - () => useConnectionSupports('dedicated-sharded', 'rollingIndexCreation'), - { - connections: mockConnections, - } - ); - expect(result.current).to.equal(true); + it('should return true if the cluster type is geosharded', function () { + const { result } = renderHookWithConnections( + () => useConnectionSupports('dedicated-geo-sharded', 'globalWrites'), + { + connections: mockConnections, + } + ); + expect(result.current).to.equal(true); + }); }); }); diff --git a/packages/compass-connections/src/hooks/use-connection-supports.ts b/packages/compass-connections/src/hooks/use-connection-supports.ts index 2a1db5a294c..7006c9c33eb 100644 --- a/packages/compass-connections/src/hooks/use-connection-supports.ts +++ b/packages/compass-connections/src/hooks/use-connection-supports.ts @@ -1,8 +1,7 @@ import { useSelector } from '../stores/store-context'; import type { ConnectionState } from '../stores/connections-store-redux'; -// only one for now -type ConnectionFeature = 'rollingIndexCreation'; +type ConnectionFeature = 'rollingIndexCreation' | 'globalWrites'; function isFreeOrSharedTierCluster(instanceSize: string | undefined): boolean { if (!instanceSize) { @@ -25,6 +24,17 @@ function supportsRollingIndexCreation(connection: ConnectionState) { (metricsType === 'cluster' || metricsType === 'replicaSet') ); } + +function supportsGlobalWrites(connection: ConnectionState) { + const atlasMetadata = connection.info?.atlasMetadata; + + if (!atlasMetadata) { + return false; + } + + return atlasMetadata.clusterType === 'GEOSHARDED'; +} + export function useConnectionSupports( connectionId: string, connectionFeature: ConnectionFeature @@ -40,6 +50,10 @@ export function useConnectionSupports( return supportsRollingIndexCreation(connection); } + if (connectionFeature === 'globalWrites') { + return supportsGlobalWrites(connection); + } + return false; }); } diff --git a/packages/compass-crud/package.json b/packages/compass-crud/package.json index 8823549be8a..91ad9d5846b 100644 --- a/packages/compass-crud/package.json +++ b/packages/compass-crud/package.json @@ -97,6 +97,7 @@ "mongodb-data-service": "^22.23.3", "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.2.3", + "numeral": "^2.0.6", "prop-types": "^15.7.2", "react": "^17.0.2", "reflux": "^0.4.1", diff --git a/packages/compass-crud/src/index.ts b/packages/compass-crud/src/index.ts index 90b75ffa127..c539352679f 100644 --- a/packages/compass-crud/src/index.ts +++ b/packages/compass-crud/src/index.ts @@ -1,3 +1,4 @@ +import React from 'react'; import type { DocumentProps } from './components/document'; import Document from './components/document'; import type { DocumentListProps } from './components/document-list'; @@ -17,7 +18,10 @@ import type { OptionalDataServiceProps, RequiredDataServiceProps, } from './utils/data-service'; -import { mongoDBInstanceLocator } from '@mongodb-js/compass-app-stores/provider'; +import { + collectionModelLocator, + mongoDBInstanceLocator, +} from '@mongodb-js/compass-app-stores/provider'; import { registerHadronPlugin } from 'hadron-app-registry'; import { preferencesLocator } from 'compass-preferences-model/provider'; import { createLoggerLocator } from '@mongodb-js/compass-logging/provider'; @@ -28,11 +32,21 @@ import { import { fieldStoreServiceLocator } from '@mongodb-js/compass-field-store'; import { queryBarServiceLocator } from '@mongodb-js/compass-query-bar'; import { telemetryLocator } from '@mongodb-js/compass-telemetry/provider'; +import { CrudTabTitle } from './plugin-title'; -export const CompassDocumentsHadronPlugin = registerHadronPlugin( +const CompassDocumentsHadronPlugin = registerHadronPlugin( { name: 'CompassDocuments', - component: DocumentList as any, // as any because of reflux store + component: function CrudProvider({ children, ...props }) { + return React.createElement( + React.Fragment, + null, + // Cloning children with props is a workaround for reflux store. + React.isValidElement(children) + ? React.cloneElement(children, props) + : null + ); + }, activate: activateDocumentsPlugin, }, { @@ -51,12 +65,15 @@ export const CompassDocumentsHadronPlugin = registerHadronPlugin( connectionScopedAppRegistry: connectionScopedAppRegistryLocator, queryBar: queryBarServiceLocator, + collection: collectionModelLocator, } ); export const CompassDocumentsPlugin = { name: 'Documents' as const, - component: CompassDocumentsHadronPlugin, + provider: CompassDocumentsHadronPlugin, + content: DocumentList as any, // as any because of reflux store + header: CrudTabTitle as any, // as any because of reflux store }; export default DocumentList; diff --git a/packages/compass-collection/src/components/collection-tab-stats.tsx b/packages/compass-crud/src/plugin-title.tsx similarity index 58% rename from packages/compass-collection/src/components/collection-tab-stats.tsx rename to packages/compass-crud/src/plugin-title.tsx index e37b2be543b..b51173adf10 100644 --- a/packages/compass-collection/src/components/collection-tab-stats.tsx +++ b/packages/compass-crud/src/plugin-title.tsx @@ -1,22 +1,20 @@ import React, { useMemo } from 'react'; import numeral from 'numeral'; -import { css, Tooltip, Badge } from '@mongodb-js/compass-components'; -import type { CollectionState } from '../modules/collection-tab'; +import { css, Tooltip, Badge, spacing } from '@mongodb-js/compass-components'; +import type { CrudStore } from './stores/crud-store'; -const tooltipDocumentsListStyles = css({ +const tooltipContentStyles = css({ listStyleType: 'none', padding: 0, margin: 0, }); -const INVALID = 'N/A'; +const containerStyles = css({ + display: 'flex', + gap: spacing[200], +}); -const avg = (size: number, count: number) => { - if (count <= 0) { - return 0; - } - return size / count; -}; +const INVALID = 'N/A'; const isNumber = (val: any): val is number => { return typeof val === 'number' && !isNaN(val); @@ -30,11 +28,11 @@ const format = (value: any, format = 'a') => { return numeral(value).format(precision + format); }; -type CollectionTabStatsProps = { +type CollectionStatsProps = { text: string; details: string[]; }; -const CollectionTabStats: React.FunctionComponent = ({ +const CollectionStats: React.FunctionComponent = ({ text, details, }) => { @@ -57,7 +55,7 @@ const CollectionTabStats: React.FunctionComponent = ({ } > -
    +
      {details.map((detail, i) => (
    1. {detail} @@ -69,48 +67,36 @@ const CollectionTabStats: React.FunctionComponent = ({ ); }; -type CollectionTabProps = { - stats: CollectionState['stats']; -}; -export const CollectionDocumentsStats: React.FunctionComponent< - CollectionTabProps -> = ({ stats }) => { +export const CrudTabTitle = ({ + store: { + state: { collectionStats }, + }, +}: { + store: CrudStore; +}) => { const { documentCount, storageSize, avgDocumentSize } = useMemo(() => { const { document_count = NaN, storage_size = NaN, free_storage_size = NaN, avg_document_size = NaN, - } = stats ?? {}; + } = collectionStats ?? {}; return { documentCount: format(document_count), storageSize: format(storage_size - free_storage_size, 'b'), avgDocumentSize: format(avg_document_size, 'b'), }; - }, [stats]); + }, [collectionStats]); const details = [ `Documents: ${documentCount}`, `Storage Size: ${storageSize}`, `Avg. Size: ${avgDocumentSize}`, ]; - return ; -}; -export const CollectionIndexesStats: React.FunctionComponent< - CollectionTabProps -> = ({ stats }) => { - const { indexCount, totalIndexSize, avgIndexSize } = useMemo(() => { - const { index_count = NaN, index_size = NaN } = stats ?? {}; - return { - indexCount: format(index_count), - totalIndexSize: format(index_size, 'b'), - avgIndexSize: format(avg(index_size, index_count), 'b'), - }; - }, [stats]); - const details = [ - `Indexes: ${indexCount}`, - `Total Size: ${totalIndexSize}`, - `Avg. Size: ${avgIndexSize}`, - ]; - return ; + return ( +
      + Documents + +
      + ); }; diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index 7c31b49b3fe..fcbac4d3d89 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -119,6 +119,31 @@ const mockQueryBar = { changeQuery: sinon.stub(), }; +const defaultMetadata = { + namespace: 'test.foo', + isReadonly: false, + isTimeSeries: false, + isClustered: false, + isFLE: false, + isSearchIndexesSupported: false, + sourceName: 'test.bar', +}; +const mockCollection = { + _id: defaultMetadata.namespace, + avg_document_size: 1, + document_count: 10, + free_storage_size: 10, + storage_size: 20, + fetchMetadata() { + return Promise.resolve(defaultMetadata); + }, + toJSON() { + return this; + }, + on: sinon.spy(), + removeListener: sinon.spy(), +}; + describe('store', function () { const cluster = mochaTestServer({ topology: 'replset', @@ -166,6 +191,7 @@ describe('store', function () { connectionInfoRef, connectionScopedAppRegistry, queryBar: mockQueryBar, + collection: mockCollection as any, ...services, }, createActivateHelpers() @@ -304,6 +330,12 @@ describe('store', function () { isCollectionScan: false, version: '6.0.0', view: 'List', + collectionStats: { + avg_document_size: 1, + document_count: 10, + free_storage_size: 10, + storage_size: 20, + }, }); }); }); diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 258422dbe1e..496556241e2 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -52,7 +52,10 @@ import { openBulkUpdateSuccessToast, } from '../components/bulk-actions-toasts'; import type { DataService } from '../utils/data-service'; -import type { MongoDBInstance } from '@mongodb-js/compass-app-stores/provider'; +import type { + Collection, + MongoDBInstance, +} from '@mongodb-js/compass-app-stores/provider'; import configureActions from '../actions'; import type { ActivateHelpers } from 'hadron-app-registry'; import type { Logger } from '@mongodb-js/compass-logging/provider'; @@ -169,6 +172,20 @@ export const fetchDocuments: ( } }; +type CollectionStats = Pick< + Collection, + 'document_count' | 'storage_size' | 'free_storage_size' | 'avg_document_size' +>; +const extractCollectionStats = (collection: Collection): CollectionStats => { + const coll = collection.toJSON(); + return { + document_count: coll.document_count, + storage_size: coll.storage_size, + free_storage_size: coll.free_storage_size, + avg_document_size: coll.avg_document_size, + }; +}; + /** * Default number of docs per page. */ @@ -316,6 +333,7 @@ type CrudState = { isUpdatePreviewSupported: boolean; bulkDelete: BulkDeleteState; docsPerPage: number; + collectionStats: CollectionStats | null; }; type CrudStoreActionsOptions = { @@ -347,6 +365,7 @@ class CrudStoreImpl instance: MongoDBInstance; connectionScopedAppRegistry: ConnectionScopedAppRegistry; queryBar: QueryBarService; + collection: Collection; constructor( options: CrudStoreOptions & CrudStoreActionsOptions, @@ -362,6 +381,7 @@ class CrudStoreImpl | 'fieldStoreService' | 'connectionScopedAppRegistry' | 'queryBar' + | 'collection' > & { favoriteQueryStorage?: FavoriteQueryStorage; recentQueryStorage?: RecentQueryStorage; @@ -381,6 +401,7 @@ class CrudStoreImpl this.fieldStoreService = services.fieldStoreService; this.connectionScopedAppRegistry = services.connectionScopedAppRegistry; this.queryBar = services.queryBar; + this.collection = services.collection; } getInitialState(): CrudState { @@ -418,6 +439,7 @@ class CrudStoreImpl isUpdatePreviewSupported: this.instance.topologyDescription.type !== 'Single', docsPerPage: this.getInitialDocsPerPage(), + collectionStats: extractCollectionStats(this.collection), }; } @@ -1516,6 +1538,12 @@ class CrudStoreImpl ); } + collectionStatsFetched(model: Collection) { + this.setState({ + collectionStats: extractCollectionStats(model), + }); + } + /** * This function is called when the collection filter changes. */ @@ -1947,6 +1975,7 @@ export type DocumentsPluginServices = { connectionInfoRef: ConnectionInfoRef; connectionScopedAppRegistry: ConnectionScopedAppRegistry; queryBar: QueryBarService; + collection: Collection; }; export function activateDocumentsPlugin( options: CrudStoreOptions, @@ -1964,6 +1993,7 @@ export function activateDocumentsPlugin( connectionInfoRef, connectionScopedAppRegistry, queryBar, + collection, }: DocumentsPluginServices, { on, cleanup }: ActivateHelpers ) { @@ -1984,6 +2014,7 @@ export function activateDocumentsPlugin( fieldStoreService, connectionScopedAppRegistry, queryBar, + collection, } ) ) as CrudStore; @@ -2027,6 +2058,16 @@ export function activateDocumentsPlugin( } ); + on(collection, 'change:status', (model: Collection, status: string) => { + if (status === 'ready') { + store.collectionStatsFetched(model); + } + }); + + on(localAppRegistry, 'refresh-collection-stats', () => { + void collection.fetch({ dataService, force: true }); + }); + if (!options.noRefreshOnConfigure) { queueMicrotask(() => { void store.refreshDocuments(); diff --git a/packages/compass-crud/src/utils/data-service.ts b/packages/compass-crud/src/utils/data-service.ts index fedb0bab4ec..6f398a0363d 100644 --- a/packages/compass-crud/src/utils/data-service.ts +++ b/packages/compass-crud/src/utils/data-service.ts @@ -16,7 +16,12 @@ export type RequiredDataServiceProps = | 'findOneAndUpdate' | 'findOneAndReplace' | 'updateOne' - | 'replaceOne'; + | 'replaceOne' + // Required for collection model (fetching stats) + | 'collectionStats' + | 'collectionInfo' + | 'listCollections' + | 'isListSearchIndexesSupported'; // TODO: It might make sense to refactor the DataService interface to be closer to // { ..., getCSFLEMode(): 'unavailable' } | { ..., getCSFLEMode(): 'unavailable' | 'enabled' | 'disabled', isUpdateAllowed(): ..., knownSchemaForCollection(): ... } // so that either these methods are always present together or always absent diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 3c1893019d9..eac0d4f5f43 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -595,7 +595,7 @@ export const CollectionTab = '[data-testid="collection-tabs"]'; export const CollectionTabStats = ( tabName: 'documents' | 'indexes' ): string => { - return `[data-testid="${tabName}-tab-with-stats"] [data-testid="collection-stats"]`; + return `[data-testid="${tabName}-tab-title"] [data-testid="collection-stats"]`; }; export const CollectionStatsTooltip = '[data-testid="collection-stats-tooltip"]'; diff --git a/packages/compass-indexes/package.json b/packages/compass-indexes/package.json index f906e786f31..ae10f59c8d6 100644 --- a/packages/compass-indexes/package.json +++ b/packages/compass-indexes/package.json @@ -48,17 +48,20 @@ "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." }, "devDependencies": { + "@mongodb-js/atlas-service": "^0.28.2", "@mongodb-js/eslint-config-compass": "^1.1.7", "@mongodb-js/mocha-config-compass": "^1.4.2", "@mongodb-js/prettier-config-compass": "^1.0.2", "@mongodb-js/testing-library-compass": "^1.0.1", "@mongodb-js/tsconfig-compass": "^1.0.5", + "@types/numeral": "^2.0.5", "chai": "^4.2.0", "depcheck": "^1.4.1", "electron": "^30.5.1", "electron-mocha": "^12.2.0", "eslint": "^7.25.0", "mocha": "^10.2.0", + "mongodb-ns": "^2.4.2", "nyc": "^15.1.0", "react-dom": "^17.0.2", "sinon": "^9.2.3", @@ -82,6 +85,7 @@ "hadron-app-registry": "^9.2.6", "lodash": "^4.17.21", "mongodb": "^6.8.0", + "mongodb-collection-model": "^5.23.3", "mongodb-data-service": "^22.23.3", "mongodb-query-parser": "^4.2.3", "numeral": "^2.0.6", diff --git a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx index c9e33b2e11b..2b8632dc57f 100644 --- a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx +++ b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx @@ -160,15 +160,14 @@ export const IndexesToolbar: React.FunctionComponent = ({ } >

      - The Atlas Search index management in Compass is only - available for Atlas local deployments and M10+ clusters - running MongoDB 6.0.7 or newer. + Atlas Search index management in Compass is only available + for Atlas local deployments and clusters running MongoDB + 6.0.7 or newer.

      - For clusters running an earlier version of MongoDB or - shared tier clusters you can manage your Atlas Search - indexes from the Atlas web UI, with the CLI, or with the - Administration API. + For clusters running an earlier version of MongoDB, you + can manage your Atlas Search indexes from the Atlas web + Ul, with the CLI, or with the Administration API.

      )} diff --git a/packages/compass-indexes/src/index.ts b/packages/compass-indexes/src/index.ts index 385d84f348f..f92d5a56b68 100644 --- a/packages/compass-indexes/src/index.ts +++ b/packages/compass-indexes/src/index.ts @@ -1,3 +1,4 @@ +import React from 'react'; import { registerHadronPlugin } from 'hadron-app-registry'; import { activateIndexesPlugin, @@ -9,14 +10,20 @@ import { dataServiceLocator, type DataServiceLocator, } from '@mongodb-js/compass-connections/provider'; -import { mongoDBInstanceLocator } from '@mongodb-js/compass-app-stores/provider'; +import { + collectionModelLocator, + mongoDBInstanceLocator, +} from '@mongodb-js/compass-app-stores/provider'; import { createLoggerLocator } from '@mongodb-js/compass-logging/provider'; import { telemetryLocator } from '@mongodb-js/compass-telemetry/provider'; +import { IndexesTabTitle } from './plugin-title'; -export const CompassIndexesHadronPlugin = registerHadronPlugin( +const CompassIndexesHadronPlugin = registerHadronPlugin( { name: 'CompassIndexes', - component: Indexes as React.FunctionComponent, + component: function IndexesProvider({ children }) { + return React.createElement(React.Fragment, null, children); + }, activate: activateIndexesPlugin, }, { @@ -26,10 +33,13 @@ export const CompassIndexesHadronPlugin = registerHadronPlugin( instance: mongoDBInstanceLocator, logger: createLoggerLocator('COMPASS-INDEXES-UI'), track: telemetryLocator, + collection: collectionModelLocator, } ); export const CompassIndexesPlugin = { name: 'Indexes' as const, - component: CompassIndexesHadronPlugin, + provider: CompassIndexesHadronPlugin, + content: Indexes as React.FunctionComponent, + header: IndexesTabTitle as React.FunctionComponent, }; diff --git a/packages/compass-indexes/src/modules/collection-stats.ts b/packages/compass-indexes/src/modules/collection-stats.ts new file mode 100644 index 00000000000..e867e452b4e --- /dev/null +++ b/packages/compass-indexes/src/modules/collection-stats.ts @@ -0,0 +1,53 @@ +import type { Reducer, AnyAction, Action } from 'redux'; +import type Collection from 'mongodb-collection-model'; + +function isAction( + action: AnyAction, + type: A['type'] +): action is A { + return action.type === type; +} + +export function extractCollectionStats( + collection: Collection +): CollectionStats { + const { index_count, index_size } = collection.toJSON(); + return { + index_count, + index_size, + }; +} + +export type CollectionStats = Pick< + Collection, + 'index_count' | 'index_size' +> | null; + +enum StatsActions { + CollectionStatsFetched = 'compass-indexes/CollectionStatsFetchedCollection', +} + +interface CollectionStatsFetchedAction { + type: StatsActions.CollectionStatsFetched; + collection: Collection; +} + +const reducer: Reducer = (state = null, action) => { + if ( + isAction( + action, + StatsActions.CollectionStatsFetched + ) + ) { + return extractCollectionStats(action.collection); + } + return state; +}; + +export const collectionStatsFetched = ( + collection: Collection +): CollectionStatsFetchedAction => { + return { type: StatsActions.CollectionStatsFetched, collection }; +}; + +export default reducer; diff --git a/packages/compass-indexes/src/modules/index.ts b/packages/compass-indexes/src/modules/index.ts index f1c7079afc5..c953acdf1cf 100644 --- a/packages/compass-indexes/src/modules/index.ts +++ b/packages/compass-indexes/src/modules/index.ts @@ -11,13 +11,14 @@ import searchIndexes from './search-indexes'; import serverVersion from './server-version'; import namespace from './namespace'; import createIndex from './create-index'; +import collectionStats from './collection-stats'; import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import type { DataService } from 'mongodb-data-service'; import type { Logger } from '@mongodb-js/compass-logging'; import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; import type { IndexesDataServiceProps } from '../stores/store'; - +import type { Collection } from '@mongodb-js/compass-app-stores/provider'; const reducer = combineReducers({ // From instance.isWritable. Used to know if the create button should be // enabled. @@ -53,6 +54,9 @@ const reducer = combineReducers({ // State for the create regular index form createIndex, + + // The stats for the collection + collectionStats, }); export type SortDirection = 'asc' | 'desc'; @@ -65,6 +69,7 @@ export type IndexesExtraArgs = { track: TrackFunction; dataService: Pick; connectionInfoRef: ConnectionInfoRef; + collection: Collection; }; export type IndexesThunkDispatch = ThunkDispatch< RootState, diff --git a/packages/compass-indexes/src/modules/rolling-indexes-service.ts b/packages/compass-indexes/src/modules/rolling-indexes-service.ts new file mode 100644 index 00000000000..78e7863fcd1 --- /dev/null +++ b/packages/compass-indexes/src/modules/rolling-indexes-service.ts @@ -0,0 +1,48 @@ +import type { AtlasService } from '@mongodb-js/atlas-service/provider'; +import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +import type { CreateIndexesOptions } from 'mongodb'; +import toNS from 'mongodb-ns'; + +export class RollingIndexesService { + constructor( + private atlasService: AtlasService, + private connectionInfo: ConnectionInfoRef + ) {} + async listRollingIndexes(namespace: string) { + const { atlasMetadata } = this.connectionInfo.current; + if (!atlasMetadata) { + throw new Error( + "Can't list rolling indexes for a non-Atlas cluster: atlasMetadata is not available" + ); + } + const { database: db, collection } = toNS(namespace); + const indexes = await this.atlasService.automationAgentFetch( + atlasMetadata, + 'listIndexStats', + { db, collection } + ); + return indexes.filter((index) => { + return index.status === 'rolling build'; + }); + } + createRollingIndex( + namespace: string, + indexSpec: Record, + { collation, ...options }: CreateIndexesOptions + ): Promise { + const { atlasMetadata } = this.connectionInfo.current; + if (!atlasMetadata) { + throw new Error( + "Can't create a rolling index for a non-Atlas cluster: atlasMetadata is not available" + ); + } + const { database: db, collection } = toNS(namespace); + return this.atlasService.automationAgentFetch(atlasMetadata, 'index', { + db, + collection, + keys: JSON.stringify(indexSpec), + options: Object.keys(options).length > 0 ? JSON.stringify(options) : '', + collationOptions: collation ? JSON.stringify(collation) : '', + }); + } +} diff --git a/packages/compass-indexes/src/plugin-title.tsx b/packages/compass-indexes/src/plugin-title.tsx new file mode 100644 index 00000000000..d4805f1eb18 --- /dev/null +++ b/packages/compass-indexes/src/plugin-title.tsx @@ -0,0 +1,108 @@ +import React, { useMemo } from 'react'; +import { connect } from 'react-redux'; +import type { RootState } from './modules'; +import { Badge, css, spacing, Tooltip } from '@mongodb-js/compass-components'; +import numeral from 'numeral'; + +const containerStyles = css({ + display: 'flex', + gap: spacing[200], +}); + +const tooltipContentStyles = css({ + listStyleType: 'none', + padding: 0, + margin: 0, +}); + +const INVALID = 'N/A'; + +const avg = (size: number, count: number) => { + if (count <= 0) { + return 0; + } + return size / count; +}; + +const isNumber = (val: any): val is number => { + return typeof val === 'number' && !isNaN(val); +}; + +const format = (value: any, format = 'a') => { + if (!isNumber(value)) { + return INVALID; + } + const precision = value <= 1000 ? '0' : '0.0'; + return numeral(value).format(precision + format); +}; + +type CollectionStatsProps = { + text: string; + details: string[]; +}; +const CollectionStats: React.FunctionComponent = ({ + text, + details, +}) => { + return ( +
      + { + // We use these stats in the Collection Tab, and LG does not + // bubble up the click event to the parent component, so we + // add noop onClick and let it bubble up. + }} + > + {text} + + } + > +
        + {details.map((detail, i) => ( +
      1. + {detail} +
      2. + ))} +
      +
      +
      + ); +}; + +const TabTitle = ({ + collectionStats, +}: { + collectionStats: RootState['collectionStats']; +}) => { + const { indexCount, totalIndexSize, avgIndexSize } = useMemo(() => { + const { index_count = NaN, index_size = NaN } = collectionStats ?? {}; + return { + indexCount: format(index_count), + totalIndexSize: format(index_size, 'b'), + avgIndexSize: format(avg(index_size, index_count), 'b'), + }; + }, [collectionStats]); + + const details = [ + `Indexes: ${indexCount}`, + `Total Size: ${totalIndexSize}`, + `Avg. Size: ${avgIndexSize}`, + ]; + + return ( +
      + Indexes + +
      + ); +}; + +export const IndexesTabTitle = connect(({ collectionStats }: RootState) => ({ + collectionStats, +}))(TabTitle); diff --git a/packages/compass-indexes/src/stores/store.spec.ts b/packages/compass-indexes/src/stores/store.spec.ts index 0375daf14ed..b2dced9256b 100644 --- a/packages/compass-indexes/src/stores/store.spec.ts +++ b/packages/compass-indexes/src/stores/store.spec.ts @@ -1,12 +1,8 @@ import { EventEmitter } from 'events'; -import AppRegistry, { createActivateHelpers } from 'hadron-app-registry'; +import AppRegistry from 'hadron-app-registry'; import { expect } from 'chai'; -import type { IndexesDataService } from './store'; -import { activateIndexesPlugin, type IndexesStore } from './store'; - -import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; -import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; -import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +import { type IndexesStore } from './store'; +import { setupStore } from '../../test/setup-store'; class FakeInstance extends EventEmitter { isWritable = true; @@ -16,48 +12,21 @@ class FakeInstance extends EventEmitter { const fakeInstance = new FakeInstance(); describe('IndexesStore [Store]', function () { - let globalAppRegistry: AppRegistry; let localAppRegistry: AppRegistry; - let store: IndexesStore; - let deactivate: () => void; - beforeEach(function () { - globalAppRegistry = new AppRegistry(); localAppRegistry = new AppRegistry(); - - const plugin = activateIndexesPlugin( + store = setupStore( { namespace: 'test.coll', isReadonly: true, - serverVersion: '6.0.0', - isSearchIndexesSupported: true, }, + undefined, { - globalAppRegistry: globalAppRegistry, - localAppRegistry: localAppRegistry, + localAppRegistry, instance: fakeInstance as any, - dataService: { - indexes: () => { - return Promise.resolve([]); - }, - } as unknown as IndexesDataService, - logger: createNoopLogger(), - track: createNoopTrack(), - connectionInfoRef: { - current: { - id: 'TEST', - }, - } as ConnectionInfoRef, - }, - createActivateHelpers() + } ); - store = plugin.store; - deactivate = () => plugin.deactivate(); - }); - - afterEach(function () { - deactivate(); }); it('sets the namespace', function () { diff --git a/packages/compass-indexes/src/stores/store.ts b/packages/compass-indexes/src/stores/store.ts index fffdb0e1fee..15dd40a8e4f 100644 --- a/packages/compass-indexes/src/stores/store.ts +++ b/packages/compass-indexes/src/stores/store.ts @@ -15,10 +15,17 @@ import { import type { DataService } from 'mongodb-data-service'; import type AppRegistry from 'hadron-app-registry'; import type { ActivateHelpers } from 'hadron-app-registry'; -import type { MongoDBInstance } from '@mongodb-js/compass-app-stores/provider'; +import type { + MongoDBInstance, + Collection, +} from '@mongodb-js/compass-app-stores/provider'; import type { Logger } from '@mongodb-js/compass-logging'; import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; +import { + collectionStatsFetched, + extractCollectionStats, +} from '../modules/collection-stats'; export type IndexesDataServiceProps = | 'indexes' @@ -29,7 +36,12 @@ export type IndexesDataServiceProps = | 'getSearchIndexes' | 'createSearchIndex' | 'updateSearchIndex' - | 'dropSearchIndex'; + | 'dropSearchIndex' + // Required for collection model (fetching stats) + | 'collectionStats' + | 'collectionInfo' + | 'listCollections' + | 'isListSearchIndexesSupported'; export type IndexesDataService = Pick; export type IndexesPluginServices = { @@ -39,6 +51,7 @@ export type IndexesPluginServices = { localAppRegistry: Pick; globalAppRegistry: Pick; logger: Logger; + collection: Collection; track: TrackFunction; }; @@ -63,6 +76,7 @@ export function activateIndexesPlugin( logger, track, dataService, + collection: collectionModel, }: IndexesPluginServices, { on, cleanup }: ActivateHelpers ) { @@ -76,6 +90,7 @@ export function activateIndexesPlugin( isReadonlyView: options.isReadonly, isSearchIndexesSupported: options.isSearchIndexesSupported, indexView: INDEX_LIST_INITIAL_STATE, + collectionStats: extractCollectionStats(collectionModel), }, applyMiddleware( thunk.withExtraArgument({ @@ -114,6 +129,15 @@ export function activateIndexesPlugin( if (options.isSearchIndexesSupported) { void store.dispatch(refreshSearchIndexes()); } + on(collectionModel, 'change:status', (model: Collection, status: string) => { + if (status === 'ready') { + store.dispatch(collectionStatsFetched(model)); + } + }); + + on(localAppRegistry, 'refresh-collection-stats', () => { + void collectionModel.fetch({ dataService, force: true }); + }); return { store, deactivate: () => cleanup() }; } diff --git a/packages/compass-indexes/test/setup-store.ts b/packages/compass-indexes/test/setup-store.ts index 8c5747dc644..554417f2b39 100644 --- a/packages/compass-indexes/test/setup-store.ts +++ b/packages/compass-indexes/test/setup-store.ts @@ -59,6 +59,32 @@ const NOOP_DATA_PROVIDER: IndexesDataService = { dropSearchIndex(ns: string, name: string) { return Promise.resolve(); }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + collectionInfo(dbName, collName) { + return Promise.resolve(null); + }, + collectionStats(databaseName, collectionName) { + return Promise.resolve({ + avg_document_size: 0, + count: 0, + database: databaseName, + document_count: 0, + free_storage_size: 0, + index_count: 0, + index_size: 0, + name: collectionName, + ns: `${databaseName}.${collectionName}`, + storage_size: 0, + }); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isListSearchIndexesSupported(ns) { + return Promise.resolve(false); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + listCollections(databaseName, filter, options) { + return Promise.resolve([]); + }, }; class FakeInstance extends EventEmitter { @@ -68,6 +94,26 @@ class FakeInstance extends EventEmitter { const fakeInstance = new FakeInstance(); +const defaultMetadata = { + namespace: 'test.foo', + isReadonly: false, + isTimeSeries: false, + isClustered: false, + isFLE: false, + isSearchIndexesSupported: false, + sourceName: 'test.bar', +}; +const mockCollection = { + _id: defaultMetadata.namespace, + fetchMetadata() { + return Promise.resolve(defaultMetadata); + }, + toJSON() { + return this; + }, + on: Sinon.spy(), +}; + export const setupStore = ( options: Partial = {}, dataProvider: Partial = NOOP_DATA_PROVIDER, @@ -104,6 +150,7 @@ export const setupStore = ( instance: fakeInstance as any, logger: createNoopLogger('TEST'), track: createNoopTrack(), + collection: mockCollection as any, connectionInfoRef, ...services, }, diff --git a/packages/compass-preferences-model/src/preferences-schema.ts b/packages/compass-preferences-model/src/preferences-schema.ts index 9160cc32dab..60b5d900484 100644 --- a/packages/compass-preferences-model/src/preferences-schema.ts +++ b/packages/compass-preferences-model/src/preferences-schema.ts @@ -87,6 +87,7 @@ export type InternalUserPreferences = { telemetryAnonymousId?: string; telemetryAtlasUserId?: string; userCreatedAt: number; + enableGlobalWrites: boolean; }; // UserPreferences contains all preferences stored to disk. @@ -852,6 +853,15 @@ export const storedUserPreferencesProps: Required<{ type: 'boolean', }, + enableGlobalWrites: { + ui: false, + cli: false, + global: false, + description: null, + validator: z.boolean().default(false), + type: 'boolean', + }, + ...allFeatureFlagsProps, }; diff --git a/packages/compass-schema-validation/src/components/validation-states/validation-states.spec.tsx b/packages/compass-schema-validation/src/components/validation-states/validation-states.spec.tsx index 49b188191cc..c693974a42c 100644 --- a/packages/compass-schema-validation/src/components/validation-states/validation-states.spec.tsx +++ b/packages/compass-schema-validation/src/components/validation-states/validation-states.spec.tsx @@ -6,10 +6,10 @@ import { createPluginTestHelpers, screen, } from '@mongodb-js/testing-library-compass'; -import { CompassSchemaValidationHadronPlugin } from '../../index'; +import { CompassSchemaValidationPlugin } from '../../index'; const { renderWithConnections } = createPluginTestHelpers( - CompassSchemaValidationHadronPlugin.withMockServices({ + CompassSchemaValidationPlugin.provider.withMockServices({ dataService: { collectionInfo() { return Promise.resolve({}); diff --git a/packages/compass-schema-validation/src/index.ts b/packages/compass-schema-validation/src/index.ts index 40d455dd039..08760b8894a 100644 --- a/packages/compass-schema-validation/src/index.ts +++ b/packages/compass-schema-validation/src/index.ts @@ -1,3 +1,4 @@ +import React from 'react'; import { onActivated } from './stores'; import CompassSchemaValidation from './components/compass-schema-validation'; import { registerHadronPlugin } from 'hadron-app-registry'; @@ -10,11 +11,14 @@ import { mongoDBInstanceLocator } from '@mongodb-js/compass-app-stores/provider' import { preferencesLocator } from 'compass-preferences-model/provider'; import { createLoggerLocator } from '@mongodb-js/compass-logging/provider'; import { telemetryLocator } from '@mongodb-js/compass-telemetry/provider'; +import { SchemaValidationTabTitle } from './plugin-title'; -export const CompassSchemaValidationHadronPlugin = registerHadronPlugin( +const CompassSchemaValidationHadronPlugin = registerHadronPlugin( { name: 'CompassSchemaValidationPlugin', - component: CompassSchemaValidation, + component: function SchemaValidationsProvider({ children }) { + return React.createElement(React.Fragment, null, children); + }, activate: onActivated, }, { @@ -30,5 +34,7 @@ export const CompassSchemaValidationHadronPlugin = registerHadronPlugin( ); export const CompassSchemaValidationPlugin = { name: 'Validation' as const, - component: CompassSchemaValidationHadronPlugin, + provider: CompassSchemaValidationHadronPlugin, + content: CompassSchemaValidation, + header: SchemaValidationTabTitle, }; diff --git a/packages/compass-schema-validation/src/plugin-title.tsx b/packages/compass-schema-validation/src/plugin-title.tsx new file mode 100644 index 00000000000..b72b96ad011 --- /dev/null +++ b/packages/compass-schema-validation/src/plugin-title.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export function SchemaValidationTabTitle() { + return
      Validation
      ; +} diff --git a/packages/compass-schema/src/index.ts b/packages/compass-schema/src/index.ts index e5e19217183..6f6d2870d39 100644 --- a/packages/compass-schema/src/index.ts +++ b/packages/compass-schema/src/index.ts @@ -1,3 +1,4 @@ +import React from 'react'; import { connectionInfoRefLocator, dataServiceLocator, @@ -12,11 +13,21 @@ import { telemetryLocator } from '@mongodb-js/compass-telemetry/provider'; import { preferencesLocator } from 'compass-preferences-model/provider'; import { fieldStoreServiceLocator } from '@mongodb-js/compass-field-store'; import { queryBarServiceLocator } from '@mongodb-js/compass-query-bar'; +import { SchemaTabTitle } from './plugin-title'; -export const CompassSchemaHadronPlugin = registerHadronPlugin( +const CompassSchemaHadronPlugin = registerHadronPlugin( { name: 'CompassSchemaPlugin', - component: CompassSchema as React.FunctionComponent /* reflux store */, + component: function SchemaProvider({ children, ...props }) { + return React.createElement( + React.Fragment, + null, + // Cloning children with props is a workaround for reflux store. + React.isValidElement(children) + ? React.cloneElement(children, props) + : null + ); + }, activate: activateSchemaPlugin, }, { @@ -31,7 +42,10 @@ export const CompassSchemaHadronPlugin = registerHadronPlugin( connectionInfoRef: connectionInfoRefLocator, } ); + export const CompassSchemaPlugin = { name: 'Schema' as const, - component: CompassSchemaHadronPlugin, + provider: CompassSchemaHadronPlugin, + content: CompassSchema as React.FunctionComponent /* reflux store */, + header: SchemaTabTitle, }; diff --git a/packages/compass-schema/src/plugin-title.tsx b/packages/compass-schema/src/plugin-title.tsx new file mode 100644 index 00000000000..bb966af87db --- /dev/null +++ b/packages/compass-schema/src/plugin-title.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const SchemaTabTitle = () => { + return
      Schema
      ; +}; diff --git a/packages/compass-web/sandbox/index.tsx b/packages/compass-web/sandbox/index.tsx index 8dcd4eba587..ff03193f723 100644 --- a/packages/compass-web/sandbox/index.tsx +++ b/packages/compass-web/sandbox/index.tsx @@ -50,6 +50,7 @@ const App = () => { maximumNumberOfActiveConnections: isAtlas ? 10 : undefined, atlasServiceBackendPreset: atlasServiceSandboxBackendVariant, enableCreatingNewConnections: !isAtlas, + enableGlobalWrites: isAtlas, }} onTrack={sandboxTelemetry.track} onDebug={sandboxLogger.debug} diff --git a/packages/compass-web/src/connection-storage.spec.ts b/packages/compass-web/src/connection-storage.spec.ts index b735caa7b40..4f612f5df70 100644 --- a/packages/compass-web/src/connection-storage.spec.ts +++ b/packages/compass-web/src/connection-storage.spec.ts @@ -157,9 +157,11 @@ describe('buildConnectionInfoFromClusterDescription', function () { projectId: 'abc', metricsId: type === 'serverless' ? `Cluster0-serverless` : '123abc', clusterName: `Cluster0-${type}`, + clusterUniqueId: '123abc', metricsType: type === 'sharded' ? 'cluster' : type, instanceSize: expectedInstanceSize, regionalBaseUrl: 'https://example.com', + clusterType: clusterDescription.clusterType, }); }); } diff --git a/packages/compass-web/src/connection-storage.tsx b/packages/compass-web/src/connection-storage.tsx index 486f0f55412..3ef497b78d4 100644 --- a/packages/compass-web/src/connection-storage.tsx +++ b/packages/compass-web/src/connection-storage.tsx @@ -25,12 +25,14 @@ type ReplicationSpec = { regionConfigs: RegionConfig[]; }; +type ClusterType = 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; + type ClusterDescription = { '@provider': string; uniqueId: string; groupId: string; name: string; - clusterType: string; + clusterType: ClusterType; srvAddress: string; state: string; deploymentItemName: string; @@ -191,10 +193,12 @@ export function buildConnectionInfoFromClusterDescription( atlasMetadata: { orgId: orgId, projectId: projectId, + clusterUniqueId: description.uniqueId, clusterName: description.name, regionalBaseUrl: description.dataProcessingRegion.regionalUrl, ...getMetricsIdAndType(description, deploymentItem), instanceSize: getInstanceSize(description), + clusterType: description.clusterType, }, }; } diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 333b354213e..1bbf0b0302e 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -274,6 +274,7 @@ const CompassWeb = ({ trackUsageStatistics: true, enableShell: false, enableCreatingNewConnections: false, + enableGlobalWrites: false, ...initialPreferences, }) ); diff --git a/packages/connection-info/src/connection-info.ts b/packages/connection-info/src/connection-info.ts index 32b1626572a..7fcaaec6a22 100644 --- a/packages/connection-info/src/connection-info.ts +++ b/packages/connection-info/src/connection-info.ts @@ -8,6 +8,10 @@ export interface AtlasClusterMetadata { * https://www.mongodb.com/docs/atlas/api/atlas-admin-api-ref/#project-id */ projectId: string; + /** + * Unique id returned with the clusterDescription + */ + clusterUniqueId: string; /** * Cluster name, unique inside same project */ @@ -21,9 +25,22 @@ export interface AtlasClusterMetadata { * https://github.com/10gen/mms/blob/43b0049a85196b44e465feb9b96ef942d6f2c8f4/client/js/legacy/core/models/deployment */ metricsId: string; + /** + * Somewhat related to the clusterType provided as part of clusterDescription, + * but way less granular: + * + * - `host`: CM/OM clusters not managed by Atlas (in theory should + * never appear in our runtime) + * - `cluster`: any sharded cluster type (sharded or geo sharded / + * "global writes" one) + * - `replicaSet`: anything that is not sharded (both dedicated or "free + * tier" / MTM) + * - `serverless`: specifically for serverless clusters + */ metricsType: 'host' | 'replicaSet' | 'cluster' | 'serverless'; /** - * Atlas API base url to be used when connecing to a regionalized cluster + * Atlas API base url to be used when making control plane requests for a + * regionalized cluster */ regionalBaseUrl: string; /* @@ -32,6 +49,13 @@ export interface AtlasClusterMetadata { * https://github.com/10gen/mms/blob/9e6bf2d81d4d85b5ac68a15bf471dcddc5922323/client/packages/types/nds/provider.ts#L60-L107 */ instanceSize?: string; + + /** + * Possible types of Atlas clusters. + * Copied from: + * https://github.com/10gen/mms/blob/9e6bf2d81d4d85b5ac68a15bf471dcddc5922323/client/packages/types/nds/clusterDescription.ts#L12-L16 + */ + clusterType: 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; } export interface ConnectionInfo {