From 156145f617965231d8f888a088daa76e0ec53ee5 Mon Sep 17 00:00:00 2001 From: Ivan Medina Date: Wed, 19 Nov 2025 15:44:15 +0100 Subject: [PATCH 1/4] COMPASS-8179: Add timeseries collection stats --- packages/collection-model/index.d.ts | 2 + packages/collection-model/lib/model.js | 2 + packages/data-service/src/data-service.ts | 50 +++++++++++++++++++ packages/data-service/src/types.ts | 2 + .../src/collections.spec.tsx | 48 +++++++++++++++--- .../src/collections.tsx | 31 ++++++++++-- 6 files changed, 123 insertions(+), 12 deletions(-) diff --git a/packages/collection-model/index.d.ts b/packages/collection-model/index.d.ts index 6b5c7ffe43f..e64af0c1044 100644 --- a/packages/collection-model/index.d.ts +++ b/packages/collection-model/index.d.ts @@ -77,6 +77,8 @@ interface CollectionProps { calculated_storage_size: number | undefined; index_count: number | undefined; index_size: number | undefined; + bucket_count: number | undefined; + avg_bucket_size: number | undefined; isTimeSeries: boolean; isView: boolean; /** Only relevant for a view and identifies collection/view from which this view was created. */ diff --git a/packages/collection-model/lib/model.js b/packages/collection-model/lib/model.js index 703ef095247..e48ebc33eab 100644 --- a/packages/collection-model/lib/model.js +++ b/packages/collection-model/lib/model.js @@ -153,6 +153,8 @@ const CollectionModel = AmpersandModel.extend(debounceActions(['fetch']), { free_storage_size: 'number', index_count: 'number', index_size: 'number', + bucket_count: 'number', + avg_bucket_size: 'number', }, derived: { ns: { diff --git a/packages/data-service/src/data-service.ts b/packages/data-service/src/data-service.ts index 0b2e8e0c1b1..f9432c154cc 100644 --- a/packages/data-service/src/data-service.ts +++ b/packages/data-service/src/data-service.ts @@ -1216,6 +1216,30 @@ class DataServiceImpl extends WithLogContext implements DataService { }, }, nindexes: { $max: '$storageStats.nindexes' }, + numBuckets: { + $first: { + $ifNull: [ + '$storageStats.timeseries.bucketCount', + '$storageStats.numBuckets', + ], + }, + }, + totalBucketSize: { + $first: { + $ifNull: [ + { + $multiply: [ + '$storageStats.timeseries.avgBucketSize', + '$storageStats.timeseries.bucketCount', + ], + }, + '$storageStats.totalBucketSize', + ], + }, + }, + avgBucketSizeFromStats: { + $first: '$storageStats.timeseries.avgBucketSize', + }, }, }, { @@ -1230,6 +1254,30 @@ class DataServiceImpl extends WithLogContext implements DataService { else: 0, }, }, + // `avgBucketSize` is the average bucket size for time series collections + avgBucketSize: { + $cond: { + if: { $ne: ['$avgBucketSizeFromStats', null] }, + then: '$avgBucketSizeFromStats', + else: { + $cond: { + if: { + $and: [ + { $ne: ['$numBuckets', null] }, + { $ne: ['$numBuckets', 0] }, + ], + }, + then: { + $divide: [ + { $toDouble: '$totalBucketSize' }, + { $toDouble: '$numBuckets' }, + ], + }, + else: null, + }, + }, + }, + }, }, }, ], @@ -2995,6 +3043,8 @@ class DataServiceImpl extends WithLogContext implements DataService { free_storage_size: data.freeStorageSize ?? 0, index_count: data.nindexes ?? 0, index_size: data.totalIndexSize ?? 0, + bucket_count: data.numBuckets, + avg_bucket_size: data.avgBucketSize, }; } diff --git a/packages/data-service/src/types.ts b/packages/data-service/src/types.ts index 5c51f09c003..f1aaf9b3dfc 100644 --- a/packages/data-service/src/types.ts +++ b/packages/data-service/src/types.ts @@ -10,6 +10,8 @@ export interface CollectionStats { free_storage_size: number; index_count: number; index_size: number; + bucket_count?: number; + avg_bucket_size?: number; } export interface CollStatsIndexDetails { diff --git a/packages/databases-collections-list/src/collections.spec.tsx b/packages/databases-collections-list/src/collections.spec.tsx index ebe3e4a6bee..67b957d4ad1 100644 --- a/packages/databases-collections-list/src/collections.spec.tsx +++ b/packages/databases-collections-list/src/collections.spec.tsx @@ -58,6 +58,8 @@ function createCollection( index_count: 15, index_size: 16, calculated_storage_size: undefined, + bucket_count: undefined, + avg_bucket_size: undefined, ...props, }; @@ -103,9 +105,11 @@ const colls: CollectionProps[] = [ storage_size: 5000, document_count: undefined, avg_document_size: undefined, - index_size: undefined, + index_size: 2000, type: 'timeseries', - index_count: undefined, + index_count: 2, + bucket_count: 50, + avg_bucket_size: 100, properties: [{ id: 'timeseries' }], }), createCollection('qux', { @@ -405,9 +409,9 @@ describe('Collections', () => { }); await testSortColumn(screen, 'collections-list', 'Indexes', [ - ['5', '0', '-', '-', '5', '1', '11', '3', '5', '17'], - ['17', '11', '5', '5', '5', '3', '1', '0', '-', '-'], - ['0', '1', '3', '5', '5', '5', '11', '17', '-', '-'], + ['5', '0', '-', '2', '5', '1', '11', '3', '5', '17'], + ['17', '11', '5', '5', '5', '3', '2', '1', '0', '-'], + ['0', '1', '2', '3', '5', '5', '5', '11', '17', '-'], ]); }); @@ -421,7 +425,7 @@ describe('Collections', () => { '500.00 B', '0 B', '-', - '-', + '2.00 kB', '17.00 kB', '10.00 MB', '555.00 B', @@ -435,23 +439,23 @@ describe('Collections', () => { '200.00 kB', '123.46 kB', '17.00 kB', + '2.00 kB', '555.00 B', '500.00 B', '0 B', '-', - '-', ], [ '0 B', '500.00 B', '555.00 B', + '2.00 kB', '17.00 kB', '123.46 kB', '200.00 kB', '333.33 kB', '10.00 MB', '-', - '-', ], ]); }); @@ -555,6 +559,34 @@ describe('Collections', () => { ); }); + it('renders a tooltip for timeseries badge with bucket stats', async function () { + renderCollectionsList({ + collections: colls, + }); + + const result = inspectTable(screen, 'collections-list'); + const badge = result.trs[3].querySelector( + '[data-testid="collection-badge-timeseries"]' + ); + expect(badge).to.exist; + + userEvent.hover(badge as Element); + await waitFor( + function () { + expect(screen.getByRole('tooltip')).to.exist; + }, + { + timeout: 5000, + } + ); + + const tooltipText = screen.getByRole('tooltip').textContent; + expect(tooltipText).to.include('Bucket count:'); + expect(tooltipText).to.include('50'); + expect(tooltipText).to.include('Avg. bucket size:'); + expect(tooltipText).to.include('100.00 B'); + }); + it('renders a tooltip for storage size cell with storage breakdown and data size', async function () { renderCollectionsList({ collections: colls, diff --git a/packages/databases-collections-list/src/collections.tsx b/packages/databases-collections-list/src/collections.tsx index ede1dc1b3f3..dd57c1bc902 100644 --- a/packages/databases-collections-list/src/collections.tsx +++ b/packages/databases-collections-list/src/collections.tsx @@ -142,8 +142,31 @@ function collectionPropertyToBadge( }; case 'capped': return { id, name: id, variant: 'darkgray' }; - case 'timeseries': - return { id, name: id, variant: 'darkgray', icon: 'TimeSeries' }; + case 'timeseries': { + let hint: React.ReactNode = undefined; + if ( + collection.bucket_count !== undefined || + collection.avg_bucket_size !== undefined + ) { + hint = ( + <> + {collection.bucket_count !== undefined && ( +
+ Bucket count:{' '} + {compactNumber(collection.bucket_count)} +
+ )} + {collection.avg_bucket_size !== undefined && ( +
+ Avg. bucket size:{' '} + {compactBytes(collection.avg_bucket_size)} +
+ )} + + ); + } + return { id, name: id, variant: 'darkgray', icon: 'TimeSeries', hint }; + } case 'fle2': return { id, @@ -415,7 +438,7 @@ function collectionColumns({ } const type = collection.type as string; - if (type === 'view' || type === 'timeseries') { + if (type === 'view') { return '-'; } @@ -436,7 +459,7 @@ function collectionColumns({ return ; } - if (collection.type === 'view' || collection.type === 'timeseries') { + if (collection.type === 'view') { return '-'; } From c6b5211e77fbd59d26db3a0a5f73290c22e67f3d Mon Sep 17 00:00:00 2001 From: Ivan Medina Date: Mon, 24 Nov 2025 11:06:41 +0100 Subject: [PATCH 2/4] refactor: collection stats based on collection type --- packages/collection-model/lib/model.js | 2 +- packages/compass-indexes/test/setup-store.ts | 2 +- .../data-service/src/data-service.spec.ts | 59 ++++++- packages/data-service/src/data-service.ts | 157 ++++++++---------- 4 files changed, 125 insertions(+), 95 deletions(-) diff --git a/packages/collection-model/lib/model.js b/packages/collection-model/lib/model.js index e48ebc33eab..bb3bc56ed49 100644 --- a/packages/collection-model/lib/model.js +++ b/packages/collection-model/lib/model.js @@ -278,7 +278,7 @@ const CollectionModel = AmpersandModel.extend(debounceActions(['fetch']), { this.set({ status: newStatus }); const [collStats, collectionInfo] = await Promise.all([ shouldFetchDbAndCollStats - ? dataService.collectionStats(this.database, this.name) + ? dataService.collectionStats(this.database, this.name, this.type) : null, fetchInfo ? dataService.collectionInfo(this.database, this.name) : null, ]); diff --git a/packages/compass-indexes/test/setup-store.ts b/packages/compass-indexes/test/setup-store.ts index e0034988b0b..807e0414cdf 100644 --- a/packages/compass-indexes/test/setup-store.ts +++ b/packages/compass-indexes/test/setup-store.ts @@ -64,7 +64,7 @@ const NOOP_DATA_PROVIDER: IndexesDataService = { collectionInfo(dbName, collName) { return Promise.resolve(null); }, - collectionStats(databaseName, collectionName) { + collectionStats(databaseName, collectionName, collectionType) { return Promise.resolve({ avg_document_size: 0, count: 0, diff --git a/packages/data-service/src/data-service.spec.ts b/packages/data-service/src/data-service.spec.ts index de20ec509a1..a0b00395205 100644 --- a/packages/data-service/src/data-service.spec.ts +++ b/packages/data-service/src/data-service.spec.ts @@ -633,11 +633,68 @@ describe('DataService', function () { it('returns an object with the collection stats', async function () { const stats = await dataService.collectionStats( testDatabaseName, - testCollectionName + testCollectionName, + 'collection' ); expect(stats.name).to.equal(testCollectionName); }); }); + + context('when the collection is a timeseries collection', function () { + let timeseriesCollectionName: string; + + beforeEach(async function () { + timeseriesCollectionName = `timeseries-${new UUID().toString()}`; + // Create a timeseries collection + await mongoClient + .db(testDatabaseName) + .createCollection(timeseriesCollectionName, { + timeseries: { + timeField: 'timestamp', + metaField: 'metadata', + }, + }); + // Insert some data into the timeseries collection + await mongoClient + .db(testDatabaseName) + .collection(timeseriesCollectionName) + .insertMany([ + { + timestamp: new Date(), + metadata: { sensor: 'A' }, + value: 10, + }, + { + timestamp: new Date(), + metadata: { sensor: 'B' }, + value: 20, + }, + ]); + }); + + afterEach(async function () { + try { + await mongoClient + .db(testDatabaseName) + .collection(timeseriesCollectionName) + .drop(); + } catch { + /* ignore */ + } + }); + + it('returns an object with the collection stats including bucket stats', async function () { + const stats = await dataService.collectionStats( + testDatabaseName, + timeseriesCollectionName, + 'timeseries' + ); + expect(stats.name).to.equal(timeseriesCollectionName); + // Timeseries collections should have bucket stats + expect(stats).to.have.property('bucket_count'); + expect(stats).to.have.property('avg_bucket_size'); + }); + }); }); describe('#listCollections', function () { diff --git a/packages/data-service/src/data-service.ts b/packages/data-service/src/data-service.ts index f9432c154cc..371b91bda6a 100644 --- a/packages/data-service/src/data-service.ts +++ b/packages/data-service/src/data-service.ts @@ -375,10 +375,12 @@ export interface DataService { * * @param databaseName - The database name. * @param collectionName - The collection name. + * @param collectionType - The collection type ('collection', 'view', or 'timeseries'). */ collectionStats( databaseName: string, - collectionName: string + collectionName: string, + collectionType: 'collection' | 'view' | 'timeseries' ): Promise; /** @@ -1182,104 +1184,75 @@ class DataServiceImpl extends WithLogContext implements DataService { }) async collectionStats( databaseName: string, - collectionName: string + collectionName: string, + collectionType: 'collection' | 'view' | 'timeseries' ): Promise { const ns = `${databaseName}.${collectionName}`; try { const coll = this._collection(ns, 'CRUD'); + + // Build the aggregation pipeline dynamically based on collection type + const groupStage: Record = { + _id: null, + capped: { $first: '$storageStats.capped' }, + count: { $sum: '$storageStats.count' }, + size: { $sum: { $toDouble: '$storageStats.size' } }, + storageSize: { + $sum: { $toDouble: '$storageStats.storageSize' }, + }, + totalIndexSize: { + $sum: { $toDouble: '$storageStats.totalIndexSize' }, + }, + freeStorageSize: { + $sum: { $toDouble: '$storageStats.freeStorageSize' }, + }, + unscaledCollSize: { + $sum: { + $multiply: [ + { $toDouble: '$storageStats.avgObjSize' }, + { $toDouble: '$storageStats.count' }, + ], + }, + }, + nindexes: { $max: '$storageStats.nindexes' }, + }; + + // Only extract bucket statistics for time series collections + if (collectionType === 'timeseries') { + groupStage.timeseriesBucketCount = { + $first: '$storageStats.timeseries.bucketCount', + }; + groupStage.timeseriesAvgBucketSize = { + $first: '$storageStats.timeseries.avgBucketSize', + }; + } + + const addFieldsStage: Record = { + // `avgObjSize` is the average of per-shard `avgObjSize` weighted by `count` + avgObjSize: { + $cond: { + if: { $ne: ['$count', 0] }, + then: { + $divide: ['$unscaledCollSize', { $toDouble: '$count' }], + }, + else: 0, + }, + }, + }; + + // Only calculate bucket stats for time series collections + if (collectionType === 'timeseries') { + addFieldsStage.bucket_count = '$timeseriesBucketCount'; + addFieldsStage.avg_bucket_size = '$timeseriesAvgBucketSize'; + } + const collStats = await coll .aggregate( [ { $collStats: { storageStats: {} } }, - { - $group: { - _id: null, - capped: { $first: '$storageStats.capped' }, - count: { $sum: '$storageStats.count' }, - size: { $sum: { $toDouble: '$storageStats.size' } }, - storageSize: { - $sum: { $toDouble: '$storageStats.storageSize' }, - }, - totalIndexSize: { - $sum: { $toDouble: '$storageStats.totalIndexSize' }, - }, - freeStorageSize: { - $sum: { $toDouble: '$storageStats.freeStorageSize' }, - }, - unscaledCollSize: { - $sum: { - $multiply: [ - { $toDouble: '$storageStats.avgObjSize' }, - { $toDouble: '$storageStats.count' }, - ], - }, - }, - nindexes: { $max: '$storageStats.nindexes' }, - numBuckets: { - $first: { - $ifNull: [ - '$storageStats.timeseries.bucketCount', - '$storageStats.numBuckets', - ], - }, - }, - totalBucketSize: { - $first: { - $ifNull: [ - { - $multiply: [ - '$storageStats.timeseries.avgBucketSize', - '$storageStats.timeseries.bucketCount', - ], - }, - '$storageStats.totalBucketSize', - ], - }, - }, - avgBucketSizeFromStats: { - $first: '$storageStats.timeseries.avgBucketSize', - }, - }, - }, - { - $addFields: { - // `avgObjSize` is the average of per-shard `avgObjSize` weighted by `count` - avgObjSize: { - $cond: { - if: { $ne: ['$count', 0] }, - then: { - $divide: ['$unscaledCollSize', { $toDouble: '$count' }], - }, - else: 0, - }, - }, - // `avgBucketSize` is the average bucket size for time series collections - avgBucketSize: { - $cond: { - if: { $ne: ['$avgBucketSizeFromStats', null] }, - then: '$avgBucketSizeFromStats', - else: { - $cond: { - if: { - $and: [ - { $ne: ['$numBuckets', null] }, - { $ne: ['$numBuckets', 0] }, - ], - }, - then: { - $divide: [ - { $toDouble: '$totalBucketSize' }, - { $toDouble: '$numBuckets' }, - ], - }, - else: null, - }, - }, - }, - }, - }, - }, + { $group: groupStage }, + { $addFields: addFieldsStage }, ], { enableUtf8Validation: false } ) @@ -3043,8 +3016,8 @@ class DataServiceImpl extends WithLogContext implements DataService { free_storage_size: data.freeStorageSize ?? 0, index_count: data.nindexes ?? 0, index_size: data.totalIndexSize ?? 0, - bucket_count: data.numBuckets, - avg_bucket_size: data.avgBucketSize, + bucket_count: data.bucket_count, + avg_bucket_size: data.avg_bucket_size, }; } From da12e01a9c70377b137bc3dfa41c1d9df9010d04 Mon Sep 17 00:00:00 2001 From: Ivan Medina Date: Mon, 24 Nov 2025 12:04:12 +0100 Subject: [PATCH 3/4] fix: unit tests for timeseries collection --- .../data-service/src/data-service.spec.ts | 107 +++++++++--------- 1 file changed, 51 insertions(+), 56 deletions(-) diff --git a/packages/data-service/src/data-service.spec.ts b/packages/data-service/src/data-service.spec.ts index a0b00395205..2fe4ba497fb 100644 --- a/packages/data-service/src/data-service.spec.ts +++ b/packages/data-service/src/data-service.spec.ts @@ -639,62 +639,6 @@ describe('DataService', function () { expect(stats.name).to.equal(testCollectionName); }); }); - - context('when the collection is a timeseries collection', function () { - let timeseriesCollectionName: string; - - beforeEach(async function () { - timeseriesCollectionName = `timeseries-${new UUID().toString()}`; - // Create a timeseries collection - await mongoClient - .db(testDatabaseName) - .createCollection(timeseriesCollectionName, { - timeseries: { - timeField: 'timestamp', - metaField: 'metadata', - }, - }); - // Insert some data into the timeseries collection - await mongoClient - .db(testDatabaseName) - .collection(timeseriesCollectionName) - .insertMany([ - { - timestamp: new Date(), - metadata: { sensor: 'A' }, - value: 10, - }, - { - timestamp: new Date(), - metadata: { sensor: 'B' }, - value: 20, - }, - ]); - }); - - afterEach(async function () { - try { - await mongoClient - .db(testDatabaseName) - .collection(timeseriesCollectionName) - .drop(); - } catch { - /* ignore */ - } - }); - - it('returns an object with the collection stats including bucket stats', async function () { - const stats = await dataService.collectionStats( - testDatabaseName, - timeseriesCollectionName, - 'timeseries' - ); - expect(stats.name).to.equal(timeseriesCollectionName); - // Timeseries collections should have bucket stats - expect(stats).to.have.property('bucket_count'); - expect(stats).to.have.property('avg_bucket_size'); - }); - }); }); describe('#listCollections', function () { @@ -829,6 +773,57 @@ describe('DataService', function () { }); }); + describe('#collectionStats with timeseries', function () { + it('returns an object with the collection stats including bucket stats', async function () { + const timeseriesCollectionName = `timeseries-${new UUID().toString()}`; + + try { + // Create a timeseries collection + await mongoClient + .db(testDatabaseName) + .createCollection(timeseriesCollectionName, { + timeseries: { + timeField: 'timestamp', + metaField: 'metadata', + }, + }); + // Insert some data into the timeseries collection + await mongoClient + .db(testDatabaseName) + .collection(timeseriesCollectionName) + .insertMany([ + { + timestamp: new Date(), + metadata: { sensor: 'A' }, + value: 10, + }, + { + timestamp: new Date(), + metadata: { sensor: 'B' }, + value: 20, + }, + ]); + + const stats = await dataService.collectionStats( + testDatabaseName, + timeseriesCollectionName, + 'timeseries' + ); + expect(stats.name).to.equal(timeseriesCollectionName); + // Timeseries collections should have bucket stats + expect(stats).to.have.property('bucket_count'); + expect(stats).to.have.property('avg_bucket_size'); + } finally { + // Clean up the timeseries collection + await mongoClient + .db(testDatabaseName) + .collection(timeseriesCollectionName) + .drop() + .catch(() => null); + } + }); + }); + describe('#updateCollection', function () { it('returns the update result', async function () { const result = await dataService.updateCollection(testNamespace); From ddd623f8db1c74e932681eccdcf18a3b8627dfb9 Mon Sep 17 00:00:00 2001 From: Ivan Medina Date: Mon, 24 Nov 2025 12:26:40 +0100 Subject: [PATCH 4/4] fix: unit test lint --- packages/compass-indexes/test/setup-store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/compass-indexes/test/setup-store.ts b/packages/compass-indexes/test/setup-store.ts index 807e0414cdf..b2fe57bf5a4 100644 --- a/packages/compass-indexes/test/setup-store.ts +++ b/packages/compass-indexes/test/setup-store.ts @@ -64,6 +64,7 @@ const NOOP_DATA_PROVIDER: IndexesDataService = { collectionInfo(dbName, collName) { return Promise.resolve(null); }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars collectionStats(databaseName, collectionName, collectionType) { return Promise.resolve({ avg_document_size: 0,