diff --git a/.cspell.json b/.cspell.json index 31df39a88e..bc37d13deb 100644 --- a/.cspell.json +++ b/.cspell.json @@ -493,6 +493,7 @@ "WCAG", "stackedplot", "Andale", + "unnnormalized", "checksnapshots", "specced" ], diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js index a5ee4bc6e6..750784b98c 100644 --- a/src/api/objects/ObjectAPI.js +++ b/src/api/objects/ObjectAPI.js @@ -354,6 +354,9 @@ export default class ObjectAPI { isPersistable(idOrKeyString) { let identifier = utils.parseKeyString(idOrKeyString); let provider = this.getProvider(identifier); + if (provider?.isReadOnly) { + return !provider.isReadOnly(); + } return provider !== undefined && provider.create !== undefined && provider.update !== undefined; } diff --git a/src/plugins/CouchDBSearchFolder/pluginSpec.js b/src/plugins/CouchDBSearchFolder/pluginSpec.js index a41bd080bb..21d2ee9339 100644 --- a/src/plugins/CouchDBSearchFolder/pluginSpec.js +++ b/src/plugins/CouchDBSearchFolder/pluginSpec.js @@ -38,7 +38,6 @@ describe('the plugin', function () { let couchPlugin = openmct.plugins.CouchDB(testPath); openmct.install(couchPlugin); - openmct.install( new CouchDBSearchFolderPlugin('CouchDB Documents', couchPlugin, { selector: { diff --git a/src/plugins/localStorage/LocalStorageObjectProvider.js b/src/plugins/localStorage/LocalStorageObjectProvider.js index 4f3fc8464c..ca6f60dd8b 100644 --- a/src/plugins/localStorage/LocalStorageObjectProvider.js +++ b/src/plugins/localStorage/LocalStorageObjectProvider.js @@ -74,6 +74,10 @@ export default class LocalStorageObjectProvider { this.localStorage.setItem(this.spaceKey, JSON.stringify(space)); } + isReadOnly() { + return false; + } + /** * @private */ diff --git a/src/plugins/myItems/README.md b/src/plugins/myItems/README.md index df61bacf6c..e3582a3369 100644 --- a/src/plugins/myItems/README.md +++ b/src/plugins/myItems/README.md @@ -1,8 +1,26 @@ # My Items plugin -Defines top-level folder named "My Items" to store user-created items. Enabled by default, this can be disabled in a -read-only deployment with no user-editable objects. +Defines top-level folder named "My Items" to store user-created items. Enabled by default, this can be disabled in a read-only deployment with no user-editable objects. ## Installation ```js openmct.install(openmct.plugins.MyItems()); +``` + +## Options +When installing, the plugin can take several options: + +- `name`: The label of the root object. Defaults to "My Items" + - Example: `'Apple Items'` + +- `namespace`: The namespace to create the root object in. Defaults to the empty string `''` + - Example: `'apple-namespace'` + +- `priority`: The optional priority to install this plugin. Defaults to `openmct.priority.LOW` + - Example: `'openmct.priority.LOW'` + +E.g., to install with a custom name and namespace, you could use: + + +```js +openmct.install(openmct.plugins.MyItems('Apple Items', 'apple-namespace')); ``` \ No newline at end of file diff --git a/src/plugins/myItems/myItemsInterceptor.js b/src/plugins/myItems/myItemsInterceptor.js index 49b9a4eb94..d1efca1ed6 100644 --- a/src/plugins/myItems/myItemsInterceptor.js +++ b/src/plugins/myItems/myItemsInterceptor.js @@ -20,9 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -import { MY_ITEMS_KEY } from './createMyItemsIdentifier.js'; - -function myItemsInterceptor(openmct, identifierObject, name) { +function myItemsInterceptor({ openmct, identifierObject, name }) { const myItemsModel = { identifier: identifierObject, name, @@ -33,7 +31,10 @@ function myItemsInterceptor(openmct, identifierObject, name) { return { appliesTo: (identifier) => { - return identifier.key === MY_ITEMS_KEY; + return ( + identifier.key === myItemsModel.identifier.key && + identifier.namespace === myItemsModel.identifier.namespace + ); }, invoke: (identifier, object) => { if (!object || openmct.objects.isMissing(object)) { diff --git a/src/plugins/myItems/plugin.js b/src/plugins/myItems/plugin.js index 9bb59e8466..44bed8f23e 100644 --- a/src/plugins/myItems/plugin.js +++ b/src/plugins/myItems/plugin.js @@ -31,13 +31,13 @@ export default function MyItemsPlugin( priority = undefined ) { return function install(openmct) { - const identifier = createMyItemsIdentifier(namespace); + const identifierObject = createMyItemsIdentifier(namespace); if (priority === undefined) { priority = openmct.priority.LOW; } - openmct.objects.addGetInterceptor(myItemsInterceptor(openmct, identifier, name)); - openmct.objects.addRoot(identifier, priority); + openmct.objects.addGetInterceptor(myItemsInterceptor({ openmct, identifierObject, name })); + openmct.objects.addRoot(identifierObject, priority); }; } diff --git a/src/plugins/persistence/couch/CouchChangesFeed.js b/src/plugins/persistence/couch/CouchChangesFeed.js index d47429ba28..6150c57d30 100644 --- a/src/plugins/persistence/couch/CouchChangesFeed.js +++ b/src/plugins/persistence/couch/CouchChangesFeed.js @@ -80,7 +80,7 @@ keepAliveTimer = setTimeout(self.listenForChanges, keepAliveTime); if (!couchEventSource || couchEventSource.readyState === EventSource.CLOSED) { - console.debug('⇿ Opening CouchDB change feed connection ⇿'); + console.debug(`⇿ Opening CouchDB change feed connection for ${changesFeedUrl} ⇿`); couchEventSource = new EventSource(changesFeedUrl); couchEventSource.onerror = self.onerror; couchEventSource.onopen = self.onopen; @@ -88,7 +88,7 @@ // start listening for events couchEventSource.addEventListener('message', self.onCouchMessage); connected = true; - console.debug('⇿ Opened connection ⇿'); + console.debug(`⇿ Opened connection to ${changesFeedUrl} ⇿`); } }; diff --git a/src/plugins/persistence/couch/CouchObjectProvider.js b/src/plugins/persistence/couch/CouchObjectProvider.js index 38e1ee4d5e..ab1859f092 100644 --- a/src/plugins/persistence/couch/CouchObjectProvider.js +++ b/src/plugins/persistence/couch/CouchObjectProvider.js @@ -33,13 +33,13 @@ const HEARTBEAT = 50000; const ALL_DOCS = '_all_docs?include_docs=true'; class CouchObjectProvider { - constructor(openmct, options, namespace, indicator) { - options = this.#normalize(options); + constructor({ openmct, databaseConfiguration, couchStatusIndicator }) { this.openmct = openmct; - this.indicator = indicator; - this.url = options.url; - this.useDesignDocuments = options.useDesignDocuments; - this.namespace = namespace; + this.indicator = couchStatusIndicator; + this.url = databaseConfiguration.url; + this.readOnly = databaseConfiguration.readOnly; + this.useDesignDocuments = databaseConfiguration.useDesignDocuments; + this.namespace = databaseConfiguration.namespace; this.objectQueue = {}; this.observers = {}; this.batchIds = []; @@ -47,6 +47,7 @@ class CouchObjectProvider { this.onEventError = this.onEventError.bind(this); this.flushPersistenceQueue = _.debounce(this.flushPersistenceQueue.bind(this)); this.persistenceQueue = []; + this.rootObject = null; } /** @@ -59,7 +60,10 @@ class CouchObjectProvider { // eslint-disable-next-line no-undef const sharedWorkerURL = `${this.openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}couchDBChangesFeed.js`; - sharedWorker = new SharedWorker(sharedWorkerURL, 'CouchDB SSE Shared Worker'); + sharedWorker = new SharedWorker( + sharedWorkerURL, + `CouchDB SSE Shared Worker for ${this.namespace}` + ); sharedWorker.port.onmessage = provider.onSharedWorkerMessage.bind(this); sharedWorker.port.onmessageerror = provider.onSharedWorkerMessageError.bind(this); sharedWorker.port.start(); @@ -93,7 +97,7 @@ class CouchObjectProvider { this.changesFeedSharedWorkerConnectionId = event.data.connectionId; } else if (event.data.type === 'state') { const state = this.#messageToIndicatorState(event.data.state); - this.indicator.setIndicatorToState(state); + this.indicator?.setIndicatorToState(state); } else { let objectChanges = event.data.objectChanges; const objectIdentifier = { @@ -184,16 +188,8 @@ class CouchObjectProvider { return state; } - //backwards compatibility, options used to be a url. Now it's an object - #normalize(options) { - if (typeof options === 'string') { - return { - url: options, - useDesignDocuments: false - }; - } - - return options; + isReadOnly() { + return this.readOnly; } async request(subPath, method, body, signal) { @@ -233,7 +229,7 @@ class CouchObjectProvider { // Network error, CouchDB unreachable. if (response === null) { - this.indicator.setIndicatorToState(DISCONNECTED); + this.indicator?.setIndicatorToState(DISCONNECTED); console.error(error.message); throw new Error(`CouchDB Error - No response"`); @@ -256,7 +252,7 @@ class CouchObjectProvider { * @private */ #handleResponseCode(status, json, fetchOptions) { - this.indicator.setIndicatorToState(this.#statusCodeToIndicatorState(status)); + this.indicator?.setIndicatorToState(this.#statusCodeToIndicatorState(status)); if (status === CouchObjectProvider.HTTP_CONFLICT) { const objectName = JSON.parse(fetchOptions.body)?.model?.name; throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`); @@ -684,7 +680,7 @@ class CouchObjectProvider { } const indicatorState = this.#messageToIndicatorState(message); - this.indicator.setIndicatorToState(indicatorState); + this.indicator?.setIndicatorToState(indicatorState); } /** diff --git a/src/plugins/persistence/couch/CouchSearchProvider.js b/src/plugins/persistence/couch/CouchSearchProvider.js index 478f25dd9e..7c0740eff3 100644 --- a/src/plugins/persistence/couch/CouchSearchProvider.js +++ b/src/plugins/persistence/couch/CouchSearchProvider.js @@ -51,6 +51,10 @@ class CouchSearchProvider { return this.supportedSearchTypes.includes(searchType); } + isReadOnly() { + return true; + } + search(query, abortSignal, searchType) { if (searchType === this.searchTypes.OBJECTS) { return this.searchForObjects(query, abortSignal); diff --git a/src/plugins/persistence/couch/README.md b/src/plugins/persistence/couch/README.md index c01c72d42e..29421d45ff 100644 --- a/src/plugins/persistence/couch/README.md +++ b/src/plugins/persistence/couch/README.md @@ -153,13 +153,63 @@ 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({url: "http://localhost:5984/openmct", useDesignDocuments: false})); + openmct.install( + openmct.plugins.CouchDB({ + databases: [ + { + url: 'http://localhost:5984/openmct', + namespace: '', + additionalNamespaces: [], + readOnly: false, + useDesignDocuments: false, + indicator: true + } + ] + }) + ); ``` +### Configuration Options for OpenMCT + +When installing the CouchDB plugin for OpenMCT, you can specify a list of databases with configuration options for each. Here's a breakdown of the available options for each database: + +- `url`: The URL to the CouchDB instance, specifying the protocol, hostname, and port as needed. + - Example: `'http://localhost:5984/openmct'` + +- `namespace`: The namespace associated with this database. + - Example: `'openmct-sandbox'` + +- `additionalNamespaces`: Other namespaces that this plugin should respond to requests for. + - Example: `['apple-namespace', 'pear-namespace']` + +- `readOnly`: A boolean indicating whether the database should be treated as read-only. If set to `true`, OpenMCT will not attempt to write to this database. + - Example: `false` + +- `useDesignDocuments`: Indicates whether design documents should be used to speed up annotation search. + - Example: `false` + +- `indicator`: A boolean to specify whether an indicator should show the status of this CouchDB connection in the OpenMCT interface. + - Example: `true` + +Note: If using the `exampleTags` plugin with non-blank namespaces, you'll need to configure it point to a writable database. For example: + +```js +openmct.install( + openmct.plugins.example.ExampleTags({ namespaceToSaveAnnotations: 'openmct-sandbox' }) + ); +``` + +Note: If using the `MyItems` plugin, be sure to configure a root for each writable namespace. E.g., if you have two namespaces called `apple-namespace` and `pear-namespace`: +```js + openmct.install(openmct.plugins.MyItems('Apple Items', 'apple-namespace')); + openmct.install(openmct.plugins.MyItems('Pear Items', 'pear-namespace')); +``` +This will create a root object with the id of `mine` in both namespaces upon load if not already created. + # Validating a successful Installation 1. Start Open MCT by running `npm start` in the `openmct` path. -2. Navigate to and create a random object in Open MCT (e.g., a 'Clock') and save. You may get an error saying that the object failed to persist - this is a known error that you can ignore, and will only happen the first time you save - just try again. +2. Navigate to and create a random object in Open MCT (e.g., a 'Clock') and save. 3. Navigate to: 4. Look at the 'JSON' tab and ensure you can see the specific object you created above. 5. All done! 🏆 @@ -242,4 +292,4 @@ To enable them in Open MCT, we need to configure the plugin `useDesignDocuments` ```js openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: true})); - ``` \ No newline at end of file + ``` diff --git a/src/plugins/persistence/couch/plugin.js b/src/plugins/persistence/couch/plugin.js index 19b8a9b625..a1c7f6c661 100644 --- a/src/plugins/persistence/couch/plugin.js +++ b/src/plugins/persistence/couch/plugin.js @@ -24,29 +24,81 @@ import CouchObjectProvider from './CouchObjectProvider.js'; import CouchSearchProvider from './CouchSearchProvider.js'; import CouchStatusIndicator from './CouchStatusIndicator.js'; -const NAMESPACE = ''; +const DEFAULT_NAMESPACE = ''; const LEGACY_SPACE = 'mct'; -const COUCH_SEARCH_ONLY_NAMESPACE = `COUCH_SEARCH_${Date.now()}`; export default function CouchPlugin(options) { - return function install(openmct) { - const simpleIndicator = openmct.indicators.simpleIndicator(); - openmct.indicators.add(simpleIndicator); - const couchStatusIndicator = new CouchStatusIndicator(simpleIndicator); - install.couchProvider = new CouchObjectProvider( - openmct, - options, - NAMESPACE, - couchStatusIndicator - ); + function normalizeOptions(unnnormalizedOptions) { + const normalizedOptions = {}; + if (typeof unnnormalizedOptions === 'string') { + normalizedOptions.databases = [ + { + url: options, + namespace: DEFAULT_NAMESPACE, + additionalNamespaces: [LEGACY_SPACE], + readOnly: false, + useDesignDocuments: false, + indicator: true + } + ]; + } else if (!unnnormalizedOptions.databases) { + normalizedOptions.databases = [ + { + url: unnnormalizedOptions.url, + namespace: DEFAULT_NAMESPACE, + additionalNamespaces: [LEGACY_SPACE], + readOnly: false, + useDesignDocuments: unnnormalizedOptions.useDesignDocuments, + indicator: true + } + ]; + } else { + normalizedOptions.databases = unnnormalizedOptions.databases; + } - // Unfortunately, for historical reasons, Couch DB produces objects with a mix of namespaces (alternately "mct", and "") - // Installing the same provider under both namespaces means that it can respond to object gets for both namespaces. - openmct.objects.addProvider(LEGACY_SPACE, install.couchProvider); - openmct.objects.addProvider(NAMESPACE, install.couchProvider); - openmct.objects.addProvider( - COUCH_SEARCH_ONLY_NAMESPACE, - new CouchSearchProvider(install.couchProvider) - ); + // final sanity check, ensure we have all options + normalizedOptions.databases.forEach((databaseConfiguration) => { + if (!databaseConfiguration.url) { + throw new Error( + `🛑 CouchDB plugin requires a url option. Please check the configuration for namespace ${databaseConfiguration.namespace}` + ); + } else if (databaseConfiguration.namespace === undefined) { + // note we can't check for just !databaseConfiguration.namespace because it could be an empty string + throw new Error( + `🛑 CouchDB plugin requires a namespace option. Please check the configuration for url ${databaseConfiguration.url}` + ); + } + }); + + return normalizedOptions; + } + + return function install(openmct) { + const normalizedOptions = normalizeOptions(options); + normalizedOptions.databases.forEach((databaseConfiguration) => { + let couchStatusIndicator; + if (databaseConfiguration.indicator) { + const simpleIndicator = openmct.indicators.simpleIndicator(); + openmct.indicators.add(simpleIndicator); + couchStatusIndicator = new CouchStatusIndicator(simpleIndicator); + } + // the provider is added to the install function to expose couchProvider to unit tests + install.couchProvider = new CouchObjectProvider({ + openmct, + databaseConfiguration, + couchStatusIndicator + }); + openmct.objects.addProvider(databaseConfiguration.namespace, install.couchProvider); + databaseConfiguration.additionalNamespaces?.forEach((additionalNamespace) => { + openmct.objects.addProvider(additionalNamespace, install.couchProvider); + }); + + // need one search provider for whole couch database + const searchOnlyNamespace = `COUCH_SEARCH_${databaseConfiguration.namespace}${Date.now()}`; + openmct.objects.addProvider( + searchOnlyNamespace, + new CouchSearchProvider(install.couchProvider) + ); + }); }; }