diff --git a/e2e/tests/functional/plugins/plot/tagging.e2e.spec.js b/e2e/tests/functional/plugins/plot/tagging.e2e.spec.js index cfb0ba1e1d..6b0fdd1176 100644 --- a/e2e/tests/functional/plugins/plot/tagging.e2e.spec.js +++ b/e2e/tests/functional/plugins/plot/tagging.e2e.spec.js @@ -41,7 +41,7 @@ test.describe('Plot Tagging', () => { * @param {Number} yEnd a telemetry item with a plot * @returns {Promise} */ - async function createTags({ page, canvas, xEnd = 700, yEnd = 480 }) { + async function createTags({ page, canvas, xEnd = 700, yEnd = 520 }) { await canvas.hover({ trial: true }); //Alt+Shift Drag Start to select some points to tag @@ -284,7 +284,7 @@ test.describe('Plot Tagging', () => { page, canvas, xEnd: 700, - yEnd: 215 + yEnd: 240 }); await basicTagsTests(page); await testTelemetryItem(page, alphaSineWave); diff --git a/e2e/tests/performance/tagging.perf.spec.js b/e2e/tests/performance/tagging.perf.spec.js index 8c527e4554..b3ceca7961 100644 --- a/e2e/tests/performance/tagging.perf.spec.js +++ b/e2e/tests/performance/tagging.perf.spec.js @@ -41,7 +41,7 @@ test.describe('Plot Tagging Performance', () => { * @param {Number} yEnd a telemetry item with a plot * @returns {Promise} */ - async function createTags({ page, canvas, xEnd = 700, yEnd = 480 }) { + async function createTags({ page, canvas, xEnd = 700, yEnd = 520 }) { await canvas.hover({ trial: true }); //Alt+Shift Drag Start to select some points to tag @@ -265,7 +265,7 @@ test.describe('Plot Tagging Performance', () => { page, canvas, xEnd: 700, - yEnd: 215 + yEnd: 240 }); await basicTagsTests(page); await testTelemetryItem(page, alphaSineWave); diff --git a/src/api/annotation/AnnotationAPI.js b/src/api/annotation/AnnotationAPI.js index c56d005c98..3c75f00009 100644 --- a/src/api/annotation/AnnotationAPI.js +++ b/src/api/annotation/AnnotationAPI.js @@ -100,7 +100,7 @@ export default class AnnotationAPI extends EventEmitter { creatable: false, cssClass: 'icon-notebook', initialize: function (domainObject) { - domainObject.targets = domainObject.targets || {}; + domainObject.targets = domainObject.targets || []; domainObject._deleted = domainObject._deleted || false; domainObject.originalContextPath = domainObject.originalContextPath || ''; domainObject.tags = domainObject.tags || []; @@ -117,10 +117,10 @@ export default class AnnotationAPI extends EventEmitter { * @property {ANNOTATION_TYPES} annotationType the type of annotation to create (e.g., PLOT_SPATIAL) * @property {Tag[]} tags tags to add to the annotation, e.g., SCIENCE for science related annotations * @property {String} contentText Some text to add to the annotation, e.g. ("This annotation is about science") - * @property {Object<string, Object>} targets The targets ID keystrings and their specific properties. - * For plots, this will be a bounding box, e.g.: {maxY: 100, minY: 0, maxX: 100, minX: 0} + * @property {Array<Object>} targets The targets ID keystrings and their specific properties. + * For plots, this will be a bounding box, e.g.: {keyString: "d8385009-789d-457b-acc7-d50ba2fd55ea", maxY: 100, minY: 0, maxX: 100, minX: 0} * For notebooks, this will be an entry ID, e.g.: {entryId: "entry-ecb158f5-d23c-45e1-a704-649b382622ba"} - * @property {DomainObject>} targetDomainObjects the targets ID keystrings and the domain objects this annotation points to (e.g., telemetry objects for a plot) + * @property {DomainObject>[]} targetDomainObjects the domain objects this annotation points to (e.g., telemetry objects for a plot) */ /** * @method create @@ -141,11 +141,15 @@ export default class AnnotationAPI extends EventEmitter { throw new Error(`Unknown annotation type: ${annotationType}`); } - if (!Object.keys(targets).length) { + if (!targets.length) { throw new Error(`At least one target is required to create an annotation`); } - if (!Object.keys(targetDomainObjects).length) { + if (targets.some((target) => !target.keyString)) { + throw new Error(`All targets require a keyString to create an annotation`); + } + + if (!targetDomainObjects.length) { throw new Error(`At least one targetDomainObject is required to create an annotation`); } @@ -181,7 +185,7 @@ export default class AnnotationAPI extends EventEmitter { const success = await this.openmct.objects.save(createdObject); if (success) { this.emit('annotationCreated', createdObject); - Object.values(targetDomainObjects).forEach((targetDomainObject) => { + targetDomainObjects.forEach((targetDomainObject) => { this.#updateAnnotationModified(targetDomainObject); }); @@ -321,7 +325,10 @@ export default class AnnotationAPI extends EventEmitter { } #addTagMetaInformationToTags(tags) { - return tags.map((tagKey) => { + // Convert to Set and back to Array to remove duplicates + const uniqueTags = [...new Set(tags)]; + + return uniqueTags.map((tagKey) => { const tagModel = this.availableTags[tagKey]; tagModel.tagID = tagKey; @@ -363,7 +370,8 @@ export default class AnnotationAPI extends EventEmitter { const modelAddedToResults = await Promise.all( results.map(async (result) => { const targetModels = await Promise.all( - Object.keys(result.targets).map(async (targetID) => { + result.targets.map(async (target) => { + const targetID = target.keyString; const targetModel = await this.openmct.objects.get(targetID); const targetKeyString = this.openmct.objects.makeKeyString(targetModel.identifier); const originalPathObjects = await this.openmct.objects.getOriginalPath(targetKeyString); @@ -410,13 +418,12 @@ export default class AnnotationAPI extends EventEmitter { #breakApartSeparateTargets(results) { const separateResults = []; results.forEach((result) => { - Object.keys(result.targets).forEach((targetID) => { + result.targets.forEach((target) => { + const targetID = target.keyString; const separatedResult = { ...result }; - separatedResult.targets = { - [targetID]: result.targets[targetID] - }; + separatedResult.targets = [target]; separatedResult.targetModels = result.targetModels.filter((targetModel) => { const targetKeyString = this.openmct.objects.makeKeyString(targetModel.identifier); diff --git a/src/api/annotation/AnnotationAPISpec.js b/src/api/annotation/AnnotationAPISpec.js index 8c865be15d..0bc7932b9f 100644 --- a/src/api/annotation/AnnotationAPISpec.js +++ b/src/api/annotation/AnnotationAPISpec.js @@ -62,11 +62,12 @@ describe('The Annotation API', () => { key: 'anAnnotationKey', namespace: 'fooNameSpace' }, - targets: { - 'fooNameSpace:some-object': { + targets: [ + { + keyString: 'fooNameSpace:some-object', entryId: 'fooBarEntry' } - } + ] }; mockObjectProvider = jasmine.createSpyObj('mock provider', ['create', 'update', 'get']); @@ -121,7 +122,7 @@ describe('The Annotation API', () => { tags: ['sometag'], contentText: 'fooContext', targetDomainObjects: [mockDomainObject], - targets: { fooTarget: {} } + targets: [{ keyString: 'fooTarget' }] }; const annotationObject = await openmct.annotation.create(annotationCreationArguments); expect(annotationObject).toBeDefined(); @@ -136,7 +137,7 @@ describe('The Annotation API', () => { tags: ['sometag'], contentText: 'fooContext', targetDomainObjects: [mockDomainObject], - targets: { fooTarget: {} } + targets: [{ keyString: 'fooTarget' }] }; openmct.annotation.setNamespaceToSaveAnnotations('fooNameSpace'); const annotationObject = await openmct.annotation.create(annotationCreationArguments); @@ -166,7 +167,7 @@ describe('The Annotation API', () => { tags: ['sometag'], contentText: 'fooContext', targetDomainObjects: [mockDomainObject], - targets: { fooTarget: {} } + targets: [{ keyString: 'fooTarget' }] }; openmct.annotation.setNamespaceToSaveAnnotations('namespaceThatDoesNotExist'); await openmct.annotation.create(annotationCreationArguments); @@ -183,7 +184,7 @@ describe('The Annotation API', () => { tags: ['sometag'], contentText: 'fooContext', targetDomainObjects: [mockDomainObject], - targets: { fooTarget: {} } + targets: [{ keyString: 'fooTarget' }] }; openmct.annotation.setNamespaceToSaveAnnotations('immutableProvider'); await openmct.annotation.create(annotationCreationArguments); @@ -202,7 +203,7 @@ describe('The Annotation API', () => { annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, tags: ['aWonderfulTag'], contentText: 'fooContext', - targets: { 'fooNameSpace:some-object': { entryId: 'fooBarEntry' } }, + targets: [{ keyString: 'fooNameSpace:some-object', entryId: 'fooBarEntry' }], targetDomainObjects: [mockDomainObject] }; }); @@ -272,17 +273,19 @@ describe('The Annotation API', () => { let comparator; beforeEach(() => { - targets = { - fooTarget: { + targets = [ + { + keyString: 'fooTarget', foo: 42 } - }; - otherTargets = { - fooTarget: { + ]; + otherTargets = [ + { + keyString: 'fooTarget', bar: 42 } - }; - comparator = (t1, t2) => t1.fooTarget.foo === t2.fooTarget.bar; + ]; + comparator = (t1, t2) => t1[0].foo === t2[0].bar; }); it('can add a comparator function', () => { diff --git a/src/api/objects/InMemorySearchProvider.js b/src/api/objects/InMemorySearchProvider.js index 15e66a2e79..0ac546983d 100644 --- a/src/api/objects/InMemorySearchProvider.js +++ b/src/api/objects/InMemorySearchProvider.js @@ -435,7 +435,8 @@ class InMemorySearchProvider { } localIndexAnnotation(objectToIndex, model) { - Object.keys(model.targets).forEach((targetID) => { + model.targets.forEach((target) => { + const targetID = target.keyString; if (!this.localIndexedAnnotationsByDomainObject[targetID]) { this.localIndexedAnnotationsByDomainObject[targetID] = []; } diff --git a/src/api/objects/InMemorySearchWorker.js b/src/api/objects/InMemorySearchWorker.js index 121d3b1d26..38e65222be 100644 --- a/src/api/objects/InMemorySearchWorker.js +++ b/src/api/objects/InMemorySearchWorker.js @@ -57,7 +57,8 @@ }; function indexAnnotation(objectToIndex, model) { - Object.keys(model.targets).forEach((targetID) => { + model.targets.forEach((target) => { + const targetID = target.keyString; if (!indexedAnnotationsByDomainObject[targetID]) { indexedAnnotationsByDomainObject[targetID] = []; } diff --git a/src/plugins/imagery/components/AnnotationsCanvas.vue b/src/plugins/imagery/components/AnnotationsCanvas.vue index c979d80b0d..7d9d160eaa 100644 --- a/src/plugins/imagery/components/AnnotationsCanvas.vue +++ b/src/plugins/imagery/components/AnnotationsCanvas.vue @@ -33,6 +33,8 @@ <script> import Flatbush from 'flatbush'; +import { toRaw } from 'vue'; + const EXISTING_ANNOTATION_STROKE_STYLE = '#D79078'; const EXISTING_ANNOTATION_FILL_STYLE = 'rgba(202, 202, 142, 0.2)'; const SELECTED_ANNOTATION_STROKE_COLOR = '#BD8ECC'; @@ -70,7 +72,9 @@ export default { // create a flatbush index for the annotations const builtAnnotationsIndex = new Flatbush(this.imageryAnnotations.length); this.imageryAnnotations.forEach((annotation) => { - const annotationRectangle = annotation.targets[this.keyString].rectangle; + const annotationRectangle = annotation.targets.find( + (target) => target.keyString === this.keyString + )?.rectangle; const annotationRectangleForPixelDepth = this.transformRectangleToPixelDense(annotationRectangle); const indexNumber = builtAnnotationsIndex.add( @@ -141,20 +145,17 @@ export default { this.prepareExistingAnnotationSelection(incomingSelectedAnnotations); }, prepareExistingAnnotationSelection(annotations) { - const targetDomainObjects = {}; - targetDomainObjects[this.keyString] = this.domainObject; - - const targetDetails = {}; + const targetDetails = []; annotations.forEach((annotation) => { - Object.entries(annotation.targets).forEach(([key, value]) => { - targetDetails[key] = value; + annotation.targets.forEach((target) => { + targetDetails.push(toRaw(target)); }); }); this.selectedAnnotations = annotations; this.drawAnnotations(); return { - targetDomainObjects, + targetDomainObjects: [this.domainObject], targetDetails }; }, @@ -292,9 +293,6 @@ export default { this.dragging = false; this.selectedAnnotations = []; - const targetDomainObjects = {}; - targetDomainObjects[this.keyString] = this.domainObject; - const targetDetails = {}; const rectangleFromCanvas = { x: this.newAnnotationRectangle.x, y: this.newAnnotationRectangle.y, @@ -302,13 +300,16 @@ export default { height: this.newAnnotationRectangle.height }; const rectangleWithoutPixelScale = this.transformRectangleFromPixelDense(rectangleFromCanvas); - targetDetails[this.keyString] = { - rectangle: rectangleWithoutPixelScale, - time: this.image.time - }; + const targetDetails = [ + { + rectangle: rectangleWithoutPixelScale, + time: this.image.time, + keyString: this.keyString + } + ]; this.selectImageAnnotations({ targetDetails, - targetDomainObjects, + targetDomainObjects: [this.domainObject], annotations: [] }); }, @@ -403,9 +404,10 @@ export default { if (annotation._deleted) { return; } - const rectangleForPixelDensity = this.transformRectangleToPixelDense( - annotation.targets[this.keyString].rectangle - ); + const annotationRectangle = annotation.targets.find( + (target) => target.keyString === this.keyString + )?.rectangle; + const rectangleForPixelDensity = this.transformRectangleToPixelDense(annotationRectangle); if (this.isSelectedAnnotation(annotation)) { this.drawRectInCanvas( rectangleForPixelDensity, diff --git a/src/plugins/inspectorViews/annotations/AnnotationsInspectorView.vue b/src/plugins/inspectorViews/annotations/AnnotationsInspectorView.vue index c7b42428d9..d28bb10397 100644 --- a/src/plugins/inspectorViews/annotations/AnnotationsInspectorView.vue +++ b/src/plugins/inspectorViews/annotations/AnnotationsInspectorView.vue @@ -94,10 +94,10 @@ export default { return this?.selection?.[0]?.[0]?.context?.item; }, targetDetails() { - return this?.selection?.[0]?.[0]?.context?.targetDetails ?? {}; + return this?.selection?.[0]?.[0]?.context?.targetDetails ?? []; }, shouldShowTagsEditor() { - const showingTagsEditor = Object.keys(this.targetDetails).length > 0; + const showingTagsEditor = this.targetDetails?.length > 0; if (showingTagsEditor) { return true; @@ -106,7 +106,7 @@ export default { return false; }, targetDomainObjects() { - return this?.selection?.[0]?.[0]?.context?.targetDomainObjects ?? {}; + return this?.selection?.[0]?.[0]?.context?.targetDomainObjects ?? []; }, selectedAnnotations() { return this?.selection?.[0]?.[0]?.context?.annotations; @@ -167,9 +167,8 @@ export default { this.unobserveEntries = {}; this.selection = selection; - const targetKeys = Object.keys(this.targetDomainObjects); - targetKeys.forEach((targetKey) => { - const targetObject = this.targetDomainObjects[targetKey]; + this.targetDomainObjects.forEach((targetObject) => { + const targetKey = targetObject.keyString; this.lastLocalAnnotationCreations[targetKey] = targetObject?.annotationLastCreated ?? 0; if (!this.unobserveEntries[targetKey]) { this.unobserveEntries[targetKey] = this.openmct.objects.observe( diff --git a/src/plugins/inspectorViews/annotations/tags/TagEditor.vue b/src/plugins/inspectorViews/annotations/tags/TagEditor.vue index c434150958..97e71b29e3 100644 --- a/src/plugins/inspectorViews/annotations/tags/TagEditor.vue +++ b/src/plugins/inspectorViews/annotations/tags/TagEditor.vue @@ -69,12 +69,12 @@ export default { default: null }, targets: { - type: Object, + type: Array, required: true, default: null }, targetDomainObjects: { - type: Object, + type: Array, required: true, default: null }, @@ -201,11 +201,8 @@ export default { const contentText = `${this.annotationType} tag`; // need to get raw version of target domain objects for comparisons to work - const rawTargetDomainObjects = {}; - Object.keys(this.targetDomainObjects).forEach((targetDomainObjectKey) => { - rawTargetDomainObjects[targetDomainObjectKey] = toRaw( - this.targetDomainObjects[targetDomainObjectKey] - ); + const rawTargetDomainObjects = this.targetDomainObjects.map((targetDomainObject) => { + return toRaw(targetDomainObject); }); const annotationCreationArguments = { name: contentText, diff --git a/src/plugins/notebook/actions/ExportNotebookAsTextAction.js b/src/plugins/notebook/actions/ExportNotebookAsTextAction.js index fbabe444a7..b359e9914b 100644 --- a/src/plugins/notebook/actions/ExportNotebookAsTextAction.js +++ b/src/plugins/notebook/actions/ExportNotebookAsTextAction.js @@ -33,7 +33,9 @@ export default class ExportNotebookAsTextAction { getTagsForEntry(entry, domainObjectKeyString, annotations) { const foundTags = []; annotations.forEach((annotation) => { - const target = annotation.targets?.[domainObjectKeyString]; + const target = annotation.targets.find( + (annotationTarget) => annotationTarget.keyString === domainObjectKeyString + ); if (target?.entryId === entry.id) { annotation.tags.forEach((tag) => { if (!foundTags.includes(tag)) { diff --git a/src/plugins/notebook/components/Notebook.vue b/src/plugins/notebook/components/Notebook.vue index d56228e922..fb65a2aaf0 100644 --- a/src/plugins/notebook/components/Notebook.vue +++ b/src/plugins/notebook/components/Notebook.vue @@ -395,8 +395,8 @@ export default { ); foundAnnotations.forEach((foundAnnotation) => { - const targetId = Object.keys(foundAnnotation.targets)[0]; - const entryId = foundAnnotation.targets[targetId].entryId; + const target = foundAnnotation.targets?.[0]; + const entryId = target.entryId; if (!this.notebookAnnotations[entryId]) { this.notebookAnnotations[entryId] = []; } diff --git a/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js b/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js index 7c617df38e..c7af753d65 100644 --- a/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js +++ b/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js @@ -51,13 +51,18 @@ async function resolveNotebookTagConflicts(localAnnotation, openmct) { throw new Error("Conflict on annotation's tag has different tags than remote"); } - Object.keys(localClonedAnnotation.targets).forEach((targetKey) => { - if (!remoteMutable.targets[targetKey]) { + localClonedAnnotation.targets.forEach((target) => { + const targetKey = target.keyString; + + const remoteMutableTarget = remoteMutable.targets.find((remoteTarget) => { + return remoteTarget.keyString === targetKey; + }); + if (!remoteMutableTarget) { throw new Error(`Conflict on annotation's target is missing ${targetKey}`); } - - const remoteMutableTarget = remoteMutable.targets[targetKey]; - const localMutableTarget = localClonedAnnotation.targets[targetKey]; + const localMutableTarget = localClonedAnnotation.targets.find((localTarget) => { + return localTarget.keyString === targetKey; + }); if (remoteMutableTarget.entryId !== localMutableTarget.entryId) { throw new Error( diff --git a/src/plugins/notebook/utils/notebook-entries.js b/src/plugins/notebook/utils/notebook-entries.js index daf483783e..0d9bdd4fa5 100644 --- a/src/plugins/notebook/utils/notebook-entries.js +++ b/src/plugins/notebook/utils/notebook-entries.js @@ -66,13 +66,14 @@ export function selectEntry({ onAnnotationChange, notebookAnnotations }) { - const targetDetails = {}; const keyString = openmct.objects.makeKeyString(domainObject.identifier); - targetDetails[keyString] = { - entryId - }; - const targetDomainObjects = {}; - targetDomainObjects[keyString] = domainObject; + const targetDetails = [ + { + entryId, + keyString + } + ]; + const targetDomainObjects = [domainObject]; openmct.selection.select( [ { diff --git a/src/plugins/persistence/couch/CouchObjectProvider.js b/src/plugins/persistence/couch/CouchObjectProvider.js index f4ff3fbe5f..786df222f6 100644 --- a/src/plugins/persistence/couch/CouchObjectProvider.js +++ b/src/plugins/persistence/couch/CouchObjectProvider.js @@ -38,6 +38,7 @@ class CouchObjectProvider { this.openmct = openmct; this.indicator = indicator; this.url = options.url; + this.useDesignDocuments = options.useDesignDocuments; this.namespace = namespace; this.objectQueue = {}; this.observers = {}; @@ -187,7 +188,8 @@ class CouchObjectProvider { #normalize(options) { if (typeof options === 'string') { return { - url: options + url: options, + useDesignDocuments: false }; } @@ -436,6 +438,39 @@ class CouchObjectProvider { return Promise.resolve([]); } + async getObjectsByView({ designDoc, viewName, keysToSearch }, abortSignal) { + const stringifiedKeys = JSON.stringify(keysToSearch); + const url = `${this.url}/_design/${designDoc}/_view/${viewName}?keys=${stringifiedKeys}&include_docs=true`; + let objectModels = []; + + try { + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: abortSignal + }); + + if (!response.ok) { + throw new Error( + `HTTP request failed with status ${response.status} ${response.statusText}` + ); + } + + const result = await response.json(); + const couchRows = result.rows; + couchRows.forEach((couchRow) => { + const couchDoc = couchRow.doc; + const objectModel = this.#getModel(couchDoc); + if (objectModel) { + objectModels.push(objectModel); + } + }); + } catch (error) { + // do nothing + } + return objectModels; + } + async getObjectsByFilter(filter, abortSignal) { let objects = []; diff --git a/src/plugins/persistence/couch/CouchSearchProvider.js b/src/plugins/persistence/couch/CouchSearchProvider.js index 53cb8b9d68..e8341e182a 100644 --- a/src/plugins/persistence/couch/CouchSearchProvider.js +++ b/src/plugins/persistence/couch/CouchSearchProvider.js @@ -37,6 +37,7 @@ class CouchSearchProvider { constructor(couchObjectProvider) { this.couchObjectProvider = couchObjectProvider; this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES; + this.useDesignDocuments = couchObjectProvider.useDesignDocuments; this.supportedSearchTypes = [ this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, @@ -102,6 +103,25 @@ class CouchSearchProvider { } #bulkAnnotationSearch(batchIdsToSearch) { + if (!batchIdsToSearch?.length) { + // nothing to search + return; + } + + let lastAbortSignal = batchIdsToSearch[batchIdsToSearch.length - 1].abortSignal; + + if (this.useDesignDocuments) { + const keysToSearch = batchIdsToSearch.map(({ keyString }) => keyString); + return this.couchObjectProvider.getObjectsByView( + { + designDoc: 'annotation_keystring_index', + viewName: 'by_keystring', + keysToSearch + }, + lastAbortSignal + ); + } + const filter = { selector: { $and: [ @@ -111,25 +131,20 @@ class CouchSearchProvider { } }, { - $or: [] + 'model.targets': { + $elemMatch: { + keyString: { + $in: [] + } + } + } } ] } }; - let lastAbortSignal = null; // TODO: should remove duplicates from batchIds batchIdsToSearch.forEach(({ keyString, abortSignal }) => { - const modelFilter = { - model: { - targets: {} - } - }; - modelFilter.model.targets[keyString] = { - $exists: true - }; - - filter.selector.$and[1].$or.push(modelFilter); - lastAbortSignal = abortSignal; + filter.selector.$and[1]['model.targets'].$elemMatch.keyString.$in.push(keyString); }); return this.couchObjectProvider.getObjectsByFilter(filter, lastAbortSignal); @@ -142,11 +157,7 @@ class CouchSearchProvider { } const returnedData = await this.#bulkPromise; - // only return data that matches the keystring - const filteredByKeyString = returnedData.filter((foundAnnotation) => { - return foundAnnotation.targets[keyString]; - }); - return filteredByKeyString; + return returnedData; } searchForTags(tagsArray, abortSignal) { @@ -154,28 +165,33 @@ class CouchSearchProvider { return []; } + if (this.useDesignDocuments) { + return this.couchObjectProvider.getObjectsByView( + { designDoc: 'annotation_tags_index', viewName: 'by_tags', keysToSearch: tagsArray }, + abortSignal + ); + } + const filter = { selector: { $and: [ { - 'model.tags': { - $elemMatch: { - $or: [] - } + 'model.type': { + $eq: 'annotation' } }, { - 'model.type': { - $eq: 'annotation' + 'model.tags': { + $elemMatch: { + $in: [] + } } } ] } }; tagsArray.forEach((tag) => { - filter.selector.$and[0]['model.tags'].$elemMatch.$or.push({ - $eq: `${tag}` - }); + filter.selector.$and[1]['model.tags'].$elemMatch.$in.push(tag); }); return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); diff --git a/src/plugins/persistence/couch/README.md b/src/plugins/persistence/couch/README.md index ef19f513b6..be09065a17 100644 --- a/src/plugins/persistence/couch/README.md +++ b/src/plugins/persistence/couch/README.md @@ -141,7 +141,7 @@ sh ./src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.s Add a line to install the CouchDB plugin for Open MCT: ```js - openmct.install(openmct.plugins.CouchDB("http://localhost:5984/openmct")); + openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: false})); ``` # Validating a successful Installation @@ -151,3 +151,63 @@ sh ./src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.s 3. Navigate to: <http://127.0.0.1:5984/_utils/#database/openmct/_all_docs> 4. Look at the 'JSON' tab and ensure you can see the specific object you created above. 5. All done! 🏆 + +# Search Performance + +For large Open MCT installations, it may be helpful to add additional CouchDB capabilities to bear to improve performance. + +## Indexing +Indexing the `model.type` field in CouchDB can benefit the performance of queries significantly, particularly if there are a large number of documents in the database. An index can accelerate annotation searches by reducing the number of documents that the database needs to examine. + +To create an index for `model.type`, you can use the following payload: + +```json +{ + "index": { + "fields": ["model.type", "model.tags"] + }, + "name": "type_tags_index", + "type": "json" +} +``` + +This instructs CouchDB to create an index on the `model.type` field and the `model.tags` field. Once this index is created, queries that include a selector on `model.type` and `model.tags` (like when searching for tags) can use this index to retrieve results faster. + +You can find more detailed information about indexing in CouchDB in the [official documentation](https://docs.couchdb.org/en/stable/api/database/find.html#db-index). + +## Design Documents + +We can also add a design document for retrieving domain objects for specific tags: + +```json +{ + "_id": "_design/annotation_tags_index", + "views": { + "by_tags": { + "map": "function (doc) { if (doc.model && doc.model.type === 'annotation' && doc.model.tags) { doc.model.tags.forEach(function (tag) { emit(tag, doc._id); }); } }" + } + } +} +``` +and can be retrieved by issuing a `GET` to http://localhost:5984/openmct/_design/annotation_tags_index/_view/by_tags?keys=["TAG_ID_TO_SEARCH_FOR"]&include_docs=true +where `TAG_ID_TO_SEARCH_FOR` is the tag UUID we're looking for. + +and for targets: +```javascript +{ + "_id": "_design/annotation_keystring_index", + "views": { + "by_keystring": { + "map": "function (doc) { if (doc.model && doc.model.type === 'annotation' && doc.model.targets) { doc.model.targets.forEach(function(target) { if(target.keyString) { emit(target.keyString, doc._id); } }); } }" + } + } +} +``` +and can be retrieved by issuing a `GET` to http://localhost:5984/openmct/_design/annotation_keystring_index/_view/by_keystring?keys=["KEY_STRING_TO_SEARCH_FOR"]&include_docs=true +where `KEY_STRING_TO_SEARCH_FOR` is the UUID we're looking for. + +To enable them in Open MCT, we need to configure the plugin `useDesignDocuments` like so: + + ```js + openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: true})); + ``` diff --git a/src/plugins/plot/MctPlot.vue b/src/plugins/plot/MctPlot.vue index 8ecebc3f5e..296cbf77b6 100644 --- a/src/plugins/plot/MctPlot.vue +++ b/src/plugins/plot/MctPlot.vue @@ -476,7 +476,7 @@ export default { // the annotations this.freeze(); // just use first annotation - const boundingBoxes = Object.values(selectedAnnotations[0].targets); + const boundingBoxes = selectedAnnotations[0].targets; let minX = Number.MAX_SAFE_INTEGER; let minY = Number.MAX_SAFE_INTEGER; let maxX = Number.MIN_SAFE_INTEGER; @@ -863,8 +863,8 @@ export default { marqueeAnnotations(annotationsToSelect) { annotationsToSelect.forEach((annotationToSelect) => { - Object.keys(annotationToSelect.targets).forEach((targetKeyString) => { - const target = annotationToSelect.targets[targetKeyString]; + annotationToSelect.targets.forEach((target) => { + const targetKeyString = target.keyString; const series = this.seriesModels.find( (seriesModel) => seriesModel.keyString === targetKeyString ); @@ -912,17 +912,14 @@ export default { }, prepareExistingAnnotationSelection(annotations) { - const targetDomainObjects = {}; - this.config.series.models.forEach((series) => { - targetDomainObjects[series.keyString] = series.domainObject; + const targetDomainObjects = this.config.series.models.map((series) => { + return series.domainObject; }); - const targetDetails = {}; + const targetDetails = []; const uniqueBoundsAnnotations = []; annotations.forEach((annotation) => { - Object.entries(annotation.targets).forEach(([key, value]) => { - targetDetails[key] = value; - }); + targetDetails.push(annotation.targets); const boundingBoxAlreadyAdded = uniqueBoundsAnnotations.some((existingAnnotation) => { const existingBoundingBox = Object.values(existingAnnotation.targets)[0]; @@ -1332,17 +1329,17 @@ export default { document.body.addEventListener('click', this.cancelSelection); }, selectNewPlotAnnotations(boundingBoxPerYAxis, pointsInBoxBySeries, event) { - let targetDomainObjects = {}; - let targetDetails = {}; + let targetDomainObjects = []; + let targetDetails = []; let annotations = []; Object.keys(pointsInBoxBySeries).forEach((seriesKey) => { const seriesModel = this.getSeries(seriesKey); const boundingBoxWithId = boundingBoxPerYAxis.find( (box) => box.id === seriesModel.get('yAxisId') ); - targetDetails[seriesKey] = boundingBoxWithId?.boundingBox; + targetDetails.push({ ...boundingBoxWithId?.boundingBox, keyString: seriesKey }); - targetDomainObjects[seriesKey] = seriesModel.domainObject; + targetDomainObjects.push(seriesModel.domainObject); }); this.selectPlotAnnotations({ targetDetails, @@ -1354,8 +1351,8 @@ export default { const annotationsBySeries = {}; rawAnnotations.forEach((rawAnnotation) => { if (rawAnnotation.targets) { - const targetValues = Object.values(rawAnnotation.targets); - const targetKeys = Object.keys(rawAnnotation.targets); + const targetValues = rawAnnotation.targets; + const targetKeys = rawAnnotation.targets.map((target) => target.keyString); if (targetValues && targetValues.length) { let boundingBoxPerYAxis = []; targetValues.forEach((boundingBox, index) => { diff --git a/src/ui/layout/search/AnnotationSearchResult.vue b/src/ui/layout/search/AnnotationSearchResult.vue index 64527facbf..b506daf4f5 100644 --- a/src/ui/layout/search/AnnotationSearchResult.vue +++ b/src/ui/layout/search/AnnotationSearchResult.vue @@ -158,24 +158,14 @@ export default { }, fireAnnotationSelection() { this.openmct.selection.off('change', this.fireAnnotationSelection); - - const targetDetails = {}; - const targetDomainObjects = {}; - Object.entries(this.result.targets).forEach(([key, value]) => { - targetDetails[key] = value; - }); - this.result.targetModels.forEach((targetModel) => { - const keyString = this.openmct.objects.makeKeyString(targetModel.identifier); - targetDomainObjects[keyString] = targetModel; - }); const selection = [ { element: this.$el, context: { item: this.result.targetModels[0], type: 'annotation-search-result', - targetDetails, - targetDomainObjects, + targetDetails: this.result.targets, + targetDomainObjects: this.result.targetModels, annotations: [this.result], annotationType: this.result.annotationType, onAnnotationChange: () => {} diff --git a/src/ui/layout/search/GrandSearchSpec.js b/src/ui/layout/search/GrandSearchSpec.js index 42d583d010..91ed9dd809 100644 --- a/src/ui/layout/search/GrandSearchSpec.js +++ b/src/ui/layout/search/GrandSearchSpec.js @@ -126,11 +126,12 @@ describe('GrandSearch', () => { key: 'anAnnotationKey', namespace: 'fooNameSpace' }, - targets: { - 'fooNameSpace:some-object': { + targets: [ + { + keyString: 'fooNameSpace:some-object', entryId: 'fooBarEntry' } - } + ] }; mockNewObject = { type: 'folder',