Compare commits

...

12 Commits
master ... gold

Author SHA1 Message Date
4aea6a510c Cherry pick 8065 - Condition sets failing to evaluate telemetry where source and key do not match in metadata (#8066)
Condition sets failing to evaluate telemetry where source and key do not match in metadata (#8065)

normalize should map from source to key
2025-05-14 12:42:59 -07:00
2667ff6a4e cherry-pick #8041 - Condition Sets can incorrectly evaluate telemetry objects that update infre (#8064)
Condition Sets can incorrectly evaluate telemetry objects that update infrequently (#8041)

* compares latest available data properly for condition calculations

* Added timestamp checking for individual criteria

* Co-authored-by: Pranaykarvi<pranaykarvi@gmail.com>

* Fixed bug with test data

* Replaced legacy tests with new e2e test of correct telemetry evaluation

* Fixed long-standing bug with evaluating enums

---------

Co-authored-by: David Tsay <3614296+davetsay@users.noreply.github.com>
2025-05-14 09:09:03 -07:00
fffee68e9c cherry-pick Fix partial matches (#8047) (#8048)
Fix partial matches (#8047)

Support character escaping
2025-04-24 09:57:06 -07:00
9c9329d8b1 cherry-pick VIPERGC 659 couch search indexes (#8037) (#8045)
Couch search indexes (#8037)

* Defined search index for object names. Add index for searching by object type
* Feature detect if views are defined to support optimized search. If not, fall back on filter-based search
* Suppress github codedcov annotations for now, they are not accurate and generate noise.
* Allow nested describes. They're good.
* Add a noop search function to couch search folder object provider. Actual search is provided by Couch provider, but need a stub to prevent in-memory indexing
* Adhere to our own interface and ensure identifiers are always returned by default composition provider
2025-04-23 16:34:54 -07:00
90668a1b46 Cherry pick 7737 - Independent Time Conductor interfering with plots behavior even if not enabled (#7956) (#8044)
Independent time conductor related handling for plot synchronization. (#7956)

* Ensure that the mode set when independent time conductor is enabled/disabled is propagated correctly.

Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
2025-04-23 16:28:54 -07:00
20a6e7eac9 cherry-pick Use the disabled attribute on a valid element - the button. (#7914) (#8043)
Use the disabled attribute on a valid element - the button. (#7914)
* Add e2e test to check for add criteria button being enabled/disabled

Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
2025-04-23 16:26:03 -07:00
3ae9d121a1 modified the sanitizeForSerialization method to remove unnecessary re… (#7950)
modified the sanitizeForSerialization method to remove unnecessary recursion, update e2e test to CORRECTLY test the functionality
2024-12-09 12:37:02 -08:00
f890be64ac In progress activities that are out of bounds are shown (#7945)
If an activity is out of bounds, but in progress, display it in the currently visible list.
2024-12-06 14:41:41 -08:00
bd11b85b6e [Notebook] Browse Bar holding onto stale model, reverts changes (#7944)
* moving rename methods to appActions

* importing back into original test

* reverting

* add the ability to change the name in the browse bar

* add test to verify entries are not being lost

* addding aria labels for tests

* when an object is changed, store the whole new object, not just the name

* typo!
2024-12-06 14:41:33 -08:00
db9c923f79 fix vue reactivity of rows by changing the reference of the updated row (#7940)
* do not call `updateVisibleRows` on horizontal scroll
* add example provider for in place row updates
2024-12-04 11:30:17 -08:00
66b5e6e83c [Telemetry API] Prevent Subscriptions with different options from overwriting each other (#7930)
* initial implementation

* cleaning up a bit

* adding the hash method back as we dont want gigantic keys

* adding a line

* added filtering to state generator, updated filters readme to fix error, more robust hash function

* removing unnecessary changes in wrong file

* adding a test to confirm each endpoint has a separate subscription based of filtering

* lint

* adding back in hints, accidentally removed

* remove some redundant code and convert sanitization method into a replacer function for stringify

* tweaking serialize replacer to handle arrays correctly, adding more determinative row addition check to test

* more focused selector for the table

* simplified the serialization method even further and added some more docs
2024-12-03 20:14:48 -08:00
860c13d23f [Gauge Plugin] Fix Missing Object handling (#7923)
* checking if the metadata exists before acting on it

* added a test to catch missing object errors in gauges

* remove waitForTimeout and add in check for time conductor successful start offset update

* hardening the test by checking for the time before the time change

* add "pageerror" to cspell
2024-12-03 20:14:33 -08:00
49 changed files with 1288 additions and 215 deletions

View File

@ -483,7 +483,8 @@
"countup",
"darkmatter",
"Undeletes",
"SSSZ"
"SSSZ",
"pageerror"
],
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
"ignorePaths": [

View File

@ -1,6 +1,10 @@
codecov:
require_ci_to_pass: false #This setting will update the bot regardless of whether or not tests pass
# Disabling annotations for now. They are incorrectly labelling lines as lacking coverage when they are in fact covered by tests.
github_checks:
annotations: false
coverage:
status:
project:

View File

@ -2,7 +2,6 @@
module.exports = {
extends: ['plugin:playwright/recommended'],
rules: {
'playwright/max-nested-describe': ['error', { max: 1 }],
'playwright/expect-expect': 'off'
},
overrides: [

View File

@ -682,6 +682,21 @@ async function linkParameterToObject(page, parameterName, objectName) {
await page.getByLabel('Save').click();
}
/**
* Rename the currently viewed `domainObject` from the browse bar.
*
* @param {import('@playwright/test').Page} page
* @param {string} newName
*/
async function renameCurrentObjectFromBrowseBar(page, newName) {
const nameInput = page.getByLabel('Browse bar object name');
await nameInput.click();
await nameInput.fill('');
await nameInput.fill(newName);
// Click the browse bar container to save changes
await page.getByLabel('Browse bar', { exact: true }).click();
}
export {
createDomainObjectWithDefaults,
createExampleTelemetryObject,
@ -693,6 +708,7 @@ export {
linkParameterToObject,
navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime,
renameCurrentObjectFromBrowseBar,
setEndOffset,
setFixedIndependentTimeConductorBounds,
setFixedTimeMode,

View File

@ -103,25 +103,40 @@ const extendedTest = test.extend({
* Default: `true`
*/
failOnConsoleError: [true, { option: true }],
ignore404s: [[], { option: true }],
/**
* Extends the base page class to enable console log error detection.
* @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion}
*/
page: async ({ page, failOnConsoleError }, use) => {
page: async ({ page, failOnConsoleError, ignore404s }, use) => {
// Capture any console errors during test execution
const messages = [];
let messages = [];
page.on('console', (msg) => messages.push(msg));
await use(page);
if (ignore404s.length > 0) {
messages = messages.filter((msg) => {
let keep = true;
if (msg.text().match(/404 \((Object )?Not Found\)/) !== null) {
keep = ignore404s.every((ignoreRule) => {
return msg.location().url.match(ignoreRule) === null;
});
}
return keep;
});
}
// Assert against console errors during teardown
if (failOnConsoleError) {
messages.forEach((msg) =>
messages.forEach((msg) => {
// eslint-disable-next-line playwright/no-standalone-expect
expect
.soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`)
.not.toEqual('error')
);
.not.toEqual('error');
});
}
}
});

View File

@ -0,0 +1,67 @@
/*****************************************************************************
* 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, setRealTimeMode } from '../../../appActions.js';
import { MISSION_TIME } from '../../../constants.js';
import { expect, test } from '../../../pluginFixtures.js';
const TELEMETRY_RATE = 2500;
test.describe('Example Event Generator Acknowledge with Controlled Clock @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
await setRealTimeMode(page);
await createDomainObjectWithDefaults(page, {
type: 'Event Message Generator with Acknowledge'
});
});
test('Rows are updatable in place', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7938'
});
await test.step('First telemetry datum gets added as new row', async () => {
await page.clock.fastForward(TELEMETRY_RATE);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).not.toHaveAttribute('title', 'OK');
});
await test.step('Incoming Telemetry datum matching an existing rows in place update key has data merged to existing row', async () => {
await page.clock.fastForward(TELEMETRY_RATE * 2);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).toHaveAttribute('title', 'OK');
});
});
});

View File

@ -27,7 +27,8 @@ demonstrate some playwright for test developers. This pattern should not be re-u
import {
createDomainObjectWithDefaults,
createExampleTelemetryObject
createExampleTelemetryObject,
setRealTimeMode
} from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
@ -116,7 +117,7 @@ test.describe('Basic Condition Set Use', () => {
await page.getByLabel('Conditions View').click();
await expect(page.getByText('Current Output')).toBeVisible();
});
test('ConditionSet has correct outputs when telemetry is and is not available', async ({
test('ConditionSet produces an output when telemetry is available, and does not when it is not', async ({
page
}) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
@ -281,12 +282,142 @@ test.describe('Basic Condition Set Use', () => {
await page.goto(exampleTelemetry.url);
});
test('Short circuit evaluation does not cause incorrect evaluation https://github.com/nasa/openmct/issues/7992', async ({
page
}) => {
await setRealTimeMode(page);
await page.getByLabel('Create', { exact: true }).click();
await page.getByLabel('State Generator').click();
await page.getByLabel('Title', { exact: true }).fill('P1');
await page.getByLabel('State Duration (seconds)').fill('1');
await page.getByLabel('Save').click();
await page.getByLabel('Create', { exact: true }).click();
await page.getByLabel('State Generator').click();
await page.getByLabel('Title', { exact: true }).fill('P2');
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('1');
await page.getByRole('treeitem', { name: 'Test Condition Set' }).click();
await page.getByLabel('Save').click();
await page.getByLabel('Expand My Items folder').click();
await page.getByRole('treeitem', { name: 'Test Condition Set' }).click();
await page.getByLabel('Edit Object').click();
await page.getByLabel('Add Condition').click();
await page.getByLabel('Condition Name Input').first().fill('P1 IS ON AND P2 IS ON');
await page.getByLabel('Criterion Telemetry Selection').selectOption({ label: 'P1' });
await page.getByLabel('Criterion Metadata Selection').selectOption('value');
await page.getByLabel('Criterion Comparison Selection').selectOption('equalTo');
await page.getByLabel('Criterion Input').fill('1');
await page.getByLabel('Add Criteria - Enabled').click();
await page.getByLabel('Criterion Telemetry Selection').nth(1).selectOption({ label: 'P2' });
await page.getByLabel('Criterion Metadata Selection').nth(1).selectOption('value');
await page.getByLabel('Criterion Comparison Selection').nth(1).selectOption('equalTo');
await page.getByLabel('Criterion Input').nth(1).fill('1');
await page.getByLabel('Add Condition').click();
await page.getByLabel('Condition Name Input').first().fill('P1 IS OFF OR P2 IS OFF');
await page.getByLabel('Condition Trigger').first().selectOption('any');
await page.getByLabel('Criterion Telemetry Selection').first().selectOption({ label: 'P1' });
await page.getByLabel('Criterion Metadata Selection').first().selectOption('value');
await page.getByLabel('Criterion Comparison Selection').first().selectOption('equalTo');
await page.getByLabel('Criterion Input').first().fill('0');
await page.getByLabel('Add Criteria - Enabled').first().click();
await page.getByLabel('Criterion Telemetry Selection').nth(1).selectOption({ label: 'P2' });
await page.getByLabel('Criterion Metadata Selection').nth(1).selectOption('value');
await page.getByLabel('Criterion Comparison Selection').nth(1).selectOption('equalTo');
await page.getByLabel('Criterion Input').nth(1).fill('0');
await page.getByLabel('Condition Name Input').first().dblclick();
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await page.getByLabel('Edit Object').click();
/**
* Create default conditions for test. Start with invalid values to put condition set into
* "default" state
*/
await page.getByLabel('Test Data Telemetry Selection').selectOption({ label: 'P1' });
await page.getByLabel('Test Data Metadata Selection').selectOption({ label: 'Value' });
await page.getByLabel('Test Data Input').fill('3');
await page.getByLabel('Add Test Datum').click();
await page.getByLabel('Test Data Telemetry Selection').nth(1).selectOption({ label: 'P2' });
await page.getByLabel('Test Data Metadata Selection').nth(1).selectOption({ label: 'Value' });
await page.getByLabel('Test Data Input').nth(1).fill('3');
await page.getByLabel('Apply Test Data').nth(1).click();
let activeCondition = page.getByLabel('Active Condition Set Condition');
let activeConditionName = activeCondition.getByLabel('Condition Name Label');
await expect(activeConditionName).toHaveText('Default');
/**
* Set P1 to 0
*/
await page.getByLabel('Test Data Input').nth(0).fill('0');
activeCondition = page.getByLabel('Active Condition Set Condition');
activeConditionName = activeCondition.getByLabel('Condition Name Label');
await expect(activeConditionName).toHaveText('P1 IS OFF OR P2 IS OFF');
/**
* Set P2 to 1
*/
await page.getByLabel('Test Data Input').nth(1).fill('1');
activeCondition = page.getByLabel('Active Condition Set Condition');
activeConditionName = activeCondition.getByLabel('Condition Name Label');
await expect(activeConditionName).toHaveText('P1 IS OFF OR P2 IS OFF');
/**
* Set P1 to 1
*/
await page.getByLabel('Test Data Input').nth(0).fill('1');
activeCondition = page.getByLabel('Active Condition Set Condition');
activeConditionName = activeCondition.getByLabel('Condition Name Label');
await expect(activeConditionName).toHaveText('P1 IS ON AND P2 IS ON');
});
test.fixme('Ensure condition sets work with telemetry like operator status', ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7484'
});
});
test('ConditionSet has add criteria button enabled/disabled when composition is and is not available', async ({
page
}) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
await page.getByLabel('Show selected item in tree').click();
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Create a condition
await page.locator('#addCondition').click();
await page.locator('#conditionCollection').getByRole('textbox').nth(0).fill('First Condition');
// Validate that the add criteria button is disabled
await expect(page.getByLabel('Add Criteria - Disabled')).toHaveAttribute('disabled');
// Add Telemetry to ConditionSet
const sineWaveGeneratorTreeItem = page
.getByRole('tree', {
name: 'Main Tree'
})
.getByRole('treeitem', {
name: exampleTelemetry.name
});
const conditionCollection = page.locator('#conditionCollection');
await sineWaveGeneratorTreeItem.dragTo(conditionCollection);
// Validate that the add criteria button is enabled and adds a new criterion
await expect(page.getByLabel('Add Criteria - Enabled')).not.toHaveAttribute('disabled');
await page.getByLabel('Add Criteria - Enabled').click();
const numOfUnnamedCriteria = await page.getByLabel('Criterion Telemetry Selection').count();
expect(numOfUnnamedCriteria).toEqual(2);
});
});
test.describe('Condition Set Composition', () => {

View File

@ -507,8 +507,140 @@ test.describe('Display Layout', () => {
// In real time mode, we don't fetch annotations at all
await expect.poll(() => networkRequests, { timeout: 10000 }).toHaveLength(0);
});
test('Same objects with different request options have unique subscriptions', async ({
page
}) => {
// Expand My Items
await page.getByLabel('Expand My Items folder').click();
// Create a Display Layout
const displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display'
});
// Create a State Generator, set to higher frequency updates
const stateGenerator = await createDomainObjectWithDefaults(page, {
type: 'State Generator',
name: 'State Generator'
});
const stateGeneratorTreeItem = page.getByRole('treeitem', {
name: stateGenerator.name
});
await stateGeneratorTreeItem.click({ button: 'right' });
await page.getByLabel('Edit Properties...').click();
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('0.1');
await page.getByLabel('Save').click();
// Create a Table for filtering ON values
const tableFilterOnValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter On Value'
});
const tableFilterOnTreeItem = page.getByRole('treeitem', {
name: tableFilterOnValue.name
});
// Create a Table for filtering OFF values
const tableFilterOffValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter Off Value'
});
const tableFilterOffTreeItem = page.getByRole('treeitem', {
name: tableFilterOffValue.name
});
// Navigate to ON filtering table and add state generator and setup filters
await page.goto(tableFilterOnValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '1');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to OFF filtering table and add state generator and setup filters
await page.goto(tableFilterOffValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '0');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to the display layout and edit it
await page.goto(displayLayout.url);
// Add the tables to the display layout
await page.getByLabel('Edit Object').click();
await tableFilterOffTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 300 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 500 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await tableFilterOnTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 100 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 300 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Get the tables so we can verify filtering is working as expected
const tableFilterOn = page.getByLabel(`${tableFilterOnValue.name} Frame`, {
exact: true
});
const tableFilterOff = page.getByLabel(`${tableFilterOffValue.name} Frame`, {
exact: true
});
// Verify filtering is working correctly
// Check that no filtered values appear for at least 2 seconds
const VERIFICATION_TIME = 2000; // 2 seconds
const CHECK_INTERVAL = 100; // Check every 100ms
// Create a promise that will check for filtered values periodically
const checkForCorrectValues = new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const offCount = await tableFilterOn.locator('td[title="OFF"]').count();
const onCount = await tableFilterOff.locator('td[title="ON"]').count();
if (offCount > 0 || onCount > 0) {
clearInterval(interval);
reject(
new Error(
`Found ${offCount} OFF and ${onCount} ON values when expecting 0 OFF and 0 ON`
)
);
}
}, CHECK_INTERVAL);
// After VERIFICATION_TIME, if no filtered values were found, resolve successfully
setTimeout(() => {
clearInterval(interval);
resolve();
}, VERIFICATION_TIME);
});
await expect(checkForCorrectValues).resolves.toBeUndefined();
});
});
async function selectFilterOption(page, filterOption) {
await page.getByRole('tab', { name: 'Filters' }).click();
await page
.getByLabel('Inspector Views')
.locator('li')
.filter({ hasText: 'State Generator' })
.locator('span')
.click();
await page.getByRole('switch').click();
await page.selectOption('select[name="setSelectionThreshold"]', filterOption);
}
async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) {
await expect(page.getByLabel(layoutObject, { exact: true })).toHaveCount(0);
await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject);

View File

@ -28,7 +28,9 @@ import { v4 as uuid } from 'uuid';
import {
createDomainObjectWithDefaults,
createExampleTelemetryObject
createExampleTelemetryObject,
setRealTimeMode,
setStartOffset
} from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
@ -166,6 +168,57 @@ test.describe('Gauge', () => {
);
});
test('Gauge does not break when an object is missing', async ({ page }) => {
// Set up error listeners
const pageErrors = [];
// Listen for uncaught exceptions
page.on('pageerror', (err) => {
pageErrors.push(err.message);
});
await setRealTimeMode(page);
// Create a Gauge
const gauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Gauge with missing object'
});
// Create a Sine Wave Generator in the Gauge with a loading delay
const missingSWG = await createExampleTelemetryObject(page, gauge.uuid);
// Remove the object from local storage
await page.evaluate(
([missingObject]) => {
const mct = localStorage.getItem('mct');
const mctObjects = JSON.parse(mct);
delete mctObjects[missingObject.uuid];
localStorage.setItem('mct', JSON.stringify(mctObjects));
},
[missingSWG]
);
// Verify start bounds
await expect(page.getByLabel('Start offset: 00:30:00')).toBeVisible();
// Nav to the Gauge
await page.goto(gauge.url, { waitUntil: 'domcontentloaded' });
// adjust time bounds and ensure they are updated
await setStartOffset(page, {
startHours: '00',
startMins: '45',
startSecs: '00'
});
// Verify start bounds changed
await expect(page.getByLabel('Start offset: 00:45:00')).toBeVisible();
// // Verify no errors were thrown
expect(pageErrors).toHaveLength(0);
});
test('Gauge enforces composition policy', async ({ page }) => {
// Create a Gauge
await createDomainObjectWithDefaults(page, {

View File

@ -26,7 +26,10 @@ This test suite is dedicated to tests which verify the basic operations surround
import { fileURLToPath } from 'url';
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import {
createDomainObjectWithDefaults,
renameCurrentObjectFromBrowseBar
} from '../../../../appActions.js';
import { copy, paste, selectAll } from '../../../../helper/hotkeys/hotkeys.js';
import * as nbUtils from '../../../../helper/notebookUtils.js';
import { expect, streamToString, test } from '../../../../pluginFixtures.js';
@ -596,4 +599,61 @@ test.describe('Notebook entry tests', () => {
await expect(await page.locator(`text="${TEST_TEXT.repeat(1)}"`).count()).toEqual(1);
await expect(await page.locator(`text="${TEST_TEXT.repeat(2)}"`).count()).toEqual(0);
});
test('When changing the name of a notebook in the browse bar, new notebook changes are not lost', async ({
page
}) => {
const TEST_TEXT = 'Do not lose me!';
const FIRST_NEW_NAME = 'New Name';
const SECOND_NEW_NAME = 'Second New Name';
await page.goto(notebookObject.url);
await page.getByLabel('Expand My Items folder').click();
await renameCurrentObjectFromBrowseBar(page, FIRST_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, FIRST_NEW_NAME);
// enter one entry
await enterAndCommitTextEntry(page, TEST_TEXT);
// verify the entry is present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
// change the name
await renameCurrentObjectFromBrowseBar(page, SECOND_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, SECOND_NEW_NAME);
// verify the entry is still present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
});
});
/**
* Enter text into the last notebook entry and commit it.
*
* @param {import('@playwright/test').Page} page
* @param {string} text
*/
async function enterAndCommitTextEntry(page, text) {
await nbUtils.addNotebookEntry(page);
await nbUtils.enterTextInLastEntry(page, text);
await nbUtils.commitEntry(page);
}
/**
* Verify the name change in the tree and browse bar.
*
* @param {import('@playwright/test').Page} page
* @param {string} newName
*/
async function verifyNameChange(page, newName) {
await expect(
page.getByRole('treeitem').locator('.is-navigated-object .c-tree__item__name')
).toHaveText(newName);
await expect(page.getByLabel('Browse bar object name')).toHaveText(newName);
}

View File

@ -108,4 +108,42 @@ test.describe('Plot Controls', () => {
// Expect before and after plot points to match
await expect(plotPixelSizeAtPause).toEqual(plotPixelSizeAfterWait);
});
/*
Test to verify that switching a plot's time context from global to
its own independent time context and then back to global context works correctly.
After switching from fixed time mode (ITC) to real time mode (global context),
the pause control for the plot should be available, indicating that it is following the right context.
*/
test('Plots follow the right time context', async ({ page }) => {
// Set global time conductor to real-time mode
await setRealTimeMode(page);
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is visible since global time conductor is in Real time mode.
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
// Toggle independent time conductor ON
await page.getByLabel('Enable Independent Time Conductor').click();
// Bring up the independent time conductor popup and switch to fixed time mode
await page.getByLabel('Independent Time Conductor Settings').click();
await page.getByLabel('Independent Time Conductor Mode Menu').click();
await page.getByRole('menuitem', { name: /Fixed Timespan/ }).click();
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is no longer visible since the plot is following the independent time context
await expect(page.getByTitle('Pause incoming real-time data')).toBeHidden();
// Toggle independent time conductor OFF - Note that the global time conductor is still in Real time mode
await page.getByLabel('Disable Independent Time Conductor').click();
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is visible since the global time conductor is in real time mode
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
});
});

View File

@ -31,6 +31,8 @@ import { expect, test } from '../../pluginFixtures.js';
test.describe('Grand Search', () => {
let grandSearchInput;
test.use({ ignore404s: [/_design\/object_names\/_view\/object_names$/] });
test.beforeEach(async ({ page }) => {
grandSearchInput = page
.getByLabel('OpenMCT Search')
@ -191,7 +193,88 @@ test.describe('Grand Search', () => {
await expect(searchResults).toContainText(folderName);
});
test.describe('Search will test for the presence of the object_names index, and', () => {
test('use index if available @couchdb @network', async ({ page }) => {
await createObjectsForSearch(page);
let isObjectNamesViewAvailable = false;
let isObjectNamesUsedForSearch = false;
page.on('request', async (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isObjectNamesRequest && isHeadRequest) {
const response = await request.response();
isObjectNamesViewAvailable = response.status() === 200;
}
});
page.on('request', (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isPostRequest = request.method().toLowerCase() === 'post';
if (isObjectNamesRequest && isPostRequest) {
isObjectNamesUsedForSearch = true;
}
});
// Full search for object
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
// Wait for search to finish
await waitForSearchCompletion(page);
expect(isObjectNamesViewAvailable).toBe(true);
expect(isObjectNamesUsedForSearch).toBe(true);
});
test('fall-back on base index if index not available @couchdb @network', async ({ page }) => {
await page.route('**/_view/object_names', (route) => {
route.fulfill({
status: 404
});
});
await createObjectsForSearch(page);
let isObjectNamesViewAvailable = false;
let isFindUsedForSearch = false;
page.on('request', async (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isObjectNamesRequest && isHeadRequest) {
const response = await request.response();
isObjectNamesViewAvailable = response.status() === 200;
}
});
page.on('request', (request) => {
const isFindRequest = request.url().endsWith('_find');
const isPostRequest = request.method().toLowerCase() === 'post';
if (isFindRequest && isPostRequest) {
isFindUsedForSearch = true;
}
});
// Full search for object
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
// Wait for search to finish
await waitForSearchCompletion(page);
console.info(
`isObjectNamesViewAvailable: ${isObjectNamesViewAvailable} | isFindUsedForSearch: ${isFindUsedForSearch}`
);
expect(isObjectNamesViewAvailable).toBe(false);
expect(isFindUsedForSearch).toBe(true);
});
});
test('Search results are debounced @couchdb @network', async ({ page }) => {
// Unfortunately 404s are always logged to the JavaScript console and can't be suppressed
// A 404 is now thrown when we test for the presence of the object names view used by search.
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6179'
@ -199,11 +282,17 @@ test.describe('Grand Search', () => {
await createObjectsForSearch(page);
let networkRequests = [];
page.on('request', (request) => {
const searchRequest =
request.url().endsWith('_find') || request.url().includes('by_keystring');
const fetchRequest = request.resourceType() === 'fetch';
if (searchRequest && fetchRequest) {
const isSearchRequest =
request.url().endsWith('object_names') ||
request.url().endsWith('_find') ||
request.url().includes('by_keystring');
const isFetchRequest = request.resourceType() === 'fetch';
// CouchDB search results in a one-time head request to test for the presence of an index.
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isSearchRequest && isFetchRequest && !isHeadRequest) {
networkRequests.push(request);
}
});

View File

@ -213,7 +213,6 @@ test.describe('Navigation memory leak is not detected in', () => {
page,
'example-imagery-memory-leak-test'
);
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true);
});
@ -317,6 +316,12 @@ test.describe('Navigation memory leak is not detected in', () => {
// Manually invoke the garbage collector once all references are removed.
window.gc();
window.gc();
window.gc();
setTimeout(() => {
window.gc();
}, 1000);
return gcPromise;
});

View File

@ -46,6 +46,24 @@ class EventMetadataProvider {
]
}
};
const inPlaceUpdateMetadataValue = {
key: 'messageId',
name: 'row identifier',
format: 'string',
useToUpdateInPlace: true
};
const eventAcknowledgeMetadataValue = {
key: 'acknowledge',
name: 'Acknowledge',
format: 'string'
};
const eventGeneratorWithAcknowledge = structuredClone(this.METADATA_BY_TYPE.eventGenerator);
eventGeneratorWithAcknowledge.values.push(inPlaceUpdateMetadataValue);
eventGeneratorWithAcknowledge.values.push(eventAcknowledgeMetadataValue);
this.METADATA_BY_TYPE.eventGeneratorWithAcknowledge = eventGeneratorWithAcknowledge;
}
supportsMetadata(domainObject) {

View File

@ -0,0 +1,70 @@
/*****************************************************************************
* 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.
*****************************************************************************/
/**
* Module defining EventTelemetryProvider. Created by chacskaylo on 06/18/2015.
*/
import EventTelemetryProvider from './EventTelemetryProvider.js';
class EventWithAcknowledgeTelemetryProvider extends EventTelemetryProvider {
constructor() {
super();
this.unAcknowledgedData = undefined;
}
generateData(firstObservedTime, count, startTime, duration, name) {
if (this.unAcknowledgedData === undefined) {
const unAcknowledgedData = super.generateData(
firstObservedTime,
count,
startTime,
duration,
name
);
unAcknowledgedData.messageId = unAcknowledgedData.message;
this.unAcknowledgedData = unAcknowledgedData;
return this.unAcknowledgedData;
} else {
const acknowledgedData = {
...this.unAcknowledgedData,
acknowledge: 'OK'
};
this.unAcknowledgedData = undefined;
return acknowledgedData;
}
}
supportsRequest(domainObject) {
return false;
}
supportsSubscribe(domainObject) {
return domainObject.type === 'eventGeneratorWithAcknowledge';
}
}
export default EventWithAcknowledgeTelemetryProvider;

View File

@ -21,6 +21,7 @@
*****************************************************************************/
import EventMetadataProvider from './EventMetadataProvider.js';
import EventTelemetryProvider from './EventTelemetryProvider.js';
import EventWithAcknowledgeTelemetryProvider from './EventWithAcknowledgeTelemetryProvider.js';
export default function EventGeneratorPlugin(options) {
return function install(openmct) {
@ -38,5 +39,20 @@ export default function EventGeneratorPlugin(options) {
});
openmct.telemetry.addProvider(new EventTelemetryProvider());
openmct.telemetry.addProvider(new EventMetadataProvider());
openmct.types.addType('eventGeneratorWithAcknowledge', {
name: 'Event Message Generator with Acknowledge',
description:
'For development use. Creates sample event message data stream and updates the event row with an acknowledgement.',
cssClass: 'icon-generator-events',
creatable: true,
initialize: function (object) {
object.telemetry = {
duration: 2.5
};
}
});
openmct.telemetry.addProvider(new EventWithAcknowledgeTelemetryProvider());
};
}

View File

@ -108,6 +108,16 @@ const METADATA_BY_TYPE = {
string: 'ON'
}
],
filters: [
{
singleSelectionThreshold: true,
comparator: 'equals',
possibleValues: [
{ label: 'OFF', value: 0 },
{ label: 'ON', value: 1 }
]
}
],
hints: {
range: 1
}

View File

@ -34,14 +34,16 @@ StateGeneratorProvider.prototype.supportsSubscribe = function (domainObject) {
return domainObject.type === 'example.state-generator';
};
StateGeneratorProvider.prototype.subscribe = function (domainObject, callback) {
StateGeneratorProvider.prototype.subscribe = function (domainObject, callback, options) {
var duration = domainObject.telemetry.duration * 1000;
var interval = setInterval(function () {
var interval = setInterval(() => {
var now = Date.now();
var datum = pointForTimestamp(now, duration, domainObject.name);
datum.value = String(datum.value);
callback(datum);
if (!this.shouldBeFiltered(datum, options)) {
datum.value = String(datum.value);
callback(datum);
}
}, duration);
return function () {
@ -63,9 +65,25 @@ StateGeneratorProvider.prototype.request = function (domainObject, options) {
var data = [];
while (start <= end && data.length < 5000) {
data.push(pointForTimestamp(start, duration, domainObject.name));
const point = pointForTimestamp(start, duration, domainObject.name);
if (!this.shouldBeFiltered(point, options)) {
data.push(point);
}
start += duration;
}
return Promise.resolve(data);
};
StateGeneratorProvider.prototype.shouldBeFiltered = function (point, options) {
const valueToFilter = options?.filters?.state?.equals?.[0];
if (!valueToFilter) {
return false;
}
const { value } = point;
return value !== Number(valueToFilter);
};

View File

@ -20,6 +20,8 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { isIdentifier } from '../objects/object-utils';
/**
* @typedef {import('openmct').DomainObject} DomainObject
*/
@ -209,9 +211,15 @@ export default class CompositionCollection {
this.#cleanUpMutables();
const children = await this.#provider.load(this.domainObject);
const childObjects = await Promise.all(
children.map((c) => this.#publicAPI.objects.get(c, abortSignal))
children.map((child) => {
if (isIdentifier(child)) {
return this.#publicAPI.objects.get(child, abortSignal);
} else {
return Promise.resolve(child);
}
})
);
childObjects.forEach((c) => this.add(c, true));
childObjects.forEach((child) => this.add(child, true));
this.#emit('load');
return childObjects;

View File

@ -96,8 +96,9 @@ export default class CompositionProvider {
* object.
* @param {DomainObject} domainObject the domain object
* for which to load composition
* @returns {Promise<Identifier[]>} a promise for
* the Identifiers in this composition
* @returns {Promise<Identifier[] | DomainObject[]>} a promise for
* the Identifiers or Domain Objects in this composition. If Identifiers are returned,
* they will be automatically resolved to domain objects by the API.
*/
load(domainObject) {
throw new Error('This method must be implemented by a subclass.');

View File

@ -21,7 +21,7 @@
*****************************************************************************/
import { toRaw } from 'vue';
import { makeKeyString } from '../objects/object-utils.js';
import { makeKeyString, parseKeyString } from '../objects/object-utils.js';
import CompositionProvider from './CompositionProvider.js';
/**
@ -75,7 +75,11 @@ export default class DefaultCompositionProvider extends CompositionProvider {
* the Identifiers in this composition
*/
load(domainObject) {
return Promise.all(domainObject.composition);
const identifiers = domainObject.composition
.filter((idOrKeystring) => idOrKeystring !== null && idOrKeystring !== undefined)
.map((idOrKeystring) => parseKeyString(idOrKeystring));
return Promise.all(identifiers);
}
/**
* Attach listeners for changes to the composition of a given domain object.

View File

@ -27,6 +27,7 @@ import ConflictError from './ConflictError.js';
import InMemorySearchProvider from './InMemorySearchProvider.js';
import InterceptorRegistry from './InterceptorRegistry.js';
import MutableDomainObject from './MutableDomainObject.js';
import { isIdentifier, isKeyString } from './object-utils.js';
import RootObjectProvider from './RootObjectProvider.js';
import RootRegistry from './RootRegistry.js';
import Transaction from './Transaction.js';
@ -742,11 +743,19 @@ export default class ObjectAPI {
* @param {AbortSignal} abortSignal (optional) signal to abort fetch requests
* @returns {Promise<Array<DomainObject>>} a promise containing an array of domain objects
*/
async getOriginalPath(identifier, path = [], abortSignal = null) {
const domainObject = await this.get(identifier, abortSignal);
async getOriginalPath(identifierOrObject, path = [], abortSignal = null) {
let domainObject;
if (isKeyString(identifierOrObject) || isIdentifier(identifierOrObject)) {
domainObject = await this.get(identifierOrObject, abortSignal);
} else {
domainObject = identifierOrObject;
}
if (!domainObject) {
return [];
}
path.push(domainObject);
const { location } = domainObject;
if (location && !this.#pathContainsDomainObject(location, path)) {

View File

@ -250,6 +250,90 @@ export default class TelemetryAPI {
return options;
}
/**
* Sanitizes objects for consistent serialization by:
* 1. Removing non-plain objects (class instances) and functions
* 2. Sorting object keys alphabetically to ensure consistent ordering
*/
sanitizeForSerialization(key, value) {
// Handle null and primitives directly
if (value === null || typeof value !== 'object') {
return value;
}
// Remove functions and non-plain objects (except arrays)
if (
typeof value === 'function' ||
(Object.getPrototypeOf(value) !== Object.prototype && !Array.isArray(value))
) {
return undefined;
}
// For plain objects, just sort the keys
if (!Array.isArray(value)) {
const sortedObject = {};
const sortedKeys = Object.keys(value).sort();
sortedKeys.forEach((objectKey) => {
sortedObject[objectKey] = value[objectKey];
});
return sortedObject;
}
return value;
}
/**
* Generates a numeric hash value for an options object. The hash is consistent
* for equivalent option objects regardless of property order.
*
* This is used to create compact, unique cache keys for telemetry subscriptions with
* different options configurations. The hash function ensures that identical options
* objects will always generate the same hash value, while different options objects
* (even with small differences) will generate different hash values.
*
* @private
* @param {Object} options The options object to hash
* @returns {number} A positive integer hash of the options object
*/
#hashOptions(options) {
const sanitizedOptionsString = JSON.stringify(
options,
this.sanitizeForSerialization.bind(this)
);
let hash = 0;
const prime = 31;
const modulus = 1e9 + 9; // Large prime number
for (let i = 0; i < sanitizedOptionsString.length; i++) {
const char = sanitizedOptionsString.charCodeAt(i);
// Calculate new hash value while keeping numbers manageable
hash = Math.floor((hash * prime + char) % modulus);
}
return Math.abs(hash);
}
/**
* Generates a unique cache key for a telemetry subscription based on the
* domain object identifier and options (which includes strategy).
*
* Uses a hash of the options object to create compact cache keys while still
* ensuring unique keys for different subscription configurations.
*
* @private
* @param {import('openmct').DomainObject} domainObject The domain object being subscribed to
* @param {Object} options The subscription options object (including strategy)
* @returns {string} A unique key string for caching the subscription
*/
#getSubscriptionCacheKey(domainObject, options) {
const keyString = makeKeyString(domainObject.identifier);
return `${keyString}:${this.#hashOptions(options)}`;
}
/**
* Register a request interceptor that transforms a request via module:openmct.TelemetryAPI.request
* The request will be modified when it is received and will be returned in it's modified state
@ -418,16 +502,14 @@ export default class TelemetryAPI {
this.#subscribeCache = {};
}
const keyString = makeKeyString(domainObject.identifier);
const supportedStrategy = supportsBatching ? requestedStrategy : SUBSCRIBE_STRATEGY.LATEST;
// Override the requested strategy with the strategy supported by the provider
const optionsWithSupportedStrategy = {
...options,
strategy: supportedStrategy
};
// If batching is supported, we need to cache a subscription for each strategy -
// latest and batched.
const cacheKey = `${keyString}:${supportedStrategy}`;
const cacheKey = this.#getSubscriptionCacheKey(domainObject, optionsWithSupportedStrategy);
let subscriber = this.#subscribeCache[cacheKey];
if (!subscriber) {

View File

@ -359,6 +359,18 @@ class IndependentTimeContext extends TimeContext {
}
}
/**
* @returns {boolean}
* @override
*/
isFixed() {
if (this.upstreamTimeContext) {
return this.upstreamTimeContext.isFixed(...arguments);
} else {
return super.isFixed(...arguments);
}
}
/**
* @returns {number}
* @override
@ -400,7 +412,7 @@ class IndependentTimeContext extends TimeContext {
}
/**
* Reset the time context to the global time context
* Reset the time context from the global time context
*/
resetContext() {
if (this.upstreamTimeContext) {
@ -428,6 +440,10 @@ class IndependentTimeContext extends TimeContext {
// Emit bounds so that views that are changing context get the upstream bounds
this.emit('bounds', this.getBounds());
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
// Also emit the mode in case it's different from previous time context
if (this.getMode()) {
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
}
}
/**
@ -502,6 +518,10 @@ class IndependentTimeContext extends TimeContext {
// Emit bounds so that views that are changing context get the upstream bounds
this.emit('bounds', this.getBounds());
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
// Also emit the mode in case it's different from the global time context
if (this.getMode()) {
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
}
// now that the view's context is set, tell others to check theirs in case they were following this view's context.
this.globalTimeContext.emit('refreshContext', viewKey);
}

View File

@ -23,6 +23,7 @@
import { FIXED_MODE_KEY, REALTIME_MODE_KEY } from '@/api/time/constants';
import IndependentTimeContext from '@/api/time/IndependentTimeContext';
import { TIME_CONTEXT_EVENTS } from './constants';
import GlobalTimeContext from './GlobalTimeContext.js';
/**
@ -142,7 +143,7 @@ class TimeAPI extends GlobalTimeContext {
addIndependentContext(key, value, clockKey) {
let timeContext = this.getIndependentContext(key);
//stop following upstream time context since the view has it's own
//stop following upstream time context since the view has its own
timeContext.resetContext();
if (clockKey) {
@ -152,6 +153,9 @@ class TimeAPI extends GlobalTimeContext {
timeContext.setMode(FIXED_MODE_KEY, value);
}
// Also emit the mode in case it's different from the previous time context
timeContext.emit(TIME_CONTEXT_EVENTS.modeChanged, structuredClone(timeContext.getMode()));
// Notify any nested views to update, pass in the viewKey so that particular view can skip getting an upstream context
this.emit('refreshContext', key);

View File

@ -24,6 +24,9 @@ export default function (folderName, couchPlugin, searchFilter) {
location: 'ROOT'
});
}
},
search() {
return Promise.resolve([]);
}
});
@ -35,9 +38,17 @@ export default function (folderName, couchPlugin, searchFilter) {
);
},
load() {
return couchProvider.getObjectsByFilter(searchFilter).then((objects) => {
return objects.map((object) => object.identifier);
});
let searchResults;
if (searchFilter.viewName !== undefined) {
// Use a view to search, instead of an _all_docs find
searchResults = couchProvider.getObjectsByView(searchFilter);
} else {
// Use the _find endpoint to search _all_docs
searchResults = couchProvider.getObjectsByFilter(searchFilter);
}
return searchResults;
}
});
};

View File

@ -44,48 +44,57 @@ import { getLatestTimestamp } from './utils/time.js';
* }
*/
export default class Condition extends EventEmitter {
#definition;
/**
* Manages criteria and emits the result of - true or false - based on criteria evaluated.
* @constructor
* @param conditionConfiguration: {id: uuid,trigger: enum, criteria: Array of {id: uuid, operation: enum, input: Array, metaDataKey: string, key: {domainObject.identifier} }
* @param definition: {id: uuid,trigger: enum, criteria: Array of {id: uuid, operation: enum, input: Array, metaDataKey: string, key: {domainObject.identifier} }
* @param openmct
* @param conditionManager
*/
constructor(conditionConfiguration, openmct, conditionManager) {
constructor(definition, openmct, conditionManager) {
super();
this.openmct = openmct;
this.conditionManager = conditionManager;
this.id = conditionConfiguration.id;
this.criteria = [];
this.result = undefined;
this.timeSystems = this.openmct.time.getAllTimeSystems();
if (conditionConfiguration.configuration.criteria) {
this.createCriteria(conditionConfiguration.configuration.criteria);
this.#definition = definition;
if (definition.configuration.criteria) {
this.createCriteria(definition.configuration.criteria);
}
this.trigger = conditionConfiguration.configuration.trigger;
this.trigger = definition.configuration.trigger;
this.summary = '';
this.handleCriterionUpdated = this.handleCriterionUpdated.bind(this);
this.handleOldTelemetryCriterion = this.handleOldTelemetryCriterion.bind(this);
this.handleTelemetryStaleness = this.handleTelemetryStaleness.bind(this);
}
get id() {
return this.#definition.id;
}
get configuration() {
return this.#definition.configuration;
}
updateResult(datum) {
if (!datum || !datum.id) {
updateResult(latestDataTable, telemetryIdThatChanged) {
if (!latestDataTable) {
console.log('no data received');
return;
}
// if all the criteria in this condition have no telemetry, we want to force the condition result to evaluate
if (this.hasNoTelemetry() || this.isTelemetryUsed(datum.id)) {
if (this.hasNoTelemetry() || this.isTelemetryUsed(telemetryIdThatChanged)) {
const currentTimeSystemKey = this.openmct.time.getTimeSystem().key;
this.criteria.forEach((criterion) => {
if (this.isAnyOrAllTelemetry(criterion)) {
criterion.updateResult(datum, this.conditionManager.telemetryObjects);
criterion.updateResult(latestDataTable, this.conditionManager.telemetryObjects);
} else {
if (criterion.usesTelemetry(datum.id)) {
criterion.updateResult(datum);
const relevantDatum = latestDataTable.get(criterion.telemetryObjectIdAsString);
if (criterion.shouldUpdateResult(relevantDatum, currentTimeSystemKey)) {
criterion.updateResult(relevantDatum, currentTimeSystemKey);
}
}
});
@ -102,9 +111,11 @@ export default class Condition extends EventEmitter {
}
hasNoTelemetry() {
return this.criteria.every((criterion) => {
return !this.isAnyOrAllTelemetry(criterion) && criterion.telemetry === '';
const usesSomeTelemetry = this.criteria.some((criterion) => {
return this.isAnyOrAllTelemetry(criterion) || criterion.telemetry !== '';
});
return !usesSomeTelemetry;
}
isTelemetryUsed(id) {
@ -182,7 +193,7 @@ export default class Condition extends EventEmitter {
findCriterion(id) {
let criterion;
for (let i = 0, ii = this.criteria.length; i < ii; i++) {
for (let i = 0; i < this.criteria.length; i++) {
if (this.criteria[i].id === id) {
criterion = {
item: this.criteria[i],
@ -247,7 +258,7 @@ export default class Condition extends EventEmitter {
this.timeSystems,
this.openmct.time.getTimeSystem()
);
this.conditionManager.updateCurrentCondition(latestTimestamp);
this.conditionManager.updateCurrentCondition(latestTimestamp, this);
}
handleTelemetryStaleness() {

View File

@ -27,6 +27,12 @@ import Condition from './Condition.js';
import { getLatestTimestamp } from './utils/time.js';
export default class ConditionManager extends EventEmitter {
#latestDataTable = new Map();
/**
* @param {import('openmct.js').DomainObject} conditionSetDomainObject
* @param {import('openmct.js').OpenMCT} openmct
*/
constructor(conditionSetDomainObject, openmct) {
super();
this.openmct = openmct;
@ -304,22 +310,6 @@ export default class ConditionManager extends EventEmitter {
this.persistConditions();
}
getCurrentCondition() {
const conditionCollection = this.conditionSetDomainObject.configuration.conditionCollection;
let currentCondition = conditionCollection[conditionCollection.length - 1];
for (let i = 0; i < conditionCollection.length - 1; i++) {
const condition = this.findConditionById(conditionCollection[i].id);
if (condition.result) {
//first condition to be true wins
currentCondition = conditionCollection[i];
break;
}
}
return currentCondition;
}
getCurrentConditionLAD(conditionResults) {
const conditionCollection = this.conditionSetDomainObject.configuration.conditionCollection;
let currentCondition = conditionCollection[conditionCollection.length - 1];
@ -410,26 +400,34 @@ export default class ConditionManager extends EventEmitter {
const normalizedDatum = this.createNormalizedDatum(datum, endpoint);
const timeSystemKey = this.openmct.time.getTimeSystem().key;
let timestamp = {};
const currentTimestamp = normalizedDatum[timeSystemKey];
const timestamp = {};
timestamp[timeSystemKey] = currentTimestamp;
this.#latestDataTable.set(normalizedDatum.id, normalizedDatum);
if (this.shouldEvaluateNewTelemetry(currentTimestamp)) {
this.updateConditionResults(normalizedDatum);
this.updateCurrentCondition(timestamp);
const matchingCondition = this.updateConditionResults(normalizedDatum.id);
this.updateCurrentCondition(timestamp, matchingCondition);
}
}
updateConditionResults(normalizedDatum) {
updateConditionResults(keyStringForUpdatedTelemetryObject) {
//We want to stop when the first condition evaluates to true.
this.conditions.some((condition) => {
condition.updateResult(normalizedDatum);
const matchingCondition = this.conditions.find((condition) => {
condition.updateResult(this.#latestDataTable, keyStringForUpdatedTelemetryObject);
return condition.result === true;
});
return matchingCondition;
}
updateCurrentCondition(timestamp) {
const currentCondition = this.getCurrentCondition();
updateCurrentCondition(timestamp, matchingCondition) {
const conditionCollection = this.conditionSetDomainObject.configuration.conditionCollection;
const defaultCondition = conditionCollection[conditionCollection.length - 1];
const currentCondition = matchingCondition || defaultCondition;
this.emit(
'conditionSetResultUpdated',
@ -444,11 +442,13 @@ export default class ConditionManager extends EventEmitter {
);
}
getTestData(metadatum) {
getTestData(metadatum, identifier) {
let data = undefined;
if (this.testData.applied) {
const found = this.testData.conditionTestInputs.find(
(testInput) => testInput.metadata === metadatum.source
(testInput) =>
testInput.metadata === metadatum.source &&
this.openmct.objects.areIdsEqual(testInput.telemetry, identifier)
);
if (found) {
data = found.value;
@ -463,7 +463,7 @@ export default class ConditionManager extends EventEmitter {
const metadata = this.openmct.telemetry.getMetadata(endpoint).valueMetadatas;
const normalizedDatum = Object.values(metadata).reduce((datum, metadatum) => {
const testValue = this.getTestData(metadatum);
const testValue = this.getTestData(metadatum, endpoint.identifier);
const formatter = this.openmct.telemetry.getValueFormatter(metadatum);
datum[metadatum.key] =
testValue !== undefined
@ -480,7 +480,7 @@ export default class ConditionManager extends EventEmitter {
updateTestData(testData) {
if (!_.isEqual(testData, this.testData)) {
this.testData = testData;
this.testData = JSON.parse(JSON.stringify(testData));
this.openmct.objects.mutate(
this.conditionSetDomainObject,
'configuration.conditionTestData',

View File

@ -53,6 +53,7 @@ describe('The condition', function () {
valueMetadatas: [
{
key: 'some-key',
source: 'some-key',
name: 'Some attribute',
hints: {
range: 2
@ -60,6 +61,7 @@ describe('The condition', function () {
},
{
key: 'utc',
source: 'utc',
name: 'Time',
format: 'utc',
hints: {
@ -88,17 +90,32 @@ describe('The condition', function () {
openmct.telemetry = jasmine.createSpyObj('telemetry', [
'isTelemetryObject',
'subscribe',
'getMetadata'
'getMetadata',
'getValueFormatter'
]);
openmct.telemetry.isTelemetryObject.and.returnValue(true);
openmct.telemetry.subscribe.and.returnValue(function () {});
openmct.telemetry.getMetadata.and.returnValue(testTelemetryObject.telemetry);
openmct.telemetry.getValueFormatter.and.callFake((metadatum) => {
return {
parse(input) {
return input;
}
};
});
mockTimeSystems = {
key: 'utc'
};
openmct.time = jasmine.createSpyObj('time', ['getAllTimeSystems', 'on', 'off']);
openmct.time = jasmine.createSpyObj('time', [
'getTimeSystem',
'getAllTimeSystems',
'on',
'off'
]);
openmct.time.getTimeSystem.and.returnValue({ key: 'utc' });
openmct.time.getAllTimeSystems.and.returnValue([mockTimeSystems]);
//openmct.time.getTimeSystem.and.returnValue();
openmct.time.on.and.returnValue(() => {});
openmct.time.off.and.returnValue(() => {});
@ -113,7 +130,7 @@ describe('The condition', function () {
id: '1234-5678-9999-0000',
operation: 'equalTo',
input: ['0'],
metadata: 'value',
metadata: 'testSource',
telemetry: testTelemetryObject.identifier
}
]
@ -156,37 +173,24 @@ describe('The condition', function () {
expect(conditionObj.criteria.length).toEqual(0);
});
it('gets the result of a condition when new telemetry data is received', function () {
conditionObj.updateResult({
value: '0',
utc: 'Hi',
id: testTelemetryObject.identifier.key
});
expect(conditionObj.result).toBeTrue();
});
it('gets the result of a condition when new telemetry data is received', function () {
conditionObj.updateResult({
value: '1',
utc: 'Hi',
id: testTelemetryObject.identifier.key
});
expect(conditionObj.result).toBeFalse();
});
it('keeps the old result new telemetry data is not used by it', function () {
conditionObj.updateResult({
const latestDataTable = new Map();
latestDataTable.set(testTelemetryObject.identifier.key, {
value: '0',
utc: 'Hi',
id: testTelemetryObject.identifier.key
});
conditionObj.updateResult(latestDataTable, testTelemetryObject.identifier.key);
expect(conditionObj.result).toBeTrue();
conditionObj.updateResult({
latestDataTable.set('1234', {
value: '1',
utc: 'Hi',
id: '1234'
});
conditionObj.updateResult(latestDataTable, '1234');
expect(conditionObj.result).toBeTrue();
});
});

View File

@ -24,7 +24,7 @@
<div
class="c-condition-h"
:class="{ 'is-drag-target': draggingOver }"
aria-label="Condition Set Condition"
:aria-label="conditionSetLabel"
@dragover.prevent
@drop.prevent="dropCondition($event, conditionIndex)"
@dragenter="dragEnter($event, conditionIndex)"
@ -53,7 +53,9 @@
@click="expanded = !expanded"
></span>
<span class="c-condition__name">{{ condition.configuration.name }}</span>
<span class="c-condition__name" aria-label="Condition Name Label">{{
condition.configuration.name
}}</span>
<span class="c-condition__summary">
<template v-if="!condition.isDefault && !canEvaluateCriteria"> Define criteria </template>
<span v-else>
@ -160,8 +162,10 @@
</div>
</template>
<div class="c-cdef__separator c-row-separator"></div>
<div class="c-cdef__controls" :disabled="!telemetry.length">
<div class="c-cdef__controls">
<button
:disabled="!telemetry.length"
:aria-label="`Add Criteria - ${!telemetry.length ? 'Disabled' : 'Enabled'}`"
class="c-cdef__add-criteria-button c-button c-button--labeled icon-plus"
@click="addCriteria"
>
@ -257,6 +261,17 @@ export default {
};
},
computed: {
conditionSetLabel() {
let label;
if (this.condition.id === this.currentConditionId) {
label = 'Active Condition Set Condition';
} else {
label = 'Condition Set Condition';
}
return label;
},
triggers() {
const keys = Object.keys(TRIGGER);
const triggerOptions = [];

View File

@ -114,7 +114,7 @@
class="c-button c-button--major icon-plus labeled"
@click="addTestInput"
>
<span class="c-cs-button__label">Add Test Datum</span>
<span class="c-cs-button__label" aria-label="Add Test Datum">Add Test Datum</span>
</button>
</div>
</section>

View File

@ -181,13 +181,20 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
if (validatedData && !this.isStalenessCheck()) {
if (this.isOldCheck()) {
if (this.ageCheck?.[validatedData.id]) {
this.ageCheck[validatedData.id].update(validatedData);
}
Object.keys(this.telemetryDataCache).forEach((objectIdKeystring) => {
if (this.ageCheck?.[objectIdKeystring]) {
this.ageCheck[objectIdKeystring].update(validatedData[objectIdKeystring]);
}
this.telemetryDataCache[validatedData.id] = false;
this.telemetryDataCache[objectIdKeystring] = false;
});
} else {
this.telemetryDataCache[validatedData.id] = this.computeResult(validatedData);
Object.keys(this.telemetryDataCache).forEach((objectIdKeystring) => {
const telemetryObject = telemetryObjects[objectIdKeystring];
this.telemetryDataCache[objectIdKeystring] = this.computeResult(
this.createNormalizedDatum(validatedData[objectIdKeystring], telemetryObject)
);
});
}
}

View File

@ -29,21 +29,29 @@ import { getOperatorText, OPERATIONS } from '../utils/operations.js';
import { checkIfOld } from '../utils/time.js';
export default class TelemetryCriterion extends EventEmitter {
#lastUpdated;
#lastTimeSystem;
#comparator;
/**
* Subscribes/Unsubscribes to telemetry and emits the result
* of operations performed on the telemetry data returned and a given input value.
* @constructor
* @param telemetryDomainObjectDefinition {id: uuid, operation: enum, input: Array, metadata: string, key: {domainObject.identifier} }
* @param openmct
* @param {import('../../../MCT.js').OpenMCT} openmct
*/
constructor(telemetryDomainObjectDefinition, openmct) {
super();
/**
* @type {import('../../../MCT.js').MCT}
*/
this.openmct = openmct;
this.telemetryDomainObjectDefinition = telemetryDomainObjectDefinition;
this.id = telemetryDomainObjectDefinition.id;
this.telemetry = telemetryDomainObjectDefinition.telemetry;
this.operation = telemetryDomainObjectDefinition.operation;
this.#comparator = this.#findOperation(this.operation);
this.input = telemetryDomainObjectDefinition.input;
this.metadata = telemetryDomainObjectDefinition.metadata;
this.result = undefined;
@ -83,7 +91,6 @@ export default class TelemetryCriterion extends EventEmitter {
if (this.ageCheck) {
this.ageCheck.clear();
}
this.ageCheck = checkIfOld(this.handleOldTelemetry.bind(this), this.input[0] * 1000);
}
@ -153,7 +160,6 @@ export default class TelemetryCriterion extends EventEmitter {
createNormalizedDatum(telemetryDatum, endpoint) {
const id = this.openmct.objects.makeKeyString(endpoint.identifier);
const metadata = this.openmct.telemetry.getMetadata(endpoint).valueMetadatas;
const normalizedDatum = Object.values(metadata).reduce((datum, metadatum) => {
const formatter = this.openmct.telemetry.getValueFormatter(metadatum);
datum[metadatum.key] = formatter.parse(telemetryDatum[metadatum.source]);
@ -179,9 +185,18 @@ export default class TelemetryCriterion extends EventEmitter {
return datum;
}
shouldUpdateResult(datum, timesystem) {
const dataIsDefined = datum !== undefined;
const hasTimeSystemChanged =
this.#lastTimeSystem === undefined || this.#lastTimeSystem !== timesystem;
const isCacheStale = this.#lastUpdated === undefined || datum[timesystem] > this.#lastUpdated;
updateResult(data) {
const validatedData = this.isValid() ? data : {};
return dataIsDefined && (hasTimeSystemChanged || isCacheStale);
}
updateResult(data, currentTimeSystemKey) {
const validatedData = this.isValid()
? this.createNormalizedDatum(data, this.telemetryObject)
: {};
if (!this.isStalenessCheck()) {
if (this.isOldCheck()) {
@ -193,6 +208,8 @@ export default class TelemetryCriterion extends EventEmitter {
} else {
this.result = this.computeResult(validatedData);
}
this.#lastUpdated = data[currentTimeSystemKey];
this.#lastTimeSystem = currentTimeSystemKey;
}
}
@ -236,8 +253,8 @@ export default class TelemetryCriterion extends EventEmitter {
});
}
findOperation(operation) {
for (let i = 0, ii = OPERATIONS.length; i < ii; i++) {
#findOperation(operation) {
for (let i = 0; i < OPERATIONS.length; i++) {
if (operation === OPERATIONS[i].name) {
return OPERATIONS[i].operation;
}
@ -249,15 +266,14 @@ export default class TelemetryCriterion extends EventEmitter {
computeResult(data) {
let result = false;
if (data) {
let comparator = this.findOperation(this.operation);
let params = [];
params.push(data[this.metadata]);
if (this.isValidInput()) {
this.input.forEach((input) => params.push(input));
}
if (typeof comparator === 'function') {
result = Boolean(comparator(params));
if (typeof this.#comparator === 'function') {
result = Boolean(this.#comparator(params));
}
}

View File

@ -106,7 +106,7 @@ describe('The telemetry criterion', function () {
id: 'test-criterion-id',
telemetry: openmct.objects.makeKeyString(testTelemetryObject.identifier),
operation: 'textContains',
metadata: 'value',
metadata: 'testSource',
input: ['Hell'],
telemetryObjects: { [testTelemetryObject.identifier.key]: testTelemetryObject }
};

View File

@ -27,9 +27,9 @@ To define a filter, you'll need to add a new `filter` property to the domain obj
singleSelectionThreshold: true,
comparator: 'equals',
possibleValues: [
{ name: 'Apple', value: 'apple' },
{ name: 'Banana', value: 'banana' },
{ name: 'Orange', value: 'orange' }
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' }
]
}]
}

View File

@ -649,6 +649,11 @@ export default {
},
request(domainObject = this.telemetryObject) {
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
if (!this.metadata) {
return;
}
this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
const LimitEvaluator = this.openmct.telemetry.getLimits(domainObject);
LimitEvaluator.limits().then(this.updateLimits);

View File

@ -434,16 +434,69 @@ class CouchObjectProvider {
return Promise.resolve([]);
}
async getObjectsByView({ designDoc, viewName, keysToSearch }, abortSignal) {
const stringifiedKeys = JSON.stringify(keysToSearch);
const url = `${this.url}/_design/${designDoc}/_view/${viewName}?keys=${stringifiedKeys}&include_docs=true`;
async isViewDefined(designDoc, viewName) {
const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
const response = await fetch(url, {
method: 'HEAD'
});
return response.ok;
}
/**
* @typedef GetObjectByViewOptions
* @property {String} designDoc the name of the design document that the view belongs to
* @property {String} viewName
* @property {Array.<String>} [keysToSearch] a list of discrete view keys to search for. View keys are not object identifiers.
* @property {String} [startKey] limit the search to a range of keys starting with the provided `startKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
* @property {String} [endKey] limit the search to a range of keys ending with the provided `endKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
* @property {Number} [limit] limit the number of results returned
* @property {String} [objectIdField] The field (either key or value) to treat as an object key. If provided, include_docs will be set to false in the request, and the field will be used as an object identifier. A bulk request will be used to resolve objects from identifiers
*/
/**
* Return objects based on a call to a view. See https://docs.couchdb.org/en/stable/api/ddoc/views.html.
* @param {GetObjectByViewOptions} options
* @param {AbortSignal} abortSignal
* @returns {Promise<Array.<import('openmct.js').DomainObject>>}
*/
async getObjectsByView(
{ designDoc, viewName, keysToSearch, startKey, endKey, limit, objectIdField },
abortSignal
) {
let stringifiedKeys = JSON.stringify(keysToSearch);
const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
const requestBody = {};
let requestBodyString;
if (objectIdField === undefined) {
requestBody.include_docs = true;
}
if (limit !== undefined) {
requestBody.limit = limit;
}
if (startKey !== undefined && endKey !== undefined) {
/* spell-checker: disable */
requestBody.startkey = startKey;
requestBody.endkey = endKey;
requestBodyString = JSON.stringify(requestBody);
requestBodyString = requestBodyString.replace('$START_KEY', startKey);
requestBodyString = requestBodyString.replace('$END_KEY', endKey);
/* spell-checker: enable */
} else {
requestBody.keys = stringifiedKeys;
requestBodyString = JSON.stringify(requestBody);
}
let objectModels = [];
try {
const response = await fetch(url, {
method: 'GET',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortSignal
signal: abortSignal,
body: requestBodyString
});
if (!response.ok) {
@ -454,13 +507,21 @@ class CouchObjectProvider {
const result = await response.json();
const couchRows = result.rows;
couchRows.forEach((couchRow) => {
const couchDoc = couchRow.doc;
const objectModel = this.#getModel(couchDoc);
if (objectModel) {
objectModels.push(objectModel);
}
});
if (objectIdField !== undefined) {
const objectIdsToResolve = [];
couchRows.forEach((couchRow) => {
objectIdsToResolve.push(couchRow[objectIdField]);
});
objectModels = Object.values(await this.#bulkGet(objectIdsToResolve), abortSignal);
} else {
couchRows.forEach((couchRow) => {
const couchDoc = couchRow.doc;
const objectModel = this.#getModel(couchDoc);
if (objectModel) {
objectModels.push(objectModel);
}
});
}
} catch (error) {
// do nothing
}

View File

@ -33,7 +33,11 @@ class CouchSearchProvider {
#bulkPromise;
#batchIds;
#lastAbortSignal;
#isSearchByNameViewDefined;
/**
*
* @param {import('./CouchObjectProvider').default} couchObjectProvider
*/
constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
@ -67,18 +71,47 @@ class CouchSearchProvider {
}
}
searchForObjects(query, abortSignal) {
const filter = {
selector: {
model: {
name: {
$regex: `(?i)${query}`
#isOptimizedSearchByNameSupported() {
let isOptimizedSearchAvailable;
if (this.#isSearchByNameViewDefined === undefined) {
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined =
this.couchObjectProvider.isViewDefined('object_names', 'object_names');
} else {
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined;
}
return isOptimizedSearchAvailable;
}
async searchForObjects(query, abortSignal) {
const preparedQuery = query.toLowerCase().trim();
const supportsOptimizedSearchByName = await this.#isOptimizedSearchByNameSupported();
if (supportsOptimizedSearchByName) {
return this.couchObjectProvider.getObjectsByView(
{
designDoc: 'object_names',
viewName: 'object_names',
startKey: preparedQuery,
endKey: preparedQuery + `\ufff0`,
objectIdField: 'value',
limit: 1000
},
abortSignal
);
} else {
const filter = {
selector: {
model: {
name: {
$regex: `(?i)${query}`
}
}
}
}
};
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
};
return this.couchObjectProvider.getObjectsByFilter(filter);
}
}
async #deferBatchAnnotationSearch() {

View File

@ -373,44 +373,6 @@ describe('the plugin', () => {
expect(requestMethod).toEqual('PUT');
});
});
describe('implements server-side search', () => {
let mockPromise;
beforeEach(() => {
mockPromise = Promise.resolve({
body: {
getReader() {
return {
read() {
return Promise.resolve({
done: true,
value: undefined
});
}
};
}
}
});
fetch.and.returnValue(mockPromise);
});
it("using Couch's 'find' endpoint", async () => {
await Promise.all(openmct.objects.search('test'));
const requestUrl = fetch.calls.mostRecent().args[0];
// we only want one call to fetch, not 2!
// see https://github.com/nasa/openmct/issues/4667
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestUrl.endsWith('_find')).toBeTrue();
});
it('and supports search by object name', async () => {
await Promise.all(openmct.objects.search('test'));
const requestPayload = JSON.parse(fetch.calls.mostRecent().args[1].body);
expect(requestPayload).toBeDefined();
expect(requestPayload.selector.model.name.$regex).toEqual('(?i)test');
});
});
});
describe('the view', () => {

View File

@ -99,6 +99,48 @@ create_replicator_table() {
add_index_and_views() {
echo "Adding index and views to $OPENMCT_DATABASE_NAME database"
# Add object names search index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_names/\
--header 'Content-Type: application/json' \
--data '{
"_id":"_design/object_names",
"views":{
"object_names":{
"map":"function(doc) { if (doc.model && doc.model.name) { const name = doc.model.name.toLowerCase().trim(); if (name.length > 0) { emit(name, doc._id); const tokens = name.split(/[^a-zA-Z0-9]/); tokens.forEach((token) => { if (token.length > 0) { emit(token, doc._id); } }); } } }"
}
}
}');
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created object_names"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "object_names already exists, skipping creation"
else
echo "Unable to create object_names"
echo $response
fi
# Add object types search index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_types/\
--header 'Content-Type: application/json' \
--data '{
"_id":"_design/object_types",
"views":{
"object_types":{
"map":"function(doc) { if (doc.model && doc.model.type) { const type = doc.model.type.toLowerCase().trim(); if (type.length > 0) { emit(type, null); } } }"
}
}
}')
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created object_types"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "object_types already exists, skipping creation"
else
echo "Unable to create object_types"
echo $response
fi
# Add type_tags_index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request POST "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_index/\
--header 'Content-Type: application/json' \

View File

@ -537,6 +537,7 @@ export default {
this.followTimeContext();
},
followTimeContext() {
this.updateMode();
this.updateDisplayBounds(this.timeContext.getBounds());
this.timeContext.on('modeChanged', this.updateMode);
this.timeContext.on('boundsChanged', this.updateDisplayBounds);

View File

@ -91,15 +91,19 @@ export default class TelemetryTableRow {
return [VIEW_DATUM_ACTION_KEY, VIEW_HISTORICAL_DATA_ACTION_KEY];
}
updateWithDatum(updatesToDatum) {
const normalizedUpdatesToDatum = createNormalizedDatum(updatesToDatum, this.columns);
/**
* Merges the row parameter's datum with the current row datum
* @param {TelemetryTableRow} row
*/
updateWithDatum(row) {
this.datum = {
...this.datum,
...normalizedUpdatesToDatum
...row.datum
};
this.fullDatum = {
...this.fullDatum,
...updatesToDatum
...row.fullDatum
};
}
}

View File

@ -23,6 +23,11 @@ import { EventEmitter } from 'eventemitter3';
import _ from 'lodash';
import { ORDER } from '../constants.js';
/**
* @typedef {import('.TelemetryTableRow.js').default} TelemetryTableRow
*/
/**
* @constructor
*/
@ -124,10 +129,22 @@ export default class TableRowCollection extends EventEmitter {
return foundIndex;
}
updateRowInPlace(row, index) {
const foundRow = this.rows[index];
foundRow.updateWithDatum(row.datum);
this.rows[index] = foundRow;
/**
* `incomingRow` exists in the collection,
* so merge existing and incoming row properties
*
* Do to reactivity of Vue, we want to replace the existing row with the updated row
* @param {TelemetryTableRow} incomingRow to update
* @param {number} index of the existing row in the collection to update
*/
updateRowInPlace(incomingRow, index) {
// Update the incoming row, not the existing row
const existingRow = this.rows[index];
incomingRow.updateWithDatum(existingRow);
// Replacing the existing row with the updated, incoming row will trigger Vue reactivity
// because the reference to the row has changed
this.rows.splice(index, 1, incomingRow);
}
setLimit(rowLimit) {

View File

@ -373,7 +373,6 @@ export default {
configuredColumnWidths: configuration.columnWidths,
sizingRows: {},
rowHeight: ROW_HEIGHT,
scrollOffset: 0,
totalHeight: 0,
totalWidth: 0,
rowOffset: 0,
@ -552,6 +551,7 @@ export default {
//Default sort
this.sortOptions = this.table.tableRows.sortBy();
this.scrollable = this.$refs.scrollable;
this.lastScrollLeft = this.scrollable.scrollLeft;
this.contentTable = this.$refs.contentTable;
this.sizingTable = this.$refs.sizingTable;
this.headersHolderEl = this.$refs.headersHolderEl;
@ -740,7 +740,9 @@ export default {
this.table.sortBy(this.sortOptions);
},
scroll() {
this.throttledUpdateVisibleRows();
if (this.lastScrollLeft === this.scrollable.scrollLeft) {
this.throttledUpdateVisibleRows();
}
this.synchronizeScrollX();
if (this.shouldAutoScroll()) {
@ -765,6 +767,8 @@ export default {
this.scrollable.scrollTop = Number.MAX_SAFE_INTEGER;
},
synchronizeScrollX() {
this.lastScrollLeft = this.scrollable.scrollLeft;
if (this.$refs.headersHolderEl && this.scrollable) {
this.headersHolderEl.scrollLeft = this.scrollable.scrollLeft;
}

View File

@ -243,12 +243,20 @@ export default {
this.timeContext.off(TIME_CONTEXT_EVENTS.modeChanged, this.setTimeOptionsMode);
},
setTimeOptionsClock(clock) {
// If the user has persisted any time options, then don't override them with global settings.
if (this.independentTCEnabled) {
return;
}
this.setTimeOptionsOffsets();
this.timeOptions.clock = clock.key;
},
setTimeOptionsMode(mode) {
this.setTimeOptionsOffsets();
this.timeOptions.mode = mode;
// If the user has persisted any time options, then don't override them with global settings.
if (this.independentTCEnabled) {
this.setTimeOptionsOffsets();
this.timeOptions.mode = mode;
this.isFixed = this.timeOptions.mode === FIXED_MODE_KEY;
}
},
setTimeOptionsOffsets() {
this.timeOptions.clockOffsets =

View File

@ -436,6 +436,9 @@ export default {
return startInBounds || endInBounds || middleInBounds;
},
isActivityInProgress(activity) {
return this.persistedActivityStates[activity.id] === 'in-progress';
},
filterActivities(activity) {
if (this.isEditing) {
return true;
@ -460,7 +463,8 @@ export default {
return false;
}
if (!this.isActivityInBounds(activity)) {
// An activity may be out of bounds, but if it is in-progress, we show it.
if (!this.isActivityInBounds(activity) && !this.isActivityInProgress(activity)) {
return false;
}
//current event or future start event or past end event

View File

@ -20,7 +20,7 @@
at runtime from the About dialog for additional information.
-->
<template>
<div class="l-browse-bar">
<div class="l-browse-bar" aria-label="Browse bar">
<div class="l-browse-bar__start">
<button
v-if="hasParent"
@ -35,6 +35,7 @@
</div>
<span
ref="objectName"
aria-label="Browse bar object name"
class="l-browse-bar__object-name c-object-label__name"
:class="{ 'c-input-inline': isPersistable }"
:contenteditable="isNameEditable"

View File

@ -111,9 +111,8 @@ export default {
return null;
}
const keyStringForObject = this.openmct.objects.makeKeyString(domainObject.identifier);
const originalPathObjects = await this.openmct.objects.getOriginalPath(
keyStringForObject,
domainObject,
[],
abortSignal
);

View File

@ -80,13 +80,11 @@ class Browse {
this.#openmct.layout.$refs.browseBar.viewKey = viewProvider.key;
}
#updateDocumentTitleOnNameMutation(newName) {
if (typeof newName === 'string' && newName !== document.title) {
document.title = newName;
this.#openmct.layout.$refs.browseBar.domainObject = {
...this.#openmct.layout.$refs.browseBar.domainObject,
name: newName
};
#handleBrowseObjectUpdate(newObject) {
this.#openmct.layout.$refs.browseBar.domainObject = newObject;
if (typeof newObject.name === 'string' && newObject.name !== document.title) {
document.title = newObject.name;
}
}
@ -120,8 +118,8 @@ class Browse {
document.title = this.#browseObject.name; //change document title to current object in main view
this.#unobserve = this.#openmct.objects.observe(
this.#browseObject,
'name',
this.#updateDocumentTitleOnNameMutation.bind(this)
'*',
this.#handleBrowseObjectUpdate.bind(this)
);
const currentProvider = this.#openmct.objectViews.getByProviderKey(currentViewKey);
if (currentProvider && currentProvider.canView(this.#browseObject, this.#openmct.router.path)) {