diff --git a/.cspell.json b/.cspell.json index 3ba2caaac66..f5829b4e354 100644 --- a/.cspell.json +++ b/.cspell.json @@ -483,7 +483,8 @@ "countup", "darkmatter", "Undeletes", - "SSSZ" + "SSSZ", + "LOCF" ], "dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"], "ignorePaths": [ diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6607a1c5530..73b902a78e7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -14,7 +14,8 @@ const config = { __OPENMCT_VERSION__: 'readonly', __OPENMCT_BUILD_DATE__: 'readonly', __OPENMCT_REVISION__: 'readonly', - __OPENMCT_BUILD_BRANCH__: 'readonly' + __OPENMCT_BUILD_BRANCH__: 'readonly', + __OPENMCT_ROOT_RELATIVE__: 'readonly' }, plugins: ['prettier', 'unicorn', 'simple-import-sort'], extends: [ diff --git a/.webpack/webpack.common.mjs b/.webpack/webpack.common.mjs index 7290ce999e5..8a745cb4640 100644 --- a/.webpack/webpack.common.mjs +++ b/.webpack/webpack.common.mjs @@ -48,6 +48,7 @@ const config = { generatorWorker: './example/generator/generatorWorker.js', couchDBChangesFeed: './src/plugins/persistence/couch/CouchChangesFeed.js', inMemorySearchWorker: './src/api/objects/InMemorySearchWorker.js', + compsMathWorker: './src/plugins/comps/CompsMathWorker.js', espressoTheme: './src/plugins/themes/espresso-theme.scss', snowTheme: './src/plugins/themes/snow-theme.scss', darkmatterTheme: './src/plugins/themes/darkmatter-theme.scss' @@ -89,7 +90,8 @@ const config = { __OPENMCT_REVISION__: `'${gitRevision}'`, __OPENMCT_BUILD_BRANCH__: `'${gitBranch}'`, __VUE_OPTIONS_API__: true, // enable/disable Options API support, default: true - __VUE_PROD_DEVTOOLS__: false // enable/disable devtools support in production, default: false + __VUE_PROD_DEVTOOLS__: false, // enable/disable devtools support in production, default: false + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // enable/disable hydration mismatch details in production, default: false }), new VueLoaderPlugin(), new CopyWebpackPlugin({ diff --git a/e2e/tests/functional/plugins/comps/comps.e2e.spec.js b/e2e/tests/functional/plugins/comps/comps.e2e.spec.js new file mode 100644 index 00000000000..72ce7d86e90 --- /dev/null +++ b/e2e/tests/functional/plugins/comps/comps.e2e.spec.js @@ -0,0 +1,111 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +import { + createDomainObjectWithDefaults, + createExampleTelemetryObject, + setRealTimeMode +} from '../../../../appActions.js'; +import { expect, test } from '../../../../pluginFixtures.js'; + +test.describe('Comps', () => { + test.use({ failOnConsoleError: false }); + test.beforeEach(async ({ page }) => { + // Open a browser, navigate to the main page, and wait until all networkevents to resolve + await page.goto('./', { waitUntil: 'domcontentloaded' }); + }); + + test('Basic Functionality Works', async ({ page, openmctConfig }) => { + const folder = await createDomainObjectWithDefaults(page, { + type: 'Folder' + }); + + // Create the comps with defaults + const comp = await createDomainObjectWithDefaults(page, { + type: 'Derived Telemetry', + parent: folder.uuid + }); + + const telemetryObject = await createExampleTelemetryObject(page, comp.uuid); + + // Check that expressions can be edited + await page.goto(comp.url); + await page.getByLabel('Edit Object').click(); + await page.getByPlaceholder('Enter an expression').fill('a*2'); + await page.getByText('Current Output').click(); + await expect(page.getByText('Expression valid')).toBeVisible(); + + // Check that expressions are marked invalid + await page.getByLabel('Reference Name Input for a').fill('b'); + await page.getByText('Current Output').click(); + await expect(page.getByText('Invalid: Undefined symbol a')).toBeVisible(); + + // Check that test data works + await page.getByPlaceholder('Enter an expression').fill('b*2'); + await page.getByLabel('Reference Test Value for b').fill('5'); + await page.getByLabel('Apply Test Data').click(); + let testValue = await page.getByLabel('Current Output Value').textContent(); + expect(testValue).toBe('10'); + + // Check that real data works + await page.getByLabel('Apply Test Data').click(); + await setRealTimeMode(page); + testValue = await page.getByLabel('Current Output Value').textContent(); + expect(testValue).not.toBe('10'); + // should be a number + expect(parseFloat(testValue)).not.toBeNaN(); + + // Check that object path is correct + const { myItemsFolderName } = openmctConfig; + let objectPath = await page.getByLabel(`${telemetryObject.name} Object Path`).textContent(); + const expectedObjectPath = `/${myItemsFolderName}/${folder.name}/${comp.name}/${telemetryObject.name}`; + expect(objectPath).toBe(expectedObjectPath); + + // Check that the comps are saved + await page.getByLabel('Save').click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + const expression = await page.getByLabel('Expression', { exact: true }).textContent(); + expect(expression).toBe('b*2'); + + // Check that object path is still correct after save + objectPath = await page.getByLabel(`${telemetryObject.name} Object Path`).textContent(); + expect(objectPath).toBe(expectedObjectPath); + + // Check that comps work after being saved + testValue = await page.getByLabel('Current Output Value').textContent(); + expect(testValue).not.toBe('10'); + // should be a number + expect(parseFloat(testValue)).not.toBeNaN(); + + // Check that output format can be changed + await page.getByLabel('Edit Object').click(); + await page.getByRole('tab', { name: 'Config' }).click(); + await page.getByLabel('Output Format').click(); + await page.getByLabel('Output Format').fill('%d'); + await page.getByRole('tab', { name: 'Config' }).click(); + // Ensure we only have one digit + await expect(page.getByLabel('Current Output Value')).toHaveText(/^-1$|^0$|^1$/); + // And that it persists post save + await page.getByLabel('Save').click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + await expect(page.getByLabel('Current Output Value')).toHaveText(/^-1$|^0$|^1$/); + }); +}); diff --git a/package-lock.json b/package-lock.json index 7dc0829bb93..ab90e82caab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "location-bar": "3.0.1", "lodash": "4.17.21", "marked": "12.0.0", + "mathjs": "13.1.1", "mini-css-extract-plugin": "2.7.6", "moment": "2.30.1", "moment-duration-format": "2.3.2", @@ -643,6 +644,18 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", @@ -3088,6 +3101,19 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/complex.js": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.1.1.tgz", + "integrity": "sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4033,6 +4059,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4483,6 +4515,12 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "dev": true + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -5817,6 +5855,19 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -7063,6 +7114,12 @@ "integrity": "sha512-UrzO3fL7nnxlQXlvTynNAenL+21oUQRlzqQFsA2U11ryb4+NLOCOePZ70PTojEaUKhiFugh7dG0Q+I58xlPdWg==", "dev": true }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -7708,6 +7765,29 @@ "node": ">= 18" } }, + "node_modules/mathjs": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-13.1.1.tgz", + "integrity": "sha512-duaSAy7m4F+QtP1Dyv8MX2XuxcqpNDDlGly0SdVTCqpAmwdOFWilDdQKbLdo9RfD6IDNMOdo9tIsEaTXkconlQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.25.4", + "complex.js": "^2.1.1", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^4.3.7", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -9491,6 +9571,12 @@ "node": ">= 0.10" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, "node_modules/regex-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", @@ -9847,6 +9933,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "dev": true + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -10833,6 +10925,15 @@ "node": ">= 0.6" } }, + "node_modules/typed-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", + "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "dev": true, + "engines": { + "node": ">= 18" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", diff --git a/package.json b/package.json index 6e96eace12c..be1e65a21bd 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "location-bar": "3.0.1", "lodash": "4.17.21", "marked": "12.0.0", + "mathjs": "13.1.1", "mini-css-extract-plugin": "2.7.6", "moment": "2.30.1", "moment-duration-format": "2.3.2", diff --git a/src/MCT.js b/src/MCT.js index 6c16c3e8c2e..4c875263e2a 100644 --- a/src/MCT.js +++ b/src/MCT.js @@ -306,6 +306,7 @@ export class MCT extends EventEmitter { this.install(this.plugins.UserIndicator()); this.install(this.plugins.Gauge()); this.install(this.plugins.InspectorViews()); + this.install(this.plugins.Comps()); } /** * Set path to where assets are hosted. This should be the path to main.js. diff --git a/src/api/telemetry/TelemetryAPI.js b/src/api/telemetry/TelemetryAPI.js index 4268e64c36d..086337d0481 100644 --- a/src/api/telemetry/TelemetryAPI.js +++ b/src/api/telemetry/TelemetryAPI.js @@ -678,6 +678,15 @@ export default class TelemetryAPI { return this.metadataCache.get(domainObject); } + /** + * Remove a domain object from the telemetry metadata cache. + * @param {import('openmct').DomainObject} domainObject + */ + + removeMetadataFromCache(domainObject) { + this.metadataCache.delete(domainObject); + } + /** * Get a value formatter for a given valueMetadata. * diff --git a/src/api/telemetry/TelemetryCollection.js b/src/api/telemetry/TelemetryCollection.js index 602eb5ce878..ed1463933eb 100644 --- a/src/api/telemetry/TelemetryCollection.js +++ b/src/api/telemetry/TelemetryCollection.js @@ -86,14 +86,23 @@ export default class TelemetryCollection extends EventEmitter { } this._setTimeSystem(this.options.timeContext.getTimeSystem()); this.lastBounds = this.options.timeContext.getBounds(); + // prioritize passed options over time bounds + if (this.options.start) { + this.lastBounds.start = this.options.start; + } + if (this.options.end) { + this.lastBounds.end = this.options.end; + } this._watchBounds(); this._watchTimeSystem(); this._watchTimeModeChange(); - this._requestHistoricalTelemetry(); + const historicalTelemetryLoadedPromise = this._requestHistoricalTelemetry(); this._initiateSubscriptionTelemetry(); this.loaded = true; + + return historicalTelemetryLoadedPromise; } /** @@ -113,6 +122,7 @@ export default class TelemetryCollection extends EventEmitter { } this.removeAllListeners(); + this.loaded = false; } /** @@ -168,7 +178,7 @@ export default class TelemetryCollection extends EventEmitter { return; } - this._processNewTelemetry(historicalData); + this._processNewTelemetry(historicalData, false); } /** @@ -182,10 +192,9 @@ export default class TelemetryCollection extends EventEmitter { const options = { ...this.options }; //We always want to receive all available values in telemetry tables. options.strategy = this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH; - this.unsubscribe = this.openmct.telemetry.subscribe( this.domainObject, - (datum) => this._processNewTelemetry(datum), + (datum) => this._processNewTelemetry(datum, true), options ); } @@ -196,9 +205,10 @@ export default class TelemetryCollection extends EventEmitter { * * @param {(Object|Object[])} telemetryData - telemetry data object or * array of telemetry data objects + * @param {boolean} isSubscriptionData - `true` if the telemetry data is new subscription data, * @private */ - _processNewTelemetry(telemetryData) { + _processNewTelemetry(telemetryData, isSubscriptionData = false) { if (telemetryData === undefined) { return; } @@ -213,12 +223,19 @@ export default class TelemetryCollection extends EventEmitter { let hasDataBeforeStartBound = false; let size = this.options.size; let enforceSize = size !== undefined && this.options.enforceSize; + const boundsToUse = this.lastBounds; + if (!isSubscriptionData && this.options.start) { + boundsToUse.start = this.options.start; + } + if (!isSubscriptionData && this.options.end) { + boundsToUse.end = this.options.end; + } // loop through, sort and dedupe for (let datum of data) { parsedValue = this.parseTime(datum); - beforeStartOfBounds = parsedValue < this.lastBounds.start; - afterEndOfBounds = parsedValue > this.lastBounds.end; + beforeStartOfBounds = parsedValue < boundsToUse.start; + afterEndOfBounds = parsedValue > boundsToUse.end; if ( !afterEndOfBounds && @@ -397,7 +414,10 @@ export default class TelemetryCollection extends EventEmitter { this.emit('add', added, [this.boundedTelemetry.length]); } } else { - // user bounds change, reset + // user bounds change, reset and remove initial requested bounds (we're using new bounds) + delete this.options?.start; + delete this.options?.end; + this.lastBounds = bounds; this._reset(); } } @@ -477,9 +497,9 @@ export default class TelemetryCollection extends EventEmitter { this.boundedTelemetry = []; this.futureBuffer = []; - this.emit('clear'); + const telemetryLoadPromise = this._requestHistoricalTelemetry(); - this._requestHistoricalTelemetry(); + this.emit('clear', telemetryLoadPromise); } /** diff --git a/src/plugins/comps/CompsInspectorViewProvider.js b/src/plugins/comps/CompsInspectorViewProvider.js new file mode 100644 index 00000000000..d2241ef2dbc --- /dev/null +++ b/src/plugins/comps/CompsInspectorViewProvider.js @@ -0,0 +1,85 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import mount from 'utils/mount'; + +import CompsInspectorView from './components/CompsInspectorView.vue'; + +export default class ConditionSetViewProvider { + constructor(openmct, compsManagerPool) { + this.openmct = openmct; + this.name = 'Config'; + this.key = 'comps-configuration'; + this.compsManagerPool = compsManagerPool; + } + + canView(selection) { + if (selection.length !== 1 || selection[0].length === 0) { + return false; + } + + let object = selection[0][0].context.item; + return object && object.type === 'comps'; + } + + view(selection) { + let _destroy = null; + const domainObject = selection[0][0].context.item; + const openmct = this.openmct; + const compsManagerPool = this.compsManagerPool; + + return { + show: function (element) { + const { destroy } = mount( + { + el: element, + components: { + CompsInspectorView: CompsInspectorView + }, + provide: { + openmct, + domainObject, + compsManagerPool + }, + template: '' + }, + { + app: openmct.app, + element + } + ); + _destroy = destroy; + }, + showTab: function (isEditing) { + return isEditing; + }, + priority: function () { + return 1; + }, + destroy: function () { + if (_destroy) { + _destroy(); + } + } + }; + } +} diff --git a/src/plugins/comps/CompsManager.js b/src/plugins/comps/CompsManager.js new file mode 100644 index 00000000000..0fc06516194 --- /dev/null +++ b/src/plugins/comps/CompsManager.js @@ -0,0 +1,379 @@ +import { EventEmitter } from 'eventemitter3'; + +export default class CompsManager extends EventEmitter { + #openmct; + #domainObject; + #composition; + #telemetryObjects = {}; + #telemetryCollections = {}; + #telemetryLoadedPromises = []; + #telemetryOptions = {}; + #loaded = false; + #compositionLoaded = false; + #telemetryProcessors = {}; + #loadVersion = 0; + #currentLoadPromise = null; + + constructor(openmct, domainObject) { + super(); + this.#openmct = openmct; + this.#domainObject = domainObject; + this.clearData = this.clearData.bind(this); + } + + #getNextAlphabeticalParameterName() { + const parameters = this.#domainObject.configuration.comps.parameters; + const existingNames = new Set(parameters.map((p) => p.name)); + const alphabet = 'abcdefghijklmnopqrstuvwxyz'; + let suffix = ''; + // eslint-disable-next-line no-constant-condition + while (true) { + for (let letter of alphabet) { + const proposedName = letter + suffix; + if (!existingNames.has(proposedName)) { + return proposedName; + } + } + // Increment suffix after exhausting the alphabet + suffix = (parseInt(suffix, 10) || 0) + 1; + } + } + + addParameter(telemetryObject) { + const keyString = this.#openmct.objects.makeKeyString(telemetryObject.identifier); + const metaData = this.#openmct.telemetry.getMetadata(telemetryObject); + const timeSystem = this.#openmct.time.getTimeSystem(); + const domains = metaData?.valuesForHints(['domain']); + const timeMetaData = domains.find((d) => d.key === timeSystem.key); + // in the valuesMetadata, find the first numeric data type + const rangeItems = metaData.valueMetadatas.filter( + (metaDatum) => metaDatum.hints && metaDatum.hints.range + ); + rangeItems.sort((a, b) => a.hints.range - b.hints.range); + let valueToUse = rangeItems[0]?.key; + if (!valueToUse) { + // if no numeric data type, just use the first one + valueToUse = metaData.valueMetadatas[0]?.key; + } + this.#domainObject.configuration.comps.parameters.push({ + keyString, + name: `${this.#getNextAlphabeticalParameterName()}`, + valueToUse, + testValue: 0, + timeMetaData, + accumulateValues: false, + sampleSize: 10 + }); + this.emit('parameterAdded', this.#domainObject); + } + + getParameters() { + const parameters = this.#domainObject.configuration.comps.parameters; + const parametersWithTimeKey = parameters.map((parameter) => { + return { + ...parameter, + timeKey: this.#telemetryCollections[parameter.keyString]?.timeKey + }; + }); + return parametersWithTimeKey; + } + + getTelemetryObjectForParameter(keyString) { + return this.#telemetryObjects[keyString]; + } + + getMetaDataValuesForParameter(keyString) { + const telemetryObject = this.getTelemetryObjectForParameter(keyString); + const metaData = this.#openmct.telemetry.getMetadata(telemetryObject); + return metaData.valueMetadatas; + } + + deleteParameter(keyString) { + this.#domainObject.configuration.comps.parameters = + this.#domainObject.configuration.comps.parameters.filter( + (parameter) => parameter.keyString !== keyString + ); + // if there are no parameters referencing this parameter keyString, remove the telemetry object too + const parameterExists = this.#domainObject.configuration.comps.parameters.some( + (parameter) => parameter.keyString === keyString + ); + if (!parameterExists) { + this.emit('parameterRemoved', this.#domainObject); + } + } + + setDomainObject(passedDomainObject) { + this.#domainObject = passedDomainObject; + } + + isReady() { + return this.#loaded; + } + + async load(telemetryOptions) { + // Increment the load version to mark a new load operation + const loadVersion = ++this.#loadVersion; + + if (!_.isEqual(this.#telemetryOptions, telemetryOptions)) { + this.#destroy(); + } + + this.#telemetryOptions = telemetryOptions; + + // Start the load process and store the promise + this.#currentLoadPromise = (async () => { + // Load composition if not already loaded + if (!this.#compositionLoaded) { + await this.#loadComposition(); + // Check if a newer load has been initiated + if (loadVersion !== this.#loadVersion) { + await this.#currentLoadPromise; + return; + } + this.#compositionLoaded = true; + } + + // Start listening to telemetry if not already done + if (!this.#loaded) { + await this.#startListeningToUnderlyingTelemetry(); + // Check again for newer load + if (loadVersion !== this.#loadVersion) { + await this.#currentLoadPromise; + return; + } + this.#loaded = true; + } + })(); + + // Await the load process + await this.#currentLoadPromise; + } + + async #startListeningToUnderlyingTelemetry() { + Object.keys(this.#telemetryCollections).forEach((keyString) => { + if (!this.#telemetryCollections[keyString].loaded) { + this.#telemetryCollections[keyString].on('add', this.#getTelemetryProcessor(keyString)); + this.#telemetryCollections[keyString].on('clear', this.clearData); + const telemetryLoadedPromise = this.#telemetryCollections[keyString].load(); + this.#telemetryLoadedPromises.push(telemetryLoadedPromise); + } + }); + await Promise.all(this.#telemetryLoadedPromises); + this.#telemetryLoadedPromises = []; + } + + #destroy() { + this.stopListeningToUnderlyingTelemetry(); + this.#composition = null; + this.#telemetryCollections = {}; + this.#compositionLoaded = false; + this.#loaded = false; + this.#telemetryObjects = {}; + } + + stopListeningToUnderlyingTelemetry() { + this.#loaded = false; + Object.keys(this.#telemetryCollections).forEach((keyString) => { + const specificTelemetryProcessor = this.#telemetryProcessors[keyString]; + delete this.#telemetryProcessors[keyString]; + this.#telemetryCollections[keyString].off('add', specificTelemetryProcessor); + this.#telemetryCollections[keyString].off('clear', this.clearData); + this.#telemetryCollections[keyString].destroy(); + }); + } + + getTelemetryObjects() { + return this.#telemetryObjects; + } + + async #loadComposition() { + this.#composition = this.#openmct.composition.get(this.#domainObject); + if (this.#composition) { + this.#composition.on('add', this.#addTelemetryObject); + this.#composition.on('remove', this.#removeTelemetryObject); + await this.#composition.load(); + } + } + + #getParameterForKeyString(keyString) { + return this.#domainObject.configuration.comps.parameters.find( + (parameter) => parameter.keyString === keyString + ); + } + + #getImputedDataUsingLOCF(datum, telemetryCollection) { + const telemetryCollectionData = telemetryCollection.getAll(); + let insertionPointForNewData = telemetryCollection._sortedIndex(datum); + if (insertionPointForNewData && insertionPointForNewData >= telemetryCollectionData.length) { + insertionPointForNewData = telemetryCollectionData.length - 1; + } + // get the closest datum to the new datum + const closestDatum = telemetryCollectionData[insertionPointForNewData]; + // clone the closest datum and replace the time key with the new time + const imputedData = { + ...closestDatum, + [telemetryCollection.timeKey]: datum[telemetryCollection.timeKey] + }; + return imputedData; + } + + getDataFrameForRequest() { + // Step 1: Collect all unique timestamps from all telemetry collections + const allTimestampsSet = new Set(); + + Object.values(this.#telemetryCollections).forEach((collection) => { + collection.getAll().forEach((dataPoint) => { + allTimestampsSet.add(dataPoint.timestamp); + }); + }); + + // Convert the set to a sorted array + const allTimestamps = Array.from(allTimestampsSet).sort((a, b) => a - b); + + // Step 2: Initialize the result object + const telemetryForComps = {}; + + // Step 3: Iterate through each telemetry collection to align data + Object.keys(this.#telemetryCollections).forEach((keyString) => { + const telemetryCollection = this.#telemetryCollections[keyString]; + const alignedValues = []; + + // Iterate through each common timestamp + allTimestamps.forEach((timestamp) => { + const timeKey = telemetryCollection.timeKey; + const fakeData = { [timeKey]: timestamp }; + const imputedDatum = this.#getImputedDataUsingLOCF(fakeData, telemetryCollection); + if (imputedDatum) { + alignedValues.push(imputedDatum); + } + }); + + telemetryForComps[keyString] = alignedValues; + }); + + return telemetryForComps; + } + + getDataFrameForSubscription(newTelemetry) { + const telemetryForComps = {}; + const newTelemetryKey = Object.keys(newTelemetry)[0]; + const newTelemetryParameter = this.#getParameterForKeyString(newTelemetryKey); + const newTelemetryData = newTelemetry[newTelemetryKey]; + const otherTelemetryKeys = Object.keys(this.#telemetryCollections).slice(0); + if (newTelemetryParameter.accumulateValues) { + telemetryForComps[newTelemetryKey] = this.#telemetryCollections[newTelemetryKey].getAll(); + } else { + telemetryForComps[newTelemetryKey] = newTelemetryData; + } + otherTelemetryKeys.forEach((keyString) => { + telemetryForComps[keyString] = []; + }); + + const otherTelemetryKeysNotAccumulating = otherTelemetryKeys.filter( + (keyString) => !this.#getParameterForKeyString(keyString).accumulateValues + ); + const otherTelemetryKeysAccumulating = otherTelemetryKeys.filter( + (keyString) => this.#getParameterForKeyString(keyString).accumulateValues + ); + + // if we're accumulating, just add all the data + otherTelemetryKeysAccumulating.forEach((keyString) => { + telemetryForComps[keyString] = this.#telemetryCollections[keyString].getAll(); + }); + + // for the others, march through the new telemetry data and add data to the frame from the other telemetry objects + // using LOCF + newTelemetryData.forEach((newDatum) => { + otherTelemetryKeysNotAccumulating.forEach((otherKeyString) => { + const otherCollection = this.#telemetryCollections[otherKeyString]; + const imputedDatum = this.#getImputedDataUsingLOCF(newDatum, otherCollection); + if (imputedDatum) { + telemetryForComps[otherKeyString].push(imputedDatum); + } + }); + }); + return telemetryForComps; + } + + #removeTelemetryObject = (telemetryObjectIdentifier) => { + const keyString = this.#openmct.objects.makeKeyString(telemetryObjectIdentifier); + delete this.#telemetryObjects[keyString]; + this.#telemetryCollections[keyString]?.destroy(); + delete this.#telemetryCollections[keyString]; + // remove all parameters that reference this telemetry object + this.deleteParameter(keyString); + }; + + #requestUnderlyingTelemetry() { + const underlyingTelemetry = {}; + Object.keys(this.#telemetryCollections).forEach((collectionKey) => { + const collection = this.#telemetryCollections[collectionKey]; + underlyingTelemetry[collectionKey] = collection.getAll(); + }); + return underlyingTelemetry; + } + + #getTelemetryProcessor(keyString) { + if (this.#telemetryProcessors[keyString]) { + return this.#telemetryProcessors[keyString]; + } + + const telemetryProcessor = (newTelemetry) => { + this.emit('underlyingTelemetryUpdated', { [keyString]: newTelemetry }); + }; + this.#telemetryProcessors[keyString] = telemetryProcessor; + return telemetryProcessor; + } + + #telemetryProcessor = (newTelemetry, keyString) => { + this.emit('underlyingTelemetryUpdated', { [keyString]: newTelemetry }); + }; + + clearData(telemetryLoadedPromise) { + this.#loaded = false; + if (telemetryLoadedPromise) { + this.#telemetryLoadedPromises.push(telemetryLoadedPromise); + } + } + + setOutputFormat(outputFormat) { + this.#domainObject.configuration.comps.outputFormat = outputFormat; + this.emit('outputFormatChanged', outputFormat); + } + + getOutputFormat() { + return this.#domainObject.configuration.comps.outputFormat; + } + + getExpression() { + return this.#domainObject.configuration.comps.expression; + } + + #addTelemetryObject = (telemetryObject) => { + const keyString = this.#openmct.objects.makeKeyString(telemetryObject.identifier); + this.#telemetryObjects[keyString] = telemetryObject; + this.#telemetryCollections[keyString] = this.#openmct.telemetry.requestCollection( + telemetryObject, + this.#telemetryOptions + ); + + // check to see if we have a corresponding parameter + // if not, add one + const parameterExists = this.#domainObject.configuration.comps.parameters.some( + (parameter) => parameter.keyString === keyString + ); + if (!parameterExists) { + this.addParameter(telemetryObject); + } + }; + + static getCompsManager(domainObject, openmct, compsManagerPool) { + const id = openmct.objects.makeKeyString(domainObject.identifier); + + if (!compsManagerPool[id]) { + compsManagerPool[id] = new CompsManager(openmct, domainObject); + } + + return compsManagerPool[id]; + } +} diff --git a/src/plugins/comps/CompsMathWorker.js b/src/plugins/comps/CompsMathWorker.js new file mode 100644 index 00000000000..a0097c00efa --- /dev/null +++ b/src/plugins/comps/CompsMathWorker.js @@ -0,0 +1,139 @@ +import { evaluate } from 'mathjs'; + +// eslint-disable-next-line no-undef +onconnect = function (e) { + const port = e.ports[0]; + + port.onmessage = function (event) { + const { type, callbackID, telemetryForComps, expression, parameters, newTelemetry } = + event.data; + let responseType = 'unknown'; + let error = null; + let result = []; + try { + if (type === 'calculateRequest') { + responseType = 'calculationRequestResult'; + console.debug(`📫 Received new calculation request with callback ID ${callbackID}`); + result = calculateRequest(telemetryForComps, parameters, expression); + } else if (type === 'calculateSubscription') { + responseType = 'calculationSubscriptionResult'; + result = calculateSubscription(telemetryForComps, newTelemetry, parameters, expression); + } else if (type === 'init') { + port.postMessage({ type: 'ready' }); + return; + } else { + throw new Error('Invalid message type'); + } + } catch (errorInCalculation) { + error = errorInCalculation; + } + console.debug(`📭 Sending response for callback ID ${callbackID}`, result); + port.postMessage({ type: responseType, callbackID, result, error }); + }; +}; + +function getFullDataFrame(telemetryForComps, parameters) { + const dataFrame = {}; + Object.keys(telemetryForComps)?.forEach((key) => { + const parameter = parameters.find((p) => p.keyString === key); + const dataSet = telemetryForComps[key]; + const telemetryMap = new Map(dataSet.map((item) => [item[parameter.timeKey], item])); + dataFrame[key] = telemetryMap; + }); + return dataFrame; +} + +function calculateSubscription(telemetryForComps, newTelemetry, parameters, expression) { + const dataFrame = getFullDataFrame(telemetryForComps, parameters); + const calculation = calculate(dataFrame, parameters, expression); + const newTelemetryKey = Object.keys(newTelemetry)[0]; + const newTelemetrySize = newTelemetry[newTelemetryKey].length; + let trimmedCalculation = calculation; + if (calculation.length > newTelemetrySize) { + trimmedCalculation = calculation.slice(calculation.length - newTelemetrySize); + } + return trimmedCalculation; +} + +function calculateRequest(telemetryForComps, parameters, expression) { + const dataFrame = getFullDataFrame(telemetryForComps, parameters); + return calculate(dataFrame, parameters, expression); +} + +function calculate(dataFrame, parameters, expression) { + const sumResults = []; + // ensure all parameter keyStrings have corresponding telemetry data + if (!expression) { + return sumResults; + } + // set up accumulated data structure + const accumulatedData = {}; + parameters.forEach((parameter) => { + if (parameter.accumulateValues) { + accumulatedData[parameter.name] = []; + } + }); + + // take the first parameter keyString as the reference + const referenceParameter = parameters[0]; + const otherParameters = parameters.slice(1); + // iterate over the reference telemetry data + const referenceTelemetry = dataFrame[referenceParameter.keyString]; + referenceTelemetry?.forEach((referenceTelemetryItem) => { + let referenceValue = referenceTelemetryItem[referenceParameter.valueToUse]; + if (referenceParameter.accumulateValues) { + accumulatedData[referenceParameter.name].push(referenceValue); + referenceValue = accumulatedData[referenceParameter.name]; + } + if ( + referenceParameter.accumulateValues && + referenceParameter.sampleSize && + referenceParameter.sampleSize > 0 + ) { + // enforce sample size by ensuring referenceValue has the latest n elements + // if we don't have at least the sample size, skip this iteration + if (!referenceValue.length || referenceValue.length < referenceParameter.sampleSize) { + return; + } + referenceValue = referenceValue.slice(-referenceParameter.sampleSize); + } + + const scope = { + [referenceParameter.name]: referenceValue + }; + const referenceTime = referenceTelemetryItem[referenceParameter.timeKey]; + // iterate over the other parameters to set the scope + let missingData = false; + otherParameters.forEach((parameter) => { + const otherDataFrame = dataFrame[parameter.keyString]; + const otherTelemetry = otherDataFrame.get(referenceTime); + if (otherTelemetry === undefined || otherTelemetry === null) { + missingData = true; + return; + } + let otherValue = otherTelemetry[parameter.valueToUse]; + if (parameter.accumulateValues) { + accumulatedData[parameter.name].push(referenceValue); + otherValue = accumulatedData[referenceParameter.name]; + } + scope[parameter.name] = otherValue; + }); + if (missingData) { + console.debug('🤦‍♂️ Missing data for some parameters, skipping calculation'); + return; + } + const rawComputedValue = evaluate(expression, scope); + let computedValue = rawComputedValue; + if (computedValue.entries) { + // if there aren't any entries, return with nothing + if (computedValue.entries.length === 0) { + return; + } + console.debug('📊 Computed value is an array of entries', computedValue.entries); + // make array of arrays of entries + computedValue = computedValue.entries?.[0]; + } + sumResults.push({ [referenceParameter.timeKey]: referenceTime, value: computedValue }); + }); + return sumResults; +} diff --git a/src/plugins/comps/CompsMetadataProvider.js b/src/plugins/comps/CompsMetadataProvider.js new file mode 100644 index 00000000000..1b4a489277e --- /dev/null +++ b/src/plugins/comps/CompsMetadataProvider.js @@ -0,0 +1,79 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +import CompsManager from './CompsManager.js'; + +export default class CompsMetadataProvider { + #openmct = null; + #compsManagerPool = null; + + constructor(openmct, compsManagerPool) { + this.#openmct = openmct; + this.#compsManagerPool = compsManagerPool; + } + + supportsMetadata(domainObject) { + return domainObject.type === 'comps'; + } + + getDefaultDomains(domainObject) { + return this.#openmct.time.getAllTimeSystems().map(function (ts, i) { + return { + key: ts.key, + name: ts.name, + format: ts.timeFormat, + hints: { + domain: i + } + }; + }); + } + + getMetadata(domainObject) { + const specificCompsManager = CompsManager.getCompsManager( + domainObject, + this.#openmct, + this.#compsManagerPool + ); + // if there are any parameters, grab the first one's timeMetaData + const timeMetaData = specificCompsManager?.getParameters()[0]?.timeMetaData; + const metaDataToReturn = { + values: [ + { + key: 'value', + name: 'Value', + derived: true, + formatString: specificCompsManager.getOutputFormat(), + hints: { + range: 1 + } + } + ] + }; + if (timeMetaData) { + metaDataToReturn.values.push(timeMetaData); + } else { + const defaultDomains = this.getDefaultDomains(domainObject); + metaDataToReturn.values.push(...defaultDomains); + } + return metaDataToReturn; + } +} diff --git a/src/plugins/comps/CompsTelemetryProvider.js b/src/plugins/comps/CompsTelemetryProvider.js new file mode 100644 index 00000000000..c53676d80d5 --- /dev/null +++ b/src/plugins/comps/CompsTelemetryProvider.js @@ -0,0 +1,175 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import CompsManager from './CompsManager.js'; + +export default class CompsTelemetryProvider { + #openmct = null; + #sharedWorker = null; + #compsManagerPool = null; + #lastUniqueID = 1; + #requestPromises = {}; + #subscriptionCallbacks = {}; + // id is random 4 digit number + #id = Math.floor(Math.random() * 9000) + 1000; + + constructor(openmct, compsManagerPool) { + this.#openmct = openmct; + this.#compsManagerPool = compsManagerPool; + this.#openmct.on('start', this.#startSharedWorker.bind(this)); + } + + isTelemetryObject(domainObject) { + return domainObject.type === 'comps'; + } + + supportsRequest(domainObject) { + return domainObject.type === 'comps'; + } + + supportsSubscribe(domainObject) { + return domainObject.type === 'comps'; + } + + #getCallbackID() { + return this.#lastUniqueID++; + } + + request(domainObject, options) { + return new Promise((resolve, reject) => { + const specificCompsManager = CompsManager.getCompsManager( + domainObject, + this.#openmct, + this.#compsManagerPool + ); + specificCompsManager.load(options).then(() => { + const callbackID = this.#getCallbackID(); + const telemetryForComps = JSON.parse( + JSON.stringify(specificCompsManager.getDataFrameForRequest()) + ); + const expression = specificCompsManager.getExpression(); + const parameters = JSON.parse(JSON.stringify(specificCompsManager.getParameters())); + if (!expression || !parameters) { + resolve([]); + return; + } + this.#requestPromises[callbackID] = { resolve, reject }; + const payload = { + type: 'calculateRequest', + telemetryForComps, + expression, + parameters, + callbackID + }; + this.#sharedWorker.port.postMessage(payload); + }); + }); + } + + #computeOnNewTelemetry(specificCompsManager, callbackID, newTelemetry) { + if (!specificCompsManager.isReady()) { + return; + } + const expression = specificCompsManager.getExpression(); + const telemetryForComps = specificCompsManager.getDataFrameForSubscription(newTelemetry); + const parameters = JSON.parse(JSON.stringify(specificCompsManager.getParameters())); + if (!expression || !parameters) { + return; + } + const payload = { + type: 'calculateSubscription', + telemetryForComps, + newTelemetry, + expression, + parameters, + callbackID + }; + this.#sharedWorker.port.postMessage(payload); + } + + subscribe(domainObject, callback) { + const specificCompsManager = CompsManager.getCompsManager( + domainObject, + this.#openmct, + this.#compsManagerPool + ); + const callbackID = this.#getCallbackID(); + this.#subscriptionCallbacks[callbackID] = callback; + const boundComputeOnNewTelemetry = this.#computeOnNewTelemetry.bind( + this, + specificCompsManager, + callbackID + ); + specificCompsManager.on('underlyingTelemetryUpdated', boundComputeOnNewTelemetry); + const telemetryOptions = { + strategy: 'latest', + size: 1 + }; + specificCompsManager.load(telemetryOptions); + return () => { + delete this.#subscriptionCallbacks[callbackID]; + specificCompsManager.stopListeningToUnderlyingTelemetry(); + specificCompsManager.off('underlyingTelemetryUpdated', boundComputeOnNewTelemetry); + }; + } + + #startSharedWorker() { + if (this.#sharedWorker) { + throw new Error('Shared worker already started'); + } + const sharedWorkerURL = `${this.#openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}compsMathWorker.js`; + + this.#sharedWorker = new SharedWorker(sharedWorkerURL, `Comps Math Worker`); + this.#sharedWorker.port.onmessage = this.onSharedWorkerMessage.bind(this); + this.#sharedWorker.port.onmessageerror = this.onSharedWorkerMessageError.bind(this); + this.#sharedWorker.port.start(); + + this.#sharedWorker.port.postMessage({ type: 'init' }); + + this.#openmct.on('destroy', () => { + this.#sharedWorker.port.close(); + }); + } + + onSharedWorkerMessage(event) { + const { type, result, callbackID, error } = event.data; + if ( + type === 'calculationSubscriptionResult' && + this.#subscriptionCallbacks[callbackID] && + result.length + ) { + this.#subscriptionCallbacks[callbackID](result); + } else if (type === 'calculationRequestResult' && this.#requestPromises[callbackID]) { + if (error) { + console.error('📝 Error calculating request:', event.data); + this.#requestPromises[callbackID].resolve([]); + } else { + this.#requestPromises[callbackID].resolve(result); + } + delete this.#requestPromises[callbackID]; + } + } + + onSharedWorkerMessageError(event) { + console.error('❌ Shared worker message error:', event); + } +} diff --git a/src/plugins/comps/CompsViewProvider.js b/src/plugins/comps/CompsViewProvider.js new file mode 100644 index 00000000000..7939670e7f9 --- /dev/null +++ b/src/plugins/comps/CompsViewProvider.js @@ -0,0 +1,98 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import mount from 'utils/mount'; + +import CompsView from './components/CompsView.vue'; + +const DEFAULT_VIEW_PRIORITY = 100; + +export default class ConditionSetViewProvider { + constructor(openmct, compsManagerPool) { + this.openmct = openmct; + this.name = 'Comps View'; + this.key = 'comps.view'; + this.cssClass = 'icon-derived-telemetry'; + this.compsManagerPool = compsManagerPool; + } + + canView(domainObject, objectPath) { + return domainObject.type === 'comps' && this.openmct.router.isNavigatedObject(objectPath); + } + + canEdit(domainObject, objectPath) { + return domainObject.type === 'comps' && this.openmct.router.isNavigatedObject(objectPath); + } + + view(domainObject, objectPath) { + let _destroy = null; + let component = null; + + return { + show: (container, isEditing) => { + const { vNode, destroy } = mount( + { + el: container, + components: { + CompsView + }, + provide: { + openmct: this.openmct, + domainObject, + objectPath, + compsManagerPool: this.compsManagerPool + }, + data() { + return { + isEditing + }; + }, + template: '' + }, + { + app: this.openmct.app, + element: container + } + ); + _destroy = destroy; + component = vNode.componentInstance; + }, + onEditModeChange: (isEditing) => { + component.isEditing = isEditing; + }, + destroy: () => { + if (_destroy) { + _destroy(); + } + component = null; + } + }; + } + + priority(domainObject) { + if (domainObject.type === 'comps') { + return Number.MAX_VALUE; + } else { + return DEFAULT_VIEW_PRIORITY; + } + } +} diff --git a/src/plugins/comps/components/CompsInspectorView.vue b/src/plugins/comps/components/CompsInspectorView.vue new file mode 100644 index 00000000000..ff02eb94e72 --- /dev/null +++ b/src/plugins/comps/components/CompsInspectorView.vue @@ -0,0 +1,77 @@ + + + + diff --git a/src/plugins/comps/components/CompsView.vue b/src/plugins/comps/components/CompsView.vue new file mode 100644 index 00000000000..5e5cf886487 --- /dev/null +++ b/src/plugins/comps/components/CompsView.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/src/plugins/comps/components/comps.scss b/src/plugins/comps/components/comps.scss new file mode 100644 index 00000000000..3003c577d6e --- /dev/null +++ b/src/plugins/comps/components/comps.scss @@ -0,0 +1,112 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +@mixin expressionMsg($fg, $bg) { + $op: 0.4; + color: rgba($fg, $op * 1.5); + background: rgba($bg, $op); +} + +.c-comps { + display: flex; + flex-direction: column; + gap: $interiorMarginLg; + + .is-editing & { + padding: $interiorMargin; + } + + &__output { + display: flex; + align-items: baseline; + gap: $interiorMargin; + + &-label { + flex: 0 0 auto; + text-transform: uppercase; + } + + &-value { + flex: 0 1 auto; + } + } + + &__section, + &__refs { + display: flex; + flex-direction: column; + gap: $interiorMarginSm; + } + + &__ref { + @include discreteItem(); + display: grid; + gap: $interiorMargin; + grid-template-columns: max-content max-content min-content 1fr; + padding: $interiorMargin; + line-height: 170%; // Aligns text with controls like selects + } + + &__path-and-field { + align-items: start; + display: flex; + gap: $interiorMargin; + } + + &__expression { + *[class*=value] { + font-family: monospace; + //font-size: 1.1em; + resize: vertical; // Only applies to textarea + } + div[class*=value] { + padding: $interiorMargin; + } + } + + &__expression-msg { + @include expressionMsg($colorOkFg, $colorOk); + border-radius: $basicCr; + display: flex; // Creates hanging indent from :before icon + padding: $interiorMarginSm $interiorMarginLg $interiorMarginSm $interiorMargin; + //text-wrap: normal; + max-width: max-content; + + &:before { + content: $glyph-icon-check; + font-family: symbolsfont; + margin-right: $interiorMarginSm; + } + + &.--bad { + @include expressionMsg($colorErrorFg, $colorError); + + &:before { + content: $glyph-icon-alert-triangle; + } + } + } + + .--em { + color: $colorBodyFgEm; + //font-weight: bold; + } +} diff --git a/src/plugins/comps/plugin.js b/src/plugins/comps/plugin.js new file mode 100644 index 00000000000..19c16769541 --- /dev/null +++ b/src/plugins/comps/plugin.js @@ -0,0 +1,60 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +import CompsInspectorViewProvider from './CompsInspectorViewProvider.js'; +import CompsMetadataProvider from './CompsMetadataProvider.js'; +import CompsTelemetryProvider from './CompsTelemetryProvider.js'; +import CompsViewProvider from './CompsViewProvider.js'; + +export default function CompsPlugin() { + const compsManagerPool = {}; + + return function install(openmct) { + openmct.types.addType('comps', { + name: 'Derived Telemetry', + key: 'comps', + description: + 'Add one or more telemetry end points, apply a mathematical operation to them, and output the result as new telemetry.', + creatable: true, + cssClass: 'icon-derived-telemetry', + initialize: function (domainObject) { + domainObject.configuration = { + comps: { + expression: '', + parameters: [] + } + }; + domainObject.composition = []; + domainObject.telemetry = {}; + } + }); + openmct.composition.addPolicy((parent, child) => { + if (parent.type === 'comps' && !openmct.telemetry.isTelemetryObject(child)) { + return false; + } + return true; + }); + openmct.telemetry.addProvider(new CompsMetadataProvider(openmct, compsManagerPool)); + openmct.telemetry.addProvider(new CompsTelemetryProvider(openmct, compsManagerPool)); + openmct.objectViews.addProvider(new CompsViewProvider(openmct, compsManagerPool)); + openmct.inspectorViews.addProvider(new CompsInspectorViewProvider(openmct, compsManagerPool)); + }; +} diff --git a/src/plugins/condition/components/ConditionSet.vue b/src/plugins/condition/components/ConditionSet.vue index bdefc5ff5ce..cb473c10b9f 100644 --- a/src/plugins/condition/components/ConditionSet.vue +++ b/src/plugins/condition/components/ConditionSet.vue @@ -23,9 +23,9 @@