diff --git a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js index 05d15e82d8..a0b9076abf 100644 --- a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js @@ -197,11 +197,16 @@ test.describe('Tagging in Notebooks @addInit', () => { page.goto('./#/browse/mine?hideTree=false'), page.click('.c-disclosure-triangle') ]); - // Click Clock - await page.click(`text=${clock.name}`); + const treePane = page.locator('#tree-pane'); + // Click Clock + await treePane.getByRole('treeitem', { + name: clock.name + }).click(); // Click Notebook - await page.click(`text=${notebook.name}`); + await page.getByRole('treeitem', { + name: notebook.name + }).click(); for (let iteration = 0; iteration < ITERATIONS; iteration++) { const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`; diff --git a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js index fb9a2e2a73..3a1e4e20d6 100644 --- a/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js +++ b/e2e/tests/functional/plugins/plot/autoscale.e2e.spec.js @@ -32,7 +32,7 @@ test.use({ } }); -test.describe('ExportAsJSON', () => { +test.fixme('ExportAsJSON', () => { test('User can set autoscale with a valid range @snapshot', async ({ page, openmctConfig }) => { const { myItemsFolderName } = openmctConfig; diff --git a/example/generator/GeneratorProvider.js b/example/generator/GeneratorProvider.js index 3c28f5b795..57d2ea0a91 100644 --- a/example/generator/GeneratorProvider.js +++ b/example/generator/GeneratorProvider.js @@ -37,8 +37,9 @@ define([ infinityValues: false }; - function GeneratorProvider(openmct) { - this.workerInterface = new WorkerInterface(openmct); + function GeneratorProvider(openmct, StalenessProvider) { + this.openmct = openmct; + this.workerInterface = new WorkerInterface(openmct, StalenessProvider); } GeneratorProvider.prototype.canProvideTelemetry = function (domainObject) { @@ -81,6 +82,7 @@ define([ workerRequest[prop] = Number(workerRequest[prop]); }); + workerRequest.id = this.openmct.objects.makeKeyString(domainObject.identifier); workerRequest.name = domainObject.name; return workerRequest; diff --git a/example/generator/SinewaveStalenessProvider.js b/example/generator/SinewaveStalenessProvider.js new file mode 100644 index 0000000000..3ebf4570e1 --- /dev/null +++ b/example/generator/SinewaveStalenessProvider.js @@ -0,0 +1,151 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 EventEmitter from 'EventEmitter'; + +export default class SinewaveLimitProvider extends EventEmitter { + + constructor(openmct) { + super(); + + this.openmct = openmct; + this.observingStaleness = {}; + this.watchingTheClock = false; + this.isRealTime = undefined; + } + + supportsStaleness(domainObject) { + return domainObject.type === 'generator'; + } + + isStale(domainObject, options) { + if (!this.providingStaleness(domainObject)) { + return Promise.resolve({ + isStale: false, + utc: 0 + }); + } + + const id = this.getObjectKeyString(domainObject); + + if (!this.observerExists(id)) { + this.createObserver(id); + } + + return Promise.resolve(this.observingStaleness[id].isStale); + } + + subscribeToStaleness(domainObject, callback) { + const id = this.getObjectKeyString(domainObject); + + if (this.isRealTime === undefined) { + this.updateRealTime(this.openmct.time.clock()); + } + + this.handleClockUpdate(); + + if (this.observerExists(id)) { + this.addCallbackToObserver(id, callback); + } else { + this.createObserver(id, callback); + } + + const intervalId = setInterval(() => { + if (this.providingStaleness(domainObject)) { + this.updateStaleness(id, !this.observingStaleness[id].isStale); + } + }, 10000); + + return () => { + clearInterval(intervalId); + this.updateStaleness(id, false); + this.handleClockUpdate(); + this.destroyObserver(id); + }; + } + + handleClockUpdate() { + let observers = Object.values(this.observingStaleness).length > 0; + + if (observers && !this.watchingTheClock) { + this.watchingTheClock = true; + this.openmct.time.on('clock', this.updateRealTime, this); + } else if (!observers && this.watchingTheClock) { + this.watchingTheClock = false; + this.openmct.time.off('clock', this.updateRealTime, this); + } + } + + updateRealTime(clock) { + this.isRealTime = clock !== undefined; + + if (!this.isRealTime) { + Object.keys(this.observingStaleness).forEach((id) => { + this.updateStaleness(id, false); + }); + } + } + + updateStaleness(id, isStale) { + this.observingStaleness[id].isStale = isStale; + this.observingStaleness[id].utc = Date.now(); + this.observingStaleness[id].callback({ + isStale: this.observingStaleness[id].isStale, + utc: this.observingStaleness[id].utc + }); + this.emit('stalenessEvent', { + id, + isStale: this.observingStaleness[id].isStale + }); + } + + createObserver(id, callback) { + this.observingStaleness[id] = { + isStale: false, + utc: Date.now() + }; + + if (typeof callback === 'function') { + this.addCallbackToObserver(id, callback); + } + } + + destroyObserver(id) { + delete this.observingStaleness[id]; + } + + providingStaleness(domainObject) { + return domainObject.telemetry?.staleness === true && this.isRealTime; + } + + getObjectKeyString(object) { + return this.openmct.objects.makeKeyString(object.identifier); + } + + addCallbackToObserver(id, callback) { + this.observingStaleness[id].callback = callback; + } + + observerExists(id) { + return this.observingStaleness?.[id]; + } +} diff --git a/example/generator/WorkerInterface.js b/example/generator/WorkerInterface.js index 1573800fff..9cdc6c5cc0 100644 --- a/example/generator/WorkerInterface.js +++ b/example/generator/WorkerInterface.js @@ -25,14 +25,24 @@ define([ ], function ( { v4: uuid } ) { - function WorkerInterface(openmct) { + function WorkerInterface(openmct, StalenessProvider) { // eslint-disable-next-line no-undef const workerUrl = `${openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}generatorWorker.js`; + this.StalenessProvider = StalenessProvider; this.worker = new Worker(workerUrl); this.worker.onmessage = this.onMessage.bind(this); this.callbacks = {}; + this.staleTelemetryIds = {}; + + this.watchStaleness(); } + WorkerInterface.prototype.watchStaleness = function () { + this.StalenessProvider.on('stalenessEvent', ({ id, isStale}) => { + this.staleTelemetryIds[id] = isStale; + }); + }; + WorkerInterface.prototype.onMessage = function (message) { message = message.data; var callback = this.callbacks[message.id]; @@ -83,11 +93,12 @@ define([ }; WorkerInterface.prototype.subscribe = function (request, cb) { - function callback(message) { - cb(message.data); - } - - var messageId = this.dispatch('subscribe', request, callback); + const id = request.id; + const messageId = this.dispatch('subscribe', request, (message) => { + if (!this.staleTelemetryIds[id]) { + cb(message.data); + } + }); return function () { this.dispatch('unsubscribe', { diff --git a/example/generator/plugin.js b/example/generator/plugin.js index 1060020a6d..8c592c5b92 100644 --- a/example/generator/plugin.js +++ b/example/generator/plugin.js @@ -20,158 +20,163 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -define([ - "./GeneratorProvider", - "./SinewaveLimitProvider", - "./StateGeneratorProvider", - "./GeneratorMetadataProvider" -], function ( - GeneratorProvider, - SinewaveLimitProvider, - StateGeneratorProvider, - GeneratorMetadataProvider -) { +import GeneratorProvider from "./GeneratorProvider"; +import SinewaveLimitProvider from "./SinewaveLimitProvider"; +import SinewaveStalenessProvider from "./SinewaveStalenessProvider"; +import StateGeneratorProvider from "./StateGeneratorProvider"; +import GeneratorMetadataProvider from "./GeneratorMetadataProvider"; - return function (openmct) { +export default function (openmct) { - openmct.types.addType("example.state-generator", { - name: "State Generator", - description: "For development use. Generates example enumerated telemetry by cycling through a given set of states.", - cssClass: "icon-generator-telemetry", - creatable: true, - form: [ - { - name: "State Duration (seconds)", - control: "numberfield", - cssClass: "l-input-sm l-numeric", - key: "duration", - required: true, - property: [ - "telemetry", - "duration" - ] - } - ], - initialize: function (object) { - object.telemetry = { - duration: 5 - }; + openmct.types.addType("example.state-generator", { + name: "State Generator", + description: "For development use. Generates example enumerated telemetry by cycling through a given set of states.", + cssClass: "icon-generator-telemetry", + creatable: true, + form: [ + { + name: "State Duration (seconds)", + control: "numberfield", + cssClass: "l-input-sm l-numeric", + key: "duration", + required: true, + property: [ + "telemetry", + "duration" + ] } - }); + ], + initialize: function (object) { + object.telemetry = { + duration: 5 + }; + } + }); - openmct.telemetry.addProvider(new StateGeneratorProvider()); + openmct.telemetry.addProvider(new StateGeneratorProvider()); - openmct.types.addType("generator", { - name: "Sine Wave Generator", - description: "For development use. Generates example streaming telemetry data using a simple sine wave algorithm.", - cssClass: "icon-generator-telemetry", - creatable: true, - form: [ - { - name: "Period", - control: "numberfield", - cssClass: "l-input-sm l-numeric", - key: "period", - required: true, - property: [ - "telemetry", - "period" - ] - }, - { - name: "Amplitude", - control: "numberfield", - cssClass: "l-numeric", - key: "amplitude", - required: true, - property: [ - "telemetry", - "amplitude" - ] - }, - { - name: "Offset", - control: "numberfield", - cssClass: "l-numeric", - key: "offset", - required: true, - property: [ - "telemetry", - "offset" - ] - }, - { - name: "Data Rate (hz)", - control: "numberfield", - cssClass: "l-input-sm l-numeric", - key: "dataRateInHz", - required: true, - property: [ - "telemetry", - "dataRateInHz" - ] - }, - { - name: "Phase (radians)", - control: "numberfield", - cssClass: "l-input-sm l-numeric", - key: "phase", - required: true, - property: [ - "telemetry", - "phase" - ] - }, - { - name: "Randomness", - control: "numberfield", - cssClass: "l-input-sm l-numeric", - key: "randomness", - required: true, - property: [ - "telemetry", - "randomness" - ] - }, - { - name: "Loading Delay (ms)", - control: "numberfield", - cssClass: "l-input-sm l-numeric", - key: "loadDelay", - required: true, - property: [ - "telemetry", - "loadDelay" - ] - }, - { - name: "Include Infinity Values", - control: "toggleSwitch", - cssClass: "l-input", - key: "infinityValues", - property: [ - "telemetry", - "infinityValues" - ] - } - ], - initialize: function (object) { - object.telemetry = { - period: 10, - amplitude: 1, - offset: 0, - dataRateInHz: 1, - phase: 0, - randomness: 0, - loadDelay: 0, - infinityValues: false - }; + openmct.types.addType("generator", { + name: "Sine Wave Generator", + description: "For development use. Generates example streaming telemetry data using a simple sine wave algorithm.", + cssClass: "icon-generator-telemetry", + creatable: true, + form: [ + { + name: "Period", + control: "numberfield", + cssClass: "l-input-sm l-numeric", + key: "period", + required: true, + property: [ + "telemetry", + "period" + ] + }, + { + name: "Amplitude", + control: "numberfield", + cssClass: "l-numeric", + key: "amplitude", + required: true, + property: [ + "telemetry", + "amplitude" + ] + }, + { + name: "Offset", + control: "numberfield", + cssClass: "l-numeric", + key: "offset", + required: true, + property: [ + "telemetry", + "offset" + ] + }, + { + name: "Data Rate (hz)", + control: "numberfield", + cssClass: "l-input-sm l-numeric", + key: "dataRateInHz", + required: true, + property: [ + "telemetry", + "dataRateInHz" + ] + }, + { + name: "Phase (radians)", + control: "numberfield", + cssClass: "l-input-sm l-numeric", + key: "phase", + required: true, + property: [ + "telemetry", + "phase" + ] + }, + { + name: "Randomness", + control: "numberfield", + cssClass: "l-input-sm l-numeric", + key: "randomness", + required: true, + property: [ + "telemetry", + "randomness" + ] + }, + { + name: "Loading Delay (ms)", + control: "numberfield", + cssClass: "l-input-sm l-numeric", + key: "loadDelay", + required: true, + property: [ + "telemetry", + "loadDelay" + ] + }, + { + name: "Include Infinity Values", + control: "toggleSwitch", + cssClass: "l-input", + key: "infinityValues", + property: [ + "telemetry", + "infinityValues" + ] + }, + { + name: "Provide Staleness Updates", + control: "toggleSwitch", + cssClass: "l-input", + key: "staleness", + property: [ + "telemetry", + "staleness" + ] } - }); + ], + initialize: function (object) { + object.telemetry = { + period: 10, + amplitude: 1, + offset: 0, + dataRateInHz: 1, + phase: 0, + randomness: 0, + loadDelay: 0, + infinityValues: false, + staleness: false + }; + } + }); + const stalenessProvider = new SinewaveStalenessProvider(openmct); - openmct.telemetry.addProvider(new GeneratorProvider(openmct)); - openmct.telemetry.addProvider(new GeneratorMetadataProvider()); - openmct.telemetry.addProvider(new SinewaveLimitProvider()); - }; - -}); + openmct.telemetry.addProvider(new GeneratorProvider(openmct, stalenessProvider)); + openmct.telemetry.addProvider(new GeneratorMetadataProvider()); + openmct.telemetry.addProvider(new SinewaveLimitProvider()); + openmct.telemetry.addProvider(stalenessProvider); +} diff --git a/src/api/telemetry/TelemetryAPI.js b/src/api/telemetry/TelemetryAPI.js index c2a392d182..9de4475b6a 100644 --- a/src/api/telemetry/TelemetryAPI.js +++ b/src/api/telemetry/TelemetryAPI.js @@ -36,6 +36,7 @@ export default class TelemetryAPI { this.formatMapCache = new WeakMap(); this.formatters = new Map(); this.limitProviders = []; + this.stalenessProviders = []; this.metadataCache = new WeakMap(); this.metadataProviders = [new DefaultMetadataProvider(this.openmct)]; this.noRequestProviderForAllObjects = false; @@ -114,6 +115,10 @@ export default class TelemetryAPI { if (provider.supportsLimits) { this.limitProviders.unshift(provider); } + + if (provider.supportsStaleness) { + this.stalenessProviders.unshift(provider); + } } /** @@ -125,7 +130,7 @@ export default class TelemetryAPI { return provider.supportsSubscribe.apply(provider, args); } - return this.subscriptionProviders.filter(supportsDomainObject)[0]; + return this.subscriptionProviders.find(supportsDomainObject); } /** @@ -138,25 +143,25 @@ export default class TelemetryAPI { return provider.supportsRequest.apply(provider, args); } - return this.requestProviders.filter(supportsDomainObject)[0]; + return this.requestProviders.find(supportsDomainObject); } /** * @private */ #findMetadataProvider(domainObject) { - return this.metadataProviders.filter(function (p) { - return p.supportsMetadata(domainObject); - })[0]; + return this.metadataProviders.find((provider) => { + return provider.supportsMetadata(domainObject); + }); } /** * @private */ #findLimitEvaluator(domainObject) { - return this.limitProviders.filter(function (p) { - return p.supportsLimits(domainObject); - })[0]; + return this.limitProviders.find((provider) => { + return provider.supportsLimits(domainObject); + }); } /** @@ -351,6 +356,101 @@ export default class TelemetryAPI { }.bind(this); } + /** + * Subscribe to staleness updates for a specific domain object. + * The callback will be called whenever staleness changes. + * + * @method subscribeToStaleness + * @memberof module:openmct.TelemetryAPI~StalenessProvider# + * @param {module:openmct.DomainObject} domainObject the object + * to watch for staleness updates + * @param {Function} callback the callback to invoke with staleness data, + * as it is received: ex. + * { + * isStale: , + * timestamp: + * } + * @returns {Function} a function which may be called to terminate + * the subscription to staleness updates + */ + subscribeToStaleness(domainObject, callback) { + const provider = this.#findStalenessProvider(domainObject); + + if (!this.stalenessSubscriberCache) { + this.stalenessSubscriberCache = {}; + } + + const keyString = objectUtils.makeKeyString(domainObject.identifier); + let stalenessSubscriber = this.stalenessSubscriberCache[keyString]; + + if (!stalenessSubscriber) { + stalenessSubscriber = this.stalenessSubscriberCache[keyString] = { + callbacks: [callback] + }; + if (provider) { + stalenessSubscriber.unsubscribe = provider + .subscribeToStaleness(domainObject, (stalenessResponse) => { + stalenessSubscriber.callbacks.forEach((cb) => { + cb(stalenessResponse); + }); + }); + } else { + stalenessSubscriber.unsubscribe = () => {}; + } + } else { + stalenessSubscriber.callbacks.push(callback); + } + + return function unsubscribe() { + stalenessSubscriber.callbacks = stalenessSubscriber.callbacks.filter((cb) => { + return cb !== callback; + }); + if (stalenessSubscriber.callbacks.length === 0) { + stalenessSubscriber.unsubscribe(); + delete this.stalenessSubscriberCache[keyString]; + } + }.bind(this); + } + + /** + * Request telemetry staleness for a domain object. + * + * @method isStale + * @memberof module:openmct.TelemetryAPI~StalenessProvider# + * @param {module:openmct.DomainObject} domainObject the object + * which has associated telemetry staleness + * @returns {Promise.} a promise for a StalenessResponseObject + * or undefined if no provider exists + */ + async isStale(domainObject) { + const provider = this.#findStalenessProvider(domainObject); + + if (!provider) { + return; + } + + const abortController = new AbortController(); + const options = { signal: abortController.signal }; + this.requestAbortControllers.add(abortController); + + try { + const staleness = await provider.isStale(domainObject, options); + + return staleness; + } finally { + this.requestAbortControllers.delete(abortController); + } + } + + /** + * @private + */ + #findStalenessProvider(domainObject) { + return this.stalenessProviders.find((provider) => { + return provider.supportsStaleness(domainObject); + }); + } + /** * Get telemetry metadata for a given domain object. Returns a telemetry * metadata manager which provides methods for interrogating telemetry @@ -661,6 +761,29 @@ export default class TelemetryAPI { * @memberof module:openmct.TelemetryAPI~ */ +/** + * Provides telemetry staleness data. To subscribe to telemetry stalenes, + * new StalenessProvider implementations should be + * [registered]{@link module:openmct.TelemetryAPI#addProvider}. + * + * @interface StalenessProvider + * @property {function} supportsStaleness receieves a domainObject and + * returns a boolean to indicate it will provide staleness + * @property {function} subscribeToStaleness receieves a domainObject to + * be subscribed to and a callback to invoke with a StalenessResponseObject + * @property {function} isStale an asynchronous method called with a domainObject + * and an options object which currently has an abort signal, ex. + * { signal: } + * this method should return a current StalenessResponseObject + * @memberof module:openmct.TelemetryAPI~ + */ + +/** + * @typedef {object} StalenessResponseObject + * @property {Boolean} isStale boolean representing the staleness state + * @property {Number} timestamp Unix timestamp in milliseconds + */ + /** * An interface for retrieving telemetry data associated with a domain * object. diff --git a/src/plugins/LADTable/components/LADRow.vue b/src/plugins/LADTable/components/LADRow.vue index 3468c870b6..1432b3be2f 100644 --- a/src/plugins/LADTable/components/LADRow.vue +++ b/src/plugins/LADTable/components/LADRow.vue @@ -29,7 +29,7 @@ {{ formattedTimestamp }} {{ value }} -
+
@@ -38,6 +41,7 @@ :domain-object="ladRow.domainObject" :path-to-table="objectPath" :has-units="hasUnits" + :is-stale="staleObjects.includes(ladRow.key)" @rowContextClick="updateViewContext" /> @@ -46,7 +50,9 @@