Compare commits

..

29 Commits

Author SHA1 Message Date
538216b5fe Fix logic to purge points less than 1000. 2024-12-02 15:33:31 -08:00
a537b3bf6e Ensure plot points are unique (fix usage of lodash sorted unique tool) 2024-12-02 11:48:11 -08:00
ea9947cab5 Use the disabled attribute on a valid element - the button. (#7914)
* Use the disabled attribute on a valid tag - the button.

* Add e2e test to check for add criteria button being enabled

* Improve test

* Check for add criteria button to have attribute disabled

* Remove focused test
2024-11-05 20:53:28 +00:00
2010f2e377 chore: bump @playwright/test to v1.48.1 (#7913) 2024-10-21 17:03:54 -07:00
3241e9ba57 chore: remove release.yml (#7907) 2024-10-18 15:43:57 -07:00
057a5f997c Encode urls for css background images (#7906)
* Add new utility to encode urls.
Use the encode urls utility to encode all background images in css

* need a commit to pull exampleimagery from

* skip and fix on otherside

---------

Co-authored-by: John Hill <john.c.hill@nasa.gov>
2024-10-18 19:59:02 +00:00
078cd341a5 Bump references to support node22 (#7901)
* Bump references to support node22

* strings!

---------

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2024-10-18 10:07:26 -07:00
518b55cf0f fix(vipergc-660): identify axis keys upon adding object to composition (#7897)
* fix: identify axis keys upon adding object to composition

* fix: set yKey to 'none' if nonArrayValues
2024-10-17 15:08:52 -07:00
3e23dceb64 fix(#7892): restore "now" (marcus bains) line to planning views (#7898)
* Initialize alignment offset to 0. (it was undefined). Also handle a small bug with swimlane configuration not getting replaced when the plan used by a gantt chart was changed

* lint: fix

* test: update visual tests to mock clock and show now line

---------

Co-authored-by: Mazzella, Jesse D. (ARC-TI)[KBR Wyle Services, LLC] <jesse.d.mazzella@nasa.gov>
2024-10-17 14:24:26 -07:00
7f8b5e09e5 Fix gantt chart swimlane order (#7895)
* Use the planObject to get ordered swimlane names

* If there is no plan object, don't try to render the chart

---------

Co-authored-by: Jamie V. <jamie.j.vigliotta@nasa.gov>
2024-10-17 17:28:55 +00:00
7c2bb16bfd Bugfix/7873 time conductor input validation (#7886)
* validate on change because input is too aggressive
* validate logical bounds on submit
* perfection
2024-10-16 18:57:56 -07:00
890ddcac4e Revert d8c5095ebb (#7894) 2024-10-16 09:57:24 -07:00
d8c5095ebb add environment variable check 2024-10-16 09:08:34 -07:00
ccf7ed91af fix(vipergc-574): Use selected shelve duration for fault management (#7890)
* refactor: `Indefinite` -> `Unlimited`
* refactor: remove unused const
* fix: use the selected shelveDuration
* fix: set and use default if none selected
* fix: bad optional chaining
* fix: handle the case of no provider
* fix: don't assign defaults if no provider
2024-10-15 23:26:57 +00:00
2b8673941a Don't persist current tab when display is locked (#7882)
Check if a display is locked before saving current tab. 

---------

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2024-10-11 16:17:29 -07:00
703186adf1 Add script to lock object sub-tree and fix object locking bugs (#7855)
* Script for locking an object tree

* Show lock button if locked

* Do not allow properties editing of locked objects

* Remove package-lock.json

* Added p-debounce

* Allow duplication of locked objects

* Better user feedback

* Add semaphores to prevent file handle exhaustion

* Leverage official Apache Couch library - nano. Clean up dependencies. Default to environment variables for couch config. Simplify batching mechanism to make it synchronouse

* Added lock user attribution

* Remove unused code

* Modify open script for adding auth design doc

* Added script for creating auth design doc

* Add css class for disallow unlock

* Add user attribution to lock button

* Fix import

* Typo

* User it was locked by, not current user. Wow.

* Closes #7877
- Front-end sanding and shimming: displays <span> instead of button when domainObject.disallowUnlock.

* Fixed bug where lock is shown even if object is not locked

---------

Co-authored-by: Charles Hacskaylo <charles.f.hacskaylo@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2024-10-10 15:09:39 -07:00
c43ef64733 [Telemetry Tables] Fix sort issues (#7875)
* Issue where immutable objects sort order was not being set correctly in telemetry tables. 
* Configuration couldn't be saved and the sort order was not being saved in memory. 
* Telemetry was not being re-requested when the sort order of a table was changed and the table was in performance (limited) mode.
* We've moved sort order to both configuration and in-memory and we will re-request telemetry if changing sort order in performance mode.

---------

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2024-10-10 14:24:31 -07:00
f4cf9c756b test(visual): Stabilize compass per timestamp (#7866)
* feat: add function to generate a seeded random value

* fix: produce the same compass orientation per timestamp
- helps for consistent visual tests

---------

Co-authored-by: John Hill <john.c.hill@nasa.gov>
2024-10-10 10:52:42 -07:00
4415fe7952 Fix complex displays not loading (#7858)
Clock
* pass in default timeContext in request options
* observe for clock tick, instead of polling, to determine if clock has time set

Progress Bars
* use scale instead of move animation

Plan
* use a ResizeObserver instead of polling for size changes

---------

Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com>
Co-authored-by: Charles Hacskaylo <charles.f.hacskaylo@nasa.gov>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2024-10-08 13:10:35 -07:00
83e4a124e2 Fix table sorting in descending order (#7863)
When adding sorted arrays to the beginning, make sure to insert them in the same order and not accidentally reverse them while inserting.

Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
2024-10-08 13:56:28 +00:00
47f0b66c7e [CI] Fix flake with clocks and state generators (#7867)
* add two appActions

* replace with appAction

* replace with determinsitic appAction

* fix lint

* speed
2024-10-04 15:47:25 -07:00
55c023d1eb chore: it is 2024. (#7864) 2024-10-03 06:28:26 -07:00
37b2660f27 [Conditionals] Increase performance, switch to TelemetryCollections (#7841)
* adding telemetry collections to condition manager

* handling telemetry collection data not datum

* adding from maaster

* addressing PR comments

* update unit test to work with telemetry collections

* fixing tests

* removing unnecessary addition

* removing focused describe

* removing focused it

* fix weird test bleed

* adding test for conditional styling

* removing some auto fix es-lint

* got a bit overzealous

* clarification

* using raf utility which handles it correctly and moving visiblity handling into the raf for consistency and performance

* using raf correctly

* removing raf, was causing issues

* move the test and add some determinism

* oops only

* missed lint

* got it!

* fix comments

* test(condStyling): stabilize test

---------

Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
2024-10-02 14:14:15 -07:00
43cc963328 chore: bump @playwright/test to 1.47.2 (#7859) 2024-10-01 21:01:02 +00:00
ad30a0e2d0 feat(Fault Management): allow fault providers to define shelve durations (#7849)
* refactor: clean up FaultManagementView code

* feat: providers can now provide "Shelve Duration" options

* fix(exampleFaultSource): support `getShelveDurations`

* a11y: aria label for fault management list item

* a11y(FaultManagement): more labels

* refactor: eliminate some faultUtils and refactor locator() out of tests

* docs: add some more docs to fault management api

* refactor: make for loop more readable

* test: use static faults when testing

* fix: set a timestamp for static faults and subtract so we get faults in order

* refactor: autoformat

* chore: add missing copyright header

* fix: use as default parameter to get value as method is called

* refactor: make magic number a const

* fix(codecov): use codecov github action to upload

* fix: generate the report

* build: update circleci yml to use codecov orb

* build: remove codecov scripts and package

* build: don't use the orb because things can't be easy

- nasa org disallows "third party" orbs

* build: only use `sudo` if we ain't da root user

---------

Co-authored-by: Andrew Henry <akhenry@gmail.com>
2024-10-01 10:41:18 -07:00
29f1956d1a Improve telemetry buffering implementation (#7837)
* Simplifies the implementation of telemetry buffering in Open MCT.
* Switches from per-parameter buffering to a shared queue. Per-parameter buffering is too easily overwhelmed by bursts of telemetry from high-frequency parameters. A single shared buffer has more overhead to grow when a burst arrives without overflowing, because the buffer is shared across all parameters.
* Removes the need for plugins to pass serialized code to a worker.
* Switched to a "one-size-fits-all" batching strategy removing the need for plugins to define a batching strategy at all.
* Captures buffering statistics for display in the PerformanceIndicator.
2024-09-30 14:36:40 -07:00
c498f7d20c Fix bad color value for Gauge 'Needle' type (#7821)
* Closes #7820
- Fix 0 opacity fill in theme constants files for Gauge type needle.

* move gauge plugin to its own suite

* add two more snapshots

* driveby: fix some flake

* bug: update linting rule

---------

Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2024-09-28 09:17:32 -07:00
a8fbabe695 fix(#7825): imagery pause (#7842)
* more readable

* unpause explicitly

* fix jsdoc

* e2e testing multiple image removal

* prettier

* fix to remove multiple images from history

* move tests that use playwright clock api into own file

* fix playwright clock tests

* add aria-label to element

* prevent straggler debounced function call on unmount

* clean up and fix tests

* update paths

* lint fix

* lint fix

---------

Co-authored-by: John Hill <john.c.hill@nasa.gov>
2024-09-27 14:32:14 -07:00
e792403788 Bar graphs should only get latest historical datum (#7811)
* Only as for latest historical telemetry

* Add test for size 1 request when a bar graph is loaded

* Use strategy latest instead of size 1 for historical request

* Fix linting issues

* Add size and strategy

* Remove bar graph tests

---------

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2024-09-25 09:37:38 -07:00
95 changed files with 2967 additions and 2678 deletions

View File

@ -5,11 +5,11 @@ orbs:
executors:
pw-focal-development:
docker:
- image: mcr.microsoft.com/playwright:v1.45.2-focal
- image: mcr.microsoft.com/playwright:v1.48.1-focal
environment:
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
PERCY_POSTINSTALL_BROWSER: "true" # Needed to store the percy browser in cache deps
PERCY_LOGLEVEL: "debug" # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742)
PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps
PERCY_LOGLEVEL: 'debug' # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742)
PERCY_PARALLEL_TOTAL: 2
ubuntu:
machine:
@ -17,7 +17,7 @@ executors:
docker_layer_caching: true
commands:
build_and_install:
description: "All steps used to build and install."
description: 'All steps used to build and install.'
parameters:
node-version:
type: string
@ -27,7 +27,7 @@ commands:
node-version: << parameters.node-version >>
- node/install-packages
generate_and_store_version_and_filesystem_artifacts:
description: "Track important packages and files"
description: 'Track important packages and files'
steps:
- run: |
[[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts)
@ -37,14 +37,45 @@ commands:
ls -latR >> /tmp/artifacts/dir.txt
- store_artifacts:
path: /tmp/artifacts/
download_verify_codecov_cli:
description: 'Download and verify Codecov CLI'
steps:
- run:
name: Download and verify Codecov CLI
command: |
# Download Codecov CLI
curl -Os https://cli.codecov.io/latest/linux/codecov
# Import Codecov's GPG key
curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import
# Download and verify the SHA256SUM and its signature
curl -Os https://cli.codecov.io/latest/linux/codecov.SHA256SUM
curl -Os https://cli.codecov.io/latest/linux/codecov.SHA256SUM.sig
gpgv codecov.SHA256SUM.sig codecov.SHA256SUM
# Verify the checksum
shasum -a 256 -c codecov.SHA256SUM
# Make the codecov executable
[[ $EUID -ne 0 ]] && sudo chmod +x codecov || chmod +x codecov
./codecov --help
generate_e2e_code_cov_report:
description: "Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test"
description: 'Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test'
parameters:
suite:
type: string
steps:
- run: npm run cov:e2e:report || true
- run: npm run cov:e2e:<<parameters.suite>>:publish
- download_verify_codecov_cli
- run:
name: Upload coverage report to Codecov
command: |
./codecov --verbose upload-process --disable-search \
-t $CODECOV_TOKEN \
-n 'e2e-<<parameters.suite>>'-${CIRCLE_WORKFLOW_ID} \
-F e2e-<<parameters.suite>> \
-f ./coverage/e2e/lcov.info
jobs:
npm-audit:
parameters:
@ -81,7 +112,15 @@ jobs:
mkdir -p dist/reports/tests/
TESTFILES=$(circleci tests glob "src/**/*Spec.js")
echo "$TESTFILES" | circleci tests run --command="xargs npm run test" --verbose
- run: npm run cov:unit:publish
- download_verify_codecov_cli
- run:
name: Upload coverage report to Codecov
command: |
./codecov --verbose upload-process --disable-search \
-t $CODECOV_TOKEN \
-n 'unit-test'-${CIRCLE_WORKFLOW_ID} \
-F unit \
-f ./coverage/unit/lcov.info
- store_test_results:
path: dist/reports/tests/
- store_artifacts:
@ -96,13 +135,13 @@ jobs:
suite: #ci or full
type: string
executor: pw-focal-development
parallelism: 7
parallelism: 8
steps:
- build_and_install:
node-version: lts/hydrogen
- when: #Only install chrome-beta when running the 'full' suite to save $$$
condition:
equal: ["full", <<parameters.suite>>]
equal: ['full', <<parameters.suite>>]
steps:
- run: npx playwright install chrome-beta
- run:
@ -159,7 +198,7 @@ jobs:
steps:
- build_and_install:
node-version: lts/hydrogen
- run: npx playwright@1.45.2 install #Necessary for bare ubuntu machine
- run: npx playwright@1.48.1 install #Necessary for bare ubuntu machine
- run: |
export $(cat src/plugins/persistence/couch/.env.ci | xargs)
docker compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach
@ -247,8 +286,8 @@ workflows:
overall-circleci-commit-status: #These jobs run on every commit
jobs:
- lint:
name: node20-lint
node-version: lts/iron
name: node22-lint
node-version: '22'
- unit-test:
name: node18-chrome
node-version: lts/hydrogen
@ -265,8 +304,8 @@ workflows:
the-nightly: #These jobs do not run on PRs, but against master at night
jobs:
- unit-test:
name: node20-chrome-nightly
node-version: lts/iron
name: node22-chrome-nightly
node-version: '22'
- unit-test:
name: node18-chrome
node-version: lts/hydrogen
@ -284,7 +323,7 @@ workflows:
- e2e-couchdb
triggers:
- schedule:
cron: "0 0 * * *"
cron: '0 0 * * *'
filters:
branches:
only:

View File

@ -37,7 +37,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- run: npx playwright@1.45.2 install
- run: npx playwright@1.48.1 install
- name: Start CouchDB Docker Container and Init with Setup Scripts
run: |
@ -51,11 +51,18 @@ jobs:
env:
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha }}
run: npm run test:e2e:couchdb
- name: Generate Code Coverage Report
run: npm run cov:e2e:report
- name: Publish Results to Codecov.io
env:
SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }}
run: npm run cov:e2e:full:publish
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/e2e/lcov.info
flags: e2e-full
fail_ci_if_error: true
verbose: true
- name: Archive test results
if: success() || failure()

View File

@ -30,7 +30,7 @@ jobs:
restore-keys: |
${{ runner.os }}-node-
- run: npx playwright@1.45.2 install
- run: npx playwright@1.48.1 install
- run: npm ci --no-audit --progress=false
- name: Run E2E Tests (Repeated 10 Times)

View File

@ -28,7 +28,7 @@ jobs:
restore-keys: |
${{ runner.os }}-node-
- run: npx playwright@1.45.2 install
- run: npx playwright@1.48.1 install
- run: npm ci --no-audit --progress=false
- run: npm run test:perf:localhost
- run: npm run test:perf:contract

View File

@ -33,7 +33,7 @@ jobs:
restore-keys: |
${{ runner.os }}-node-
- run: npx playwright@1.45.2 install
- run: npx playwright@1.47.2 install
- run: npx playwright install chrome-beta
- run: npm ci --no-audit --progress=false
- run: npm run test:e2e:full -- --max-failures=40

View File

@ -27,6 +27,12 @@ module.exports = {
rules: {
'playwright/no-raw-locators': 'off'
}
},
{
files: ['**/*.visual.spec.js'],
rules: {
'playwright/no-networkidle': 'off' //https://github.com/nasa/openmct/issues/7549
}
}
]
};

View File

@ -469,6 +469,7 @@ By adhering to this principle, we can create tests that are both robust and refl
//Select from object
await percySnapshot(page, `object selected (theme: ${theme})`)
```
8. **Use `networkidle` to wait for network requests to complete**: This is necessary to ensure that all network requests have completed before taking a snapshot. This ensures that icons are loaded and other assets are available. https://github.com/nasa/openmct/issues/7549
#### How to write a great network test

View File

@ -227,6 +227,37 @@ async function createExampleTelemetryObject(page, parent = 'mine') {
};
}
/**
* Create a Stable State Telemetry Object (State Generator) for use in visual tests
* and tests against plotting telemetry (e.g. logPlot tests). This will change state every 2 seconds.
* @param {import('@playwright/test').Page} page
* @param {string | import('../src/api/objects/ObjectAPI').Identifier} [parent] the uuid or identifier of the parent object. Defaults to 'mine'
* @returns {Promise<CreatedObjectInfo>} An object containing information about the telemetry object.
*/
async function createStableStateTelemetry(page, parent = 'mine') {
const parentUrl = await getHashUrlToDomainObject(page, parent);
await page.goto(`${parentUrl}`);
const createdObject = await createDomainObjectWithDefaults(page, {
type: 'State Generator',
name: 'Stable State Generator'
});
// edit the state generator to have a 1 second update rate
await page.getByLabel('More actions').click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('2');
await page.getByLabel('Save').click();
// Wait until the URL is updated
const uuid = await getFocusedObjectUuid(page);
const url = await getHashUrlToDomainObject(page, uuid);
return {
name: createdObject.name,
uuid,
url
};
}
/**
* Navigates directly to a given object url, in fixed time mode, with the given start and end bounds. Note: does not set
* default view type.
@ -479,6 +510,10 @@ async function setTimeConductorBounds(page, { submitChanges = true, ...bounds })
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// FIXME: https://github.com/nasa/openmct/pull/7818
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
if (startDate) {
await page.getByLabel('Start date').fill(startDate);
}
@ -629,13 +664,33 @@ async function getCanvasPixels(page, canvasSelector) {
);
}
/**
* Search for telemetry and link it to an object. objectName should come from the domainObject.name function.
* @param {import('@playwright/test').Page} page
* @param {string} parameterName
* @param {string} objectName
*/
async function linkParameterToObject(page, parameterName, objectName) {
await page.getByRole('searchbox', { name: 'Search Input' }).click();
await page.getByRole('searchbox', { name: 'Search Input' }).fill(parameterName);
await page.getByLabel('Object Results').getByText(parameterName).click();
await page.getByLabel('More actions').click();
await page.getByLabel('Create Link').click();
await page.getByLabel('Modal Overlay').getByLabel('Search Input').click();
await page.getByLabel('Modal Overlay').getByLabel('Search Input').fill(objectName);
await page.getByLabel('Modal Overlay').getByLabel(`Navigate to ${objectName}`).click();
await page.getByLabel('Save').click();
}
export {
createDomainObjectWithDefaults,
createExampleTelemetryObject,
createNotification,
createPlanFromJSON,
createStableStateTelemetry,
expandEntireTree,
getCanvasPixels,
linkParameterToObject,
navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime,
setEndOffset,

View File

@ -25,6 +25,7 @@ import { expect } from '../pluginFixtures.js';
/**
* @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/
export async function navigateToFaultManagementWithExample(page) {
await page.addInitScript({
@ -36,6 +37,7 @@ export async function navigateToFaultManagementWithExample(page) {
/**
* @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/
export async function navigateToFaultManagementWithStaticExample(page) {
await page.addInitScript({
@ -47,6 +49,7 @@ export async function navigateToFaultManagementWithStaticExample(page) {
/**
* @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/
export async function navigateToFaultManagementWithoutExample(page) {
await page.addInitScript({
@ -58,6 +61,7 @@ export async function navigateToFaultManagementWithoutExample(page) {
/**
* @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/
async function navigateToFaultItemInTree(page) {
await page.goto('./', { waitUntil: 'domcontentloaded' });
@ -77,6 +81,8 @@ async function navigateToFaultItemInTree(page) {
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/
export async function acknowledgeFault(page, rowNumber) {
await openFaultRowMenu(page, rowNumber);
@ -86,6 +92,8 @@ export async function acknowledgeFault(page, rowNumber) {
/**
* @param {import('@playwright/test').Page} page
* @param {...number} nums
* @returns {Promise<void>}
*/
export async function shelveMultipleFaults(page, ...nums) {
const selectRows = nums.map((num) => {
@ -99,6 +107,8 @@ export async function shelveMultipleFaults(page, ...nums) {
/**
* @param {import('@playwright/test').Page} page
* @param {...number} nums
* @returns {Promise<void>}
*/
export async function acknowledgeMultipleFaults(page, ...nums) {
const selectRows = nums.map((num) => {
@ -106,50 +116,43 @@ export async function acknowledgeMultipleFaults(page, ...nums) {
});
await Promise.all(selectRows);
await page.locator('button:has-text("Acknowledge")').click();
await page.getByLabel('Acknowledge selected faults').click();
await page.getByLabel('Save').click();
}
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/
export async function shelveFault(page, rowNumber) {
await openFaultRowMenu(page, rowNumber);
await page.locator('.c-menu >> text="Shelve"').click();
// Click [aria-label="Save"]
await page.getByLabel('Shelve', { exact: true }).click();
await page.getByLabel('Save').click();
}
/**
* @param {import('@playwright/test').Page} page
*/
export async function changeViewTo(page, view) {
await page.locator('.c-fault-mgmt__search-row select').first().selectOption(view);
}
/**
* @param {import('@playwright/test').Page} page
* @param {'severity' | 'newest-first' | 'oldest-first'} sort
* @returns {Promise<void>}
*/
export async function sortFaultsBy(page, sort) {
await page.locator('.c-fault-mgmt__list-header-sortButton select').selectOption(sort);
await page.getByTitle('Sort By').getByRole('combobox').selectOption(sort);
}
/**
* @param {import('@playwright/test').Page} page
* @param {'acknowledged' | 'shelved' | 'standard view'} view
* @returns {Promise<void>}
*/
export async function enterSearchTerm(page, term) {
await page.locator('.c-fault-mgmt-search [aria-label="Search Input"]').fill(term);
}
/**
* @param {import('@playwright/test').Page} page
*/
export async function clearSearch(page) {
await enterSearchTerm(page, '');
export async function changeViewTo(page, view) {
await page.getByTitle('View Filter').getByRole('combobox').selectOption(view);
}
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/
export async function selectFaultItem(page, rowNumber) {
await page
@ -165,71 +168,37 @@ export async function selectFaultItem(page, rowNumber) {
/**
* @param {import('@playwright/test').Page} page
*/
export async function getHighestSeverity(page) {
const criticalCount = await page.locator('[title=CRITICAL]').count();
const warningCount = await page.locator('[title=WARNING]').count();
if (criticalCount > 0) {
return 'CRITICAL';
} else if (warningCount > 0) {
return 'WARNING';
}
return 'WATCH';
}
/**
* @param {import('@playwright/test').Page} page
*/
export async function getLowestSeverity(page) {
const warningCount = await page.locator('[title=WARNING]').count();
const watchCount = await page.locator('[title=WATCH]').count();
if (watchCount > 0) {
return 'WATCH';
} else if (warningCount > 0) {
return 'WARNING';
}
return 'CRITICAL';
}
/**
* @param {import('@playwright/test').Page} page
*/
export async function getFaultResultCount(page) {
const count = await page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').count();
return count;
}
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {import('@playwright/test').Locator}
*/
export function getFault(page, rowNumber) {
const fault = page.locator(
`.c-faults-list-view-item-body > .c-fault-mgmt__list >> nth=${rowNumber - 1}`
);
const fault = page.getByLabel('Fault triggered at').nth(rowNumber - 1);
return fault;
}
/**
* @param {import('@playwright/test').Page} page
* @param {string} name
* @returns {import('@playwright/test').Locator}
*/
export function getFaultByName(page, name) {
const fault = page.locator(`.c-fault-mgmt__list-faultname:has-text("${name}")`);
const fault = page.getByLabel('Fault triggered at').filter({
hasText: name
});
return fault;
}
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/
export async function getFaultName(page, rowNumber) {
const faultName = await page
.locator(`.c-fault-mgmt__list-faultname >> nth=${rowNumber - 1}`)
.getByLabel('Fault name', { exact: true })
.nth(rowNumber - 1)
.textContent();
return faultName;
@ -237,21 +206,13 @@ export async function getFaultName(page, rowNumber) {
/**
* @param {import('@playwright/test').Page} page
*/
export async function getFaultSeverity(page, rowNumber) {
const faultSeverity = await page
.locator(`.c-faults-list-view-item-body .c-fault-mgmt__list-severity >> nth=${rowNumber - 1}`)
.getAttribute('title');
return faultSeverity;
}
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/
export async function getFaultNamespace(page, rowNumber) {
const faultNamespace = await page
.locator(`.c-fault-mgmt__list-path >> nth=${rowNumber - 1}`)
.getByLabel('Fault namespace')
.nth(rowNumber - 1)
.textContent();
return faultNamespace;
@ -259,10 +220,13 @@ export async function getFaultNamespace(page, rowNumber) {
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/
export async function getFaultTriggerTime(page, rowNumber) {
const faultTriggerTime = await page
.locator(`.c-fault-mgmt__list-trigTime >> nth=${rowNumber - 1} >> .c-fault-mgmt-item__value`)
.getByLabel('Last Trigger Time')
.nth(rowNumber - 1)
.textContent();
return faultTriggerTime.toString().trim();
@ -270,11 +234,14 @@ export async function getFaultTriggerTime(page, rowNumber) {
/**
* @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/
export async function openFaultRowMenu(page, rowNumber) {
// select
await page
.getByLabel('Disposition actions')
.getByLabel('Fault triggered at')
.nth(rowNumber - 1)
.getByLabel('Disposition Actions')
.click();
}

View File

@ -0,0 +1,33 @@
import { createDomainObjectWithDefaults } from '../appActions.js';
import { expect } from '../pluginFixtures.js';
const IMAGE_LOAD_DELAY = 5 * 1000;
const FIVE_MINUTES = 1000 * 60 * 5;
const THIRTY_SECONDS = 1000 * 30;
const MOUSE_WHEEL_DELTA_Y = 120;
/**
* @param {import('@playwright/test').Page} page
*/
async function createImageryViewWithShortDelay(page, { name, parent }) {
await createDomainObjectWithDefaults(page, {
name,
type: 'Example Imagery',
parent
});
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery');
await page.getByLabel('More actions').click();
await page.getByLabel('Edit Properties').click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
}
export {
createImageryViewWithShortDelay,
FIVE_MINUTES,
IMAGE_LOAD_DELAY,
MOUSE_WHEEL_DELTA_Y,
THIRTY_SECONDS
};

View File

@ -129,6 +129,7 @@ export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl
*/
export function getEarliestStartTime(planJson) {
const activities = Object.values(planJson).flat();
return Math.min(...activities.map((activity) => activity.start));
}
@ -139,6 +140,7 @@ export function getEarliestStartTime(planJson) {
*/
export function getLatestEndTime(planJson) {
const activities = Object.values(planJson).flat();
return Math.max(...activities.map((activity) => activity.end));
}
@ -151,6 +153,7 @@ export function getFirstActivity(planJson) {
const groups = Object.keys(planJson);
const firstGroupKey = groups[0];
const firstGroupItems = planJson[firstGroupKey];
return firstGroupItems[0];
}

View File

@ -16,7 +16,7 @@
"devDependencies": {
"@percy/cli": "1.27.4",
"@percy/playwright": "1.0.4",
"@playwright/test": "1.45.2",
"@playwright/test": "1.48.1",
"@axe-core/playwright": "4.8.5"
},
"author": {
@ -24,4 +24,4 @@
"url": "https://www.nasa.gov"
},
"license": "Apache-2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -26,8 +26,10 @@ import {
createExampleTelemetryObject,
createNotification,
createPlanFromJSON,
createStableStateTelemetry,
expandEntireTree,
getCanvasPixels,
linkParameterToObject,
navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime,
setEndOffset,
@ -339,4 +341,23 @@ test.describe('AppActions @framework', () => {
// Expect this step to fail
await waitForPlotsToRender(page, { timeout: 1000 });
});
test('createStableStateTelemetry', async ({ page }) => {
const stableStateTelemetry = await createStableStateTelemetry(page);
expect(stableStateTelemetry.name).toBe('Stable State Generator');
expect(stableStateTelemetry.url).toBe(`./#/browse/mine/${stableStateTelemetry.uuid}`);
expect(stableStateTelemetry.uuid).toBeDefined();
});
test('linkParameterToObject', async ({ page }) => {
const displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display Layout'
});
const exampleTelemetry = await createExampleTelemetryObject(page);
await linkParameterToObject(page, exampleTelemetry.name, displayLayout.name);
await page.goto(displayLayout.url);
await expect(page.getByRole('main').getByText('Test Display Layout')).toBeVisible();
await expandEntireTree(page);
await expect(page.getByLabel('Navigate to VIPER Rover').first()).toBeVisible();
});
});

View File

@ -287,6 +287,41 @@ test.describe('Basic Condition Set Use', () => {
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

@ -24,19 +24,13 @@ import {
acknowledgeFault,
acknowledgeMultipleFaults,
changeViewTo,
clearSearch,
enterSearchTerm,
getFault,
getFaultByName,
getFaultName,
getFaultNamespace,
getFaultResultCount,
getFaultSeverity,
getFaultTriggerTime,
getHighestSeverity,
getLowestSeverity,
navigateToFaultManagementWithExample,
navigateToFaultManagementWithoutExample,
navigateToFaultManagementWithStaticExample,
selectFaultItem,
shelveFault,
shelveMultipleFaults,
@ -46,7 +40,7 @@ import { expect, test } from '../../../../pluginFixtures.js';
test.describe('The Fault Management Plugin using example faults', () => {
test.beforeEach(async ({ page }) => {
await navigateToFaultManagementWithExample(page);
await navigateToFaultManagementWithStaticExample(page);
});
test('Shows a criticality icon for every fault', async ({ page }) => {
@ -56,7 +50,7 @@ test.describe('The Fault Management Plugin using example faults', () => {
expect(faultCount).toEqual(criticalityIconCount);
});
test('When selecting a fault, it has an "is-selected" class and it\'s information shows in the inspector', async ({
test('When selecting a fault, it has an "is-selected" class and its information shows in the inspector', async ({
page
}) => {
await selectFaultItem(page, 1);
@ -67,9 +61,7 @@ test.describe('The Fault Management Plugin using example faults', () => {
.getByLabel('Source inspector properties')
.getByLabel('inspector property value');
await expect(
page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').first()
).toHaveClass(/is-selected/);
await expect(page.getByLabel('Fault triggered at').first()).toHaveClass(/is-selected/);
await expect(inspectorFaultName).toHaveCount(1);
});
@ -79,23 +71,18 @@ test.describe('The Fault Management Plugin using example faults', () => {
await selectFaultItem(page, 1);
await selectFaultItem(page, 2);
const selectedRows = page.locator(
'.c-fault-mgmt__list.is-selected .c-fault-mgmt__list-faultname'
);
expect(await selectedRows.count()).toEqual(2);
const selectedRows = page.getByRole('checkbox', { checked: true });
await expect(selectedRows).toHaveCount(2);
await page.getByRole('tab', { name: 'Config' }).click();
const firstSelectedFaultName = await selectedRows.nth(0).textContent();
const secondSelectedFaultName = await selectedRows.nth(1).textContent();
const firstNameInInspectorCount = await page
.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`)
.count();
const secondNameInInspectorCount = await page
.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`)
.count();
expect(firstNameInInspectorCount).toEqual(0);
expect(secondNameInInspectorCount).toEqual(0);
await expect(
page.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`)
).toHaveCount(0);
await expect(
page.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`)
).toHaveCount(0);
});
test('Allows you to shelve a fault', async ({ page }) => {
@ -186,44 +173,60 @@ test.describe('The Fault Management Plugin using example faults', () => {
const faultFiveTriggerTime = await getFaultTriggerTime(page, 5);
// should be all faults (5)
let faultResultCount = await getFaultResultCount(page);
expect(faultResultCount).toEqual(5);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
// search namespace
await enterSearchTerm(page, faultThreeNamespace);
await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultThreeNamespace);
faultResultCount = await getFaultResultCount(page);
expect(faultResultCount).toEqual(1);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect(await getFaultNamespace(page, 1)).toEqual(faultThreeNamespace);
// all faults
await clearSearch(page);
faultResultCount = await getFaultResultCount(page);
expect(faultResultCount).toEqual(5);
await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('');
await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
// search name
await enterSearchTerm(page, faultTwoName);
await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultTwoName);
faultResultCount = await getFaultResultCount(page);
expect(faultResultCount).toEqual(1);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect(await getFaultName(page, 1)).toEqual(faultTwoName);
// all faults
await clearSearch(page);
faultResultCount = await getFaultResultCount(page);
expect(faultResultCount).toEqual(5);
await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('');
await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
// search triggerTime
await enterSearchTerm(page, faultFiveTriggerTime);
await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultFiveTriggerTime);
faultResultCount = await getFaultResultCount(page);
expect(faultResultCount).toEqual(1);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect(await getFaultTriggerTime(page, 1)).toEqual(faultFiveTriggerTime);
});
test('Allows you to sort faults', async ({ page }) => {
const highestSeverity = await getHighestSeverity(page);
const lowestSeverity = await getLowestSeverity(page);
/**
* Compares two severity levels and returns a number indicating their relative order.
*
* @param {'CRITICAL' | 'WARNING' | 'WATCH'} severity1 - The first severity level to compare.
* @param {'CRITICAL' | 'WARNING' | 'WATCH'} severity2 - The second severity level to compare.
* @returns {number} - A negative number if severity1 is less severe than severity2,
* a positive number if severity1 is more severe than severity2,
* or 0 if they are equally severe.
*/
// eslint-disable-next-line func-style
const compareSeverity = (severity1, severity2) => {
const severityOrder = ['WATCH', 'WARNING', 'CRITICAL'];
return severityOrder.indexOf(severity1) - severityOrder.indexOf(severity2);
};
const faultOneName = 'Example Fault 1';
const faultFiveName = 'Example Fault 5';
let firstFaultName = await getFaultName(page, 1);
@ -237,10 +240,19 @@ test.describe('The Fault Management Plugin using example faults', () => {
await sortFaultsBy(page, 'severity');
const sortedHighestSeverity = await getFaultSeverity(page, 1);
const sortedLowestSeverity = await getFaultSeverity(page, 5);
expect(sortedHighestSeverity).toEqual(highestSeverity);
expect(sortedLowestSeverity).toEqual(lowestSeverity);
const firstFaultSeverityLabel = await page
.getByLabel('Severity:')
.first()
.getAttribute('aria-label');
const firstFaultSeverity = firstFaultSeverityLabel.split(' ').slice(1).join(' ');
const lastFaultSeverityLabel = await page
.getByLabel('Severity:')
.last()
.getAttribute('aria-label');
const lastFaultSeverity = lastFaultSeverityLabel.split(' ').slice(1).join(' ');
expect(compareSeverity(firstFaultSeverity, lastFaultSeverity)).toBeGreaterThan(0);
});
});
@ -250,24 +262,18 @@ test.describe('The Fault Management Plugin without using example faults', () =>
});
test('Shows no faults when no faults are provided', async ({ page }) => {
const faultCount = await page.locator('c-fault-mgmt__list').count();
expect(faultCount).toEqual(0);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
await changeViewTo(page, 'acknowledged');
const acknowledgedCount = await page.locator('c-fault-mgmt__list').count();
expect(acknowledgedCount).toEqual(0);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
await changeViewTo(page, 'shelved');
const shelvedCount = await page.locator('c-fault-mgmt__list').count();
expect(shelvedCount).toEqual(0);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
});
test('Will return no faults when searching', async ({ page }) => {
await enterSearchTerm(page, 'fault');
await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('fault');
const faultCount = await page.locator('c-fault-mgmt__list').count();
expect(faultCount).toEqual(0);
await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
});
});

View File

@ -30,16 +30,19 @@ import {
navigateToObjectWithRealTime,
setRealTimeMode
} from '../../../../appActions.js';
import { MISSION_TIME } from '../../../../constants.js';
import {
createImageryViewWithShortDelay,
FIVE_MINUTES,
IMAGE_LOAD_DELAY,
MOUSE_WHEEL_DELTA_Y,
THIRTY_SECONDS
} from '../../../../helper/imageryUtils.js';
import { expect, test } from '../../../../pluginFixtures.js';
const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt'];
const tagHotkey = ['Shift', 'Alt'];
const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan';
const thumbnailUrlParamsRegexp = /\?w=100&h=100/;
const IMAGE_LOAD_DELAY = 5 * 1000;
const MOUSE_WHEEL_DELTA_Y = 120;
const FIVE_MINUTES = 1000 * 60 * 5;
const THIRTY_SECONDS = 1000 * 30;
//The following block of tests verifies the basic functionality of example imagery and serves as a template for Imagery objects embedded in other objects.
test.describe('Example Imagery Object', () => {
@ -93,9 +96,6 @@ test.describe('Example Imagery Object', () => {
expect(newPage.url()).toContain('.jpg');
});
// this requires CORS to be enabled in some fashion
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
test('Can adjust image brightness/contrast by dragging the sliders', async ({
page,
browserName
@ -357,15 +357,10 @@ test.describe('Example Imagery Object', () => {
});
});
test.describe('Example Imagery in Display Layout @clock', () => {
test.describe('Example Imagery in Display Layout', () => {
let displayLayout;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
// Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
@ -428,12 +423,7 @@ test.describe('Example Imagery in Display Layout @clock', () => {
await expect.soft(pausePlayButton).toHaveClass(/is-paused/);
});
test('Imagery View operations @clock', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test('Imagery View operations', async ({ page }) => {
// Edit mode
await page.getByLabel('Edit Object').click();
@ -526,14 +516,9 @@ test.describe('Example Imagery in Display Layout @clock', () => {
});
});
test.describe('Example Imagery in Flexible layout @clock', () => {
test.describe('Example Imagery in Flexible layout', () => {
let flexibleLayout;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' });
@ -562,7 +547,7 @@ test.describe('Example Imagery in Flexible layout @clock', () => {
await page.getByRole('button', { name: 'Close' }).click();
});
test('Imagery View operations @clock', async ({ page, browserName }) => {
test('Imagery View operations', async ({ page, browserName }) => {
test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox');
test.info().annotations.push({
type: 'issue',
@ -573,14 +558,10 @@ test.describe('Example Imagery in Flexible layout @clock', () => {
});
});
test.describe('Example Imagery in Tabs View @clock', () => {
test.describe('Example Imagery in Tabs View', () => {
let tabsView;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
tabsView = await createDomainObjectWithDefaults(page, { type: 'Tabs View' });
@ -607,7 +588,8 @@ test.describe('Example Imagery in Tabs View @clock', () => {
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
test('Imagery View operations @clock', async ({ page }) => {
test('Imagery View operations', async ({ page }) => {
await performImageryViewOperationsAndAssert(page, tabsView);
});
});
@ -668,16 +650,19 @@ test.describe('Example Imagery in Time Strip', () => {
* 3. Can pan the image using the pan hotkey + mouse drag
* 4. Clicking on the left arrow button pauses imagery and moves to the previous image
* 5. Imagery is updated as new images stream in, regardless of pause status
* 6. Old images are discarded when new images stream in
* 7. Image brightness/contrast can be adjusted by dragging the sliders
* 6. Old images are discarded when their timestamps fall out of bounds
* 7. Multiple images can be discarded when their timestamps fall out of bounds
* 8. Image brightness/contrast can be adjusted by dragging the sliders
* @param {import('@playwright/test').Page} page
*/
async function performImageryViewOperationsAndAssert(page, layoutObject) {
// Verify that imagery thumbnails use a thumbnail url
const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image');
const mainImage = page.locator('.c-imagery__main-image__image');
await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp);
await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp);
await test.step('Verify that imagery thumbnails use a thumbnail url', async () => {
const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image');
const mainImage = page.locator('.c-imagery__main-image__image');
await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp);
await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp);
});
// Click previous image button
const previousImageButton = page.getByLabel('Previous image');
await expect(previousImageButton).toBeVisible();
@ -736,19 +721,6 @@ async function performImageryViewOperationsAndAssert(page, layoutObject) {
// Unpause imagery
await page.locator('.pause-play').click();
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
//Get background-image url from background-image css prop
await assertBackgroundImageUrlFromBackgroundCss(page);
// Open the image filter menu
await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click();
@ -815,24 +787,6 @@ async function assertBackgroundImageBrightness(page, expected) {
expect(actual).toBe(expected);
}
/**
* @param {import('@playwright/test').Page} page
*/
async function assertBackgroundImageUrlFromBackgroundCss(page) {
const backgroundImage = page.getByLabel('Focused Image Element');
const backgroundImageUrl = await backgroundImage.evaluate((el) => {
return window
.getComputedStyle(el)
.getPropertyValue('background-image')
.match(/url\(([^)]+)\)/)[1];
});
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(backgroundImage).not.toHaveJSProperty('background-image', backgroundImageUrl);
}
/**
* @param {import('@playwright/test').Page} page
*/
@ -918,62 +872,66 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) {
* @param {import('@playwright/test').Page} page
*/
async function buttonZoomOnImageAndAssert(page) {
// Lock the zoom and pan so it doesn't reset if a new image comes in
await page.getByLabel('Focused Image Element').hover({ trial: true });
const lockButton = page.getByRole('button', {
name: 'Lock current zoom and pan across all images'
});
if (!(await lockButton.isVisible())) {
await test.step('Can zoom using buttons', async () => {
// Lock the zoom and pan so it doesn't reset if a new image comes in
await page.getByLabel('Focused Image Element').hover({ trial: true });
}
await lockButton.click();
const lockButton = page.getByRole('button', {
name: 'Lock current zoom and pan across all images'
});
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
await lockButton.isVisible();
// if (!(await lockButton.isVisible())) {
// await page.getByLabel('Focused Image Element').hover({ trial: true });
// }
await lockButton.click();
// Get initial image dimensions
const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
// Zoom in twice via button
await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(2) translate(0px, 0px)'
);
await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(3) translate(0px, 0px)'
);
// Get initial image dimensions
const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
// Get and assert zoomed in image dimensions
const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
// Zoom in twice via button
await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(2) translate(0px, 0px)'
);
await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(3) translate(0px, 0px)'
);
// Zoom out once via button
await zoomOutOfImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(2) translate(0px, 0px)'
);
// Get and assert zoomed in image dimensions
const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
// Get and assert zoomed out image dimensions
const zoomedOutBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height);
expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width);
// Zoom out once via button
await zoomOutOfImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(2) translate(0px, 0px)'
);
// Zoom out again via button, assert against the initial image dimensions
await zoomOutOfImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
// Get and assert zoomed out image dimensions
const zoomedOutBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height);
expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width);
const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(finalBoundingBox).toEqual(initialBoundingBox);
// Zoom out again via button, assert against the initial image dimensions
await zoomOutOfImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(finalBoundingBox).toEqual(initialBoundingBox);
});
}
/**
@ -1035,24 +993,6 @@ async function resetImageryPanAndZoom(page) {
await expect(page.locator('.c-thumb__viewable-area')).toBeHidden();
}
/**
* @param {import('@playwright/test').Page} page
*/
async function createImageryViewWithShortDelay(page, { name, parent }) {
await createDomainObjectWithDefaults(page, {
name,
type: 'Example Imagery',
parent
});
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery');
await page.getByLabel('More actions').click();
await page.getByLabel('Edit Properties').click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
}
/**
* @param {import('@playwright/test').Page} page
*/

View File

@ -0,0 +1,489 @@
/*****************************************************************************
* 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.
*****************************************************************************/
/*
This test suite is dedicated to testing how imagery functions over time.
It only assumes that example imagery is present.
It uses https://playwright.dev/docs/clock to have control over time
*/
import {
createDomainObjectWithDefaults,
navigateToObjectWithRealTime,
setRealTimeMode,
setStartOffset
} from '../../../../appActions.js';
import { MISSION_TIME } from '../../../../constants.js';
import {
createImageryViewWithShortDelay,
FIVE_MINUTES,
IMAGE_LOAD_DELAY,
THIRTY_SECONDS
} from '../../../../helper/imageryUtils.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Example Imagery Object with Controlled Clock @clock', () => {
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
//Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create a default 'Example Imagery' object
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery
await page.getByRole('menuitem', { name: 'Example Imagery' }).click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').clear();
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
// Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
);
await page.getByLabel('Focused Image Element').hover({ trial: true });
// set realtime mode
await setRealTimeMode(page);
await setStartOffset(page, { startMins: '05' });
});
test('Imagery Time Bounding', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Display Layout with Controlled Clock @clock', () => {
let displayLayout;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
// Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' });
// Create Example Imagery inside Display Layout
await createImageryViewWithShortDelay(page, {
name: 'Unnamed Example Imagery',
parent: displayLayout.uuid
});
// set realtime mode
await navigateToObjectWithRealTime(
page,
displayLayout.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
});
test('Imagery Time Bounding', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// Edit mode
await page.getByLabel('Edit Object').click();
// Click on example imagery to expose toolbar
await page.locator('.c-so-view__header').click();
// Adjust object height
await page.locator('div[title="Resize object height"] > input').click();
await page.locator('div[title="Resize object height"] > input').fill('50');
// Adjust object width
await page.locator('div[title="Resize object width"] > input').click();
await page.locator('div[title="Resize object width"] > input').fill('50');
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop @clock', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Flexible layout with Controlled Clock @clock', () => {
let flexibleLayout;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' });
// Create Example Imagery inside the Flexible Layout
await createImageryViewWithShortDelay(page, {
name: 'Unnamed Example Imagery',
parent: flexibleLayout.uuid
});
// set realtime mode
await navigateToObjectWithRealTime(
page,
flexibleLayout.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
test('Imagery Time Bounding @clock', async ({ page, browserName }) => {
test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox');
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5326'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop @clock', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Tabs View with Controlled Clock @clock', () => {
let timeStripObject;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Tabs View' });
await page.goto(timeStripObject.url);
/* Create Example Imagery with minimum Image Load Delay */
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery
await page.getByRole('menuitem', { name: 'Example Imagery' }).click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').clear();
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
);
// set realtime mode
await navigateToObjectWithRealTime(
page,
timeStripObject.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
test('Imagery Time Bounding @clock', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop @clock', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Time Strip with Controlled Clock @clock', () => {
let timeStripObject;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Time Strip' });
await page.goto(timeStripObject.url);
/* Create Example Imagery with minimum Image Load Delay */
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery
await page.getByRole('menuitem', { name: 'Example Imagery' }).click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').clear();
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
);
// set realtime mode
await navigateToObjectWithRealTime(
page,
timeStripObject.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('wrapper-').last()).toBeInViewport();
});
test('Imagery Time Bounding @clock', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('wrapper-').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('aria-label');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('aria-label');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('aria-label');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
});
/**
* @param {import('@playwright/test').Page} page
*/
async function assertBackgroundImageUrlFromBackgroundCss(page) {
const backgroundImage = page.getByLabel('Focused Image Element');
const backgroundImageUrl = await backgroundImage.evaluate((el) => {
return window
.getComputedStyle(el)
.getPropertyValue('background-image')
.match(/url\(([^)]+)\)/)[1];
});
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(backgroundImage).not.toHaveJSProperty('background-image', backgroundImageUrl);
}

View File

@ -0,0 +1,93 @@
/*****************************************************************************
* 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.
*****************************************************************************/
/*
* This test suite verifies modifying the image location of the example imagery object.
*/
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Example Imagery Object Custom Images', () => {
let exampleImagery;
test.beforeEach(async ({ page }) => {
//Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create a default 'Example Imagery' object
exampleImagery = await createDomainObjectWithDefaults(page, {
name: 'Example Imagery',
type: 'Example Imagery'
});
// Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name);
await page.getByLabel('Focused Image Element').hover({ trial: true });
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
// this requires CORS to be enabled in some fashion
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
test('Can provide a custom image location for the example imagery object', async ({ page }) => {
// Modify Example Imagery to create a really stable image which will never let us down
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page
.locator('#imageLocation-textarea')
.fill(
'https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg,https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg'
);
await page.getByRole('button', { name: 'Save' }).click();
await page.reload({ waitUntil: 'domcontentloaded' });
// Wait for the thumbnails to finish their scroll animation
// (Wait until the rightmost thumbnail is in view)
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
});
test.fixme('Can provide a custom image with spaces in name', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7903'
});
await page.goto(exampleImagery.url, { waitUntil: 'domcontentloaded' });
// Modify Example Imagery to create a really stable image which will never let us down
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page
.locator('#imageLocation-textarea')
.fill(
'https://raw.githubusercontent.com/nasa/openmct/d8c64f183400afb70137221fc1a035e091bea912/e2e/test-data/rick%20space%20roll.jpg'
);
await page.getByRole('button', { name: 'Save' }).click();
await page.reload({ waitUntil: 'domcontentloaded' });
// Wait for the thumbnails to finish their scroll animation
// (Wait until the rightmost thumbnail is in view)
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
});
});

View File

@ -0,0 +1,72 @@
/*****************************************************************************
* 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 { expect, test } from '../../../../pluginFixtures.js';
test.describe('The performance indicator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
const openmct = window.openmct;
openmct.install(openmct.plugins.PerformanceIndicator());
});
});
test('can be installed', ({ page }) => {
const performanceIndicator = page.getByTitle('Performance Indicator');
expect(performanceIndicator).toBeDefined();
});
test('Shows a numerical FPS value', async ({ page }) => {
// Frames Per Second. We need to wait at least 1 second to get a value.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expect(page.getByTitle('Performance Indicator')).toHaveText(/\d\d? fps/);
});
test('Supports showing optional extended performance information in an overlay for debugging', async ({
page
}) => {
const performanceMeasurementLabel = 'Some measurement';
const performanceMeasurementValue = 'Some value';
await page.evaluate(
({ performanceMeasurementLabel: label, performanceMeasurementValue: value }) => {
const openmct = window.openmct;
openmct.performance.measurements.set(label, value);
},
{ performanceMeasurementLabel, performanceMeasurementValue }
);
const performanceIndicator = page.getByTitle('Performance Indicator');
await performanceIndicator.click();
//Performance overlay is a crude debugging tool, it's evaluated once per second.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
const performanceOverlay = page.getByTitle('Performance Overlay');
await expect(performanceOverlay).toBeVisible();
await expect(performanceOverlay).toHaveText(new RegExp(`${performanceMeasurementLabel}.*`));
await expect(performanceOverlay).toHaveText(new RegExp(`.*${performanceMeasurementValue}`));
//Confirm that it disappears if we click on it again.
await performanceIndicator.click();
await expect(performanceOverlay).toBeHidden();
});
});

View File

@ -1,5 +1,5 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*

View File

@ -0,0 +1,163 @@
/*****************************************************************************
* 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.
*****************************************************************************/
/*
This test suite is dedicated to tests which verify the basic operations surrounding conditionSets and styling
*/
import {
createDomainObjectWithDefaults,
linkParameterToObject,
setRealTimeMode
} from '../../../../appActions.js';
import { MISSION_TIME } from '../../../../constants.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Conditionally Styling, using a Condition Set', () => {
let stateGenerator;
let conditionSet;
let displayLayout;
const STATE_CHANGE_INTERVAL = '1';
test.beforeEach(async ({ page }) => {
// Install the clock and set the time to the mission time such that the state generator will be controllable
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Condition Set, State Generator, and Display Layout
conditionSet = await createDomainObjectWithDefaults(page, {
type: 'Condition Set',
name: 'Test Condition Set'
});
stateGenerator = await createDomainObjectWithDefaults(page, {
type: 'State Generator',
name: 'One Second State Generator'
});
// edit the state generator to have a 1 second update rate
await page.getByTitle('More actions').click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page.getByLabel('State Duration (seconds)', { exact: true }).fill(STATE_CHANGE_INTERVAL);
await page.getByLabel('Save').click();
displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display Layout'
});
});
test('Conditional styling, using a Condition Set, will style correctly based on the output @clock', async ({
page
}) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7840'
});
// set up the condition set to use the state generator
await page.goto(conditionSet.url, { waitUntil: 'domcontentloaded' });
// Add the State Generator to the Condition Set by dragging from the main tree
await page.getByLabel('Show selected item in tree').click();
await page
.getByRole('tree', {
name: 'Main Tree'
})
.getByRole('treeitem', {
name: stateGenerator.name
})
.dragTo(page.locator('#conditionCollection'));
// Add the state generator to the first criterion such that there is a condition named 'OFF' when the state generator is off
await page.getByLabel('Add Condition').click();
await page
.getByLabel('Criterion Telemetry Selection')
.selectOption({ label: stateGenerator.name });
await page.getByLabel('Criterion Metadata Selection').selectOption({ label: 'State' });
await page.getByLabel('Criterion Comparison Selection').selectOption({ label: 'is' });
await page.getByLabel('Condition Name Input').first().fill('OFF');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await linkParameterToObject(page, stateGenerator.name, displayLayout.name);
//Add a box to the display layout
await page.goto(displayLayout.url, { waitUntil: 'domcontentloaded' });
await page.getByLabel('Edit Object').click();
//Add a box to the display layout and move it to the right
//TEMP: Click the layout such that the state generator is deselected
await page.getByLabel('Test Display Layout Layout Grid').locator('div').nth(1).click();
await page.getByLabel('Add Drawing Object').click();
await page.getByText('Box').click();
await page.getByLabel('X:').click();
await page.getByLabel('X:').fill('10');
await page.getByLabel('X:').press('Enter');
// set up conditional styling such that the box is red when the state generator condition is 'OFF'
await page.getByRole('tab', { name: 'Styles' }).click();
await page.getByRole('button', { name: 'Use Conditional Styling...' }).click();
await page.getByLabel('Modal Overlay').getByLabel('Expand My Items folder').click();
await page.getByLabel('Modal Overlay').getByLabel(`Preview ${conditionSet.name}`).click();
await page.getByText('Ok').click();
await page.getByLabel('Set background color').first().click();
await page.getByLabel('#ff0000').click();
await page.getByLabel('Save', { exact: true }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await setRealTimeMode(page);
//Pause at a time when the state generator is 'OFF' which is 20 minutes in the future
await page.clock.pauseAt(new Date(MISSION_TIME + 1200000));
const redBG = 'background-color: rgb(255, 0, 0);';
const defaultBG = 'background-color: rgb(102, 102, 102);';
const textElement = page.getByLabel('Alpha-numeric telemetry value').locator('div:first-child');
const styledElement = page.getByLabel('Box', { exact: true });
await page.clock.resume();
// Check if the style is red when text is 'OFF'
await expect(textElement).toHaveText('OFF');
await waitForStyleChange(styledElement, redBG);
// Fast forward to the next state change
await page.clock.fastForward(STATE_CHANGE_INTERVAL * 1000);
// Check if the style is not red when text is 'ON'
await expect(textElement).toHaveText('ON');
await waitForStyleChange(styledElement, defaultBG);
});
});
/**
* Wait for the style of an element to change to the expected style.
* @param {import('@playwright/test').Locator} element - The element to check.
* @param {string} expectedStyle - The expected style to wait for.
* @param {number} timeout - The timeout in milliseconds.
*/
async function waitForStyleChange(element, expectedStyle, timeout = 0) {
await expect(async () => {
const style = await element.getAttribute('style');
// eslint-disable-next-line playwright/prefer-web-first-assertions
expect(style).toBe(expectedStyle);
}).toPass({ timeout: 1000 }); // timeout allows for the style to be applied
}

View File

@ -57,7 +57,7 @@ test.describe('Tabs View', () => {
await page.goto(tabsView.url);
// select first tab
await page.getByLabel(`${table.name} tab`, { exact: true }).click();
await page.getByLabel(`${table.name} tab - selected`, { exact: true }).click();
// ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
@ -92,6 +92,38 @@ test.describe('Tabs View', () => {
// no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
});
test('Changing the displayed tab should not be persisted if the view is locked', async ({
page
}) => {
await page.goto(tabsView.url);
//lock the view
await page.getByLabel('Unlocked for editing, click to lock.', { exact: true }).click();
// get the initial tab index
const initialTab = page.getByLabel(/- selected/);
// switch to a different tab in the view
const swgTab = page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true });
await swgTab.click();
await page.getByLabel(`${sineWaveGenerator.name} Object View`).isVisible();
// navigate away from the tabbed view and back
await page.getByRole('treeitem', { name: 'My Items' }).click();
await page.goto(tabsView.url);
// check that the initial tab is displayed
const lockedSelectedTab = page.getByLabel(/- selected/);
await expect(lockedSelectedTab).toHaveText(await initialTab.textContent());
//unlock the view
await page.getByLabel('Locked for editing. Click to unlock.', { exact: true }).click();
// switch to a different tab in the view
await swgTab.click();
await page.getByLabel(`${sineWaveGenerator.name} Object View`).isVisible();
// navigate away from the tabbed view and back
await page.getByRole('treeitem', { name: 'My Items' }).click();
await page.goto(tabsView.url);
// check that the newly selected tab is displayed
const unlockedSelectedTab = page.getByLabel(/- selected/);
await expect(unlockedSelectedTab).toBeVisible();
});
});
test.describe('Tabs View CRUD', () => {

View File

@ -117,7 +117,8 @@ test.describe('Telemetry Table', () => {
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() - 5);
const endDate = endTimeStamp.toISOString().split('T')[0];
const endTime = endTimeStamp.toISOString().split('T')[1];
const milliseconds = endTimeStamp.getMilliseconds();
const endTime = endTimeStamp.toISOString().split('T')[1].replace(`.${milliseconds}Z`, '');
await setTimeConductorBounds(page, { endDate, endTime });

View File

@ -24,65 +24,210 @@ import {
setEndOffset,
setFixedTimeMode,
setRealTimeMode,
setStartOffset,
setTimeConductorBounds
setStartOffset
} from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Time conductor operations', () => {
test('validate start time does not exceed end time', async ({ page }) => {
const DAY = '2024-01-01';
const DAY_AFTER = '2024-01-02';
const ONE_O_CLOCK = '01:00:00';
const TWO_O_CLOCK = '02:00:00';
test.beforeEach(async ({ page }) => {
// Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
const year = new Date().getFullYear();
});
// Set initial valid time bounds
const startDate = `${year}-01-01`;
const startTime = '01:00:00';
const endDate = `${year}-01-01`;
const endTime = '02:00:00';
await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime });
test('validate date and time inputs are validated on input event', async ({ page }) => {
const submitButtonLocator = page.getByLabel('Submit time bounds');
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// Test invalid start date
const invalidStartDate = `${year}-01-02`;
await page.getByLabel('Start date').fill(invalidStartDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('Start date').fill(startDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
await test.step('invalid start date disables submit button', async () => {
const initialStartDate = await page.getByLabel('Start date').inputValue();
const invalidStartDate = `${initialStartDate.substring(0, 5)}${initialStartDate.substring(6)}`;
// Test invalid end date
const invalidEndDate = `${year - 1}-12-31`;
await page.getByLabel('End date').fill(invalidEndDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End date').fill(endDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
await page.getByLabel('Start date').fill(invalidStartDate);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start date').fill(initialStartDate);
await expect(submitButtonLocator).toBeEnabled();
});
// Test invalid start time
const invalidStartTime = '42:00:00';
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('Start time').fill(startTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
await test.step('invalid start time disables submit button', async () => {
const initialStartTime = await page.getByLabel('Start time').inputValue();
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
// Test invalid end time
const invalidEndTime = '43:00:00';
await page.getByLabel('End time').fill(invalidEndTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End time').fill(endTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start time').fill(initialStartTime);
await expect(submitButtonLocator).toBeEnabled();
});
// Submit valid time bounds
await test.step('disable/enable submit button also works with multiple invalid inputs', async () => {
const initialEndDate = await page.getByLabel('End date').inputValue();
const invalidEndDate = `${initialEndDate.substring(0, 5)}${initialEndDate.substring(6)}`;
const initialStartTime = await page.getByLabel('Start time').inputValue();
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('End date').fill(invalidEndDate);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('End date').fill(initialEndDate);
await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start time').fill(initialStartTime);
await expect(submitButtonLocator).toBeEnabled();
});
});
test('validate date and time inputs validation is reported on change event', async ({ page }) => {
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await test.step('invalid start date is reported on change event, not on input event', async () => {
const initialStartDate = await page.getByLabel('Start date').inputValue();
const invalidStartDate = `${initialStartDate.substring(0, 5)}${initialStartDate.substring(6)}`;
await page.getByLabel('Start date').fill(invalidStartDate);
await expect(page.getByLabel('Start date')).not.toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('Start date').press('Tab');
await expect(page.getByLabel('Start date')).toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('Start date').fill(initialStartDate);
await expect(page.getByLabel('Start date')).not.toHaveAttribute('title', 'Invalid Date');
});
await test.step('invalid start time is reported on change event, not on input event', async () => {
const initialStartTime = await page.getByLabel('Start time').inputValue();
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(page.getByLabel('Start time')).not.toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('Start time').press('Tab');
await expect(page.getByLabel('Start time')).toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('Start time').fill(initialStartTime);
await expect(page.getByLabel('Start time')).not.toHaveAttribute('title', 'Invalid Time');
});
await test.step('invalid end date is reported on change event, not on input event', async () => {
const initialEndDate = await page.getByLabel('End date').inputValue();
const invalidEndDate = `${initialEndDate.substring(0, 5)}${initialEndDate.substring(6)}`;
await page.getByLabel('End date').fill(invalidEndDate);
await expect(page.getByLabel('End date')).not.toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('End date').press('Tab');
await expect(page.getByLabel('End date')).toHaveAttribute('title', 'Invalid Date');
await page.getByLabel('End date').fill(initialEndDate);
await expect(page.getByLabel('End date')).not.toHaveAttribute('title', 'Invalid Date');
});
await test.step('invalid end time is reported on change event, not on input event', async () => {
const initialEndTime = await page.getByLabel('End time').inputValue();
const invalidEndTime = `${initialEndTime.substring(0, 5)}${initialEndTime.substring(6)}`;
await page.getByLabel('End time').fill(invalidEndTime);
await expect(page.getByLabel('End time')).not.toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('End time').press('Tab');
await expect(page.getByLabel('End time')).toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('End time').fill(initialEndTime);
await expect(page.getByLabel('End time')).not.toHaveAttribute('title', 'Invalid Time');
});
});
test('validate start time does not exceed end time on submit', async ({ page }) => {
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// FIXME: https://github.com/nasa/openmct/pull/7818
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(TWO_O_CLOCK);
await page.getByLabel('End date').fill(DAY);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
// Verify the submitted time bounds
await expect(page.getByLabel('Start bounds')).toHaveText(
new RegExp(`${startDate} ${startTime}.000Z`)
await expect(page.getByLabel('Start date')).toHaveAttribute(
'title',
'Specified start date exceeds end bound'
);
await expect(page.getByLabel('End bounds')).toHaveText(
new RegExp(`${endDate} ${endTime}.000Z`)
await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY);
await page.getByLabel('End time').fill(TWO_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`);
});
test('validate start datetime does not exceed end datetime on submit', async ({ page }) => {
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// FIXME: https://github.com/nasa/openmct/pull/7818
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await page.getByLabel('Start date').fill(DAY_AFTER);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start date')).toHaveAttribute(
'title',
'Specified start date exceeds end bound'
);
await expect(page.getByLabel('Start bounds')).not.toHaveText(
`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`
);
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY_AFTER);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
});
test('cancelling form does not set bounds', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7791'
});
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY_AFTER);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Discard changes and close time popup').click();
await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
// Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await page.getByLabel('Start date').fill(DAY);
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
await page.getByLabel('End date').fill(DAY_AFTER);
await page.getByLabel('End time').fill(ONE_O_CLOCK);
await page.getByLabel('Submit time bounds').click();
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
});
});
@ -131,77 +276,6 @@ test.describe('Global Time Conductor', () => {
await expect(page.getByLabel('End offset: 01:30:31')).toBeVisible();
});
test('Input field validation: fixed time mode', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7791'
});
// Switch to fixed time mode
await setFixedTimeMode(page);
// Define valid time bounds for testing
const validBounds = {
startDate: '2024-04-20',
startTime: '00:04:20',
endDate: '2024-04-20',
endTime: '16:04:20'
};
// Set valid time conductor bounds ✌️
await setTimeConductorBounds(page, validBounds);
// Verify that the time bounds are set correctly
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
// Open the Time Conductor Mode popup
await page.getByLabel('Time Conductor Mode').click();
// Test invalid start date
const invalidStartDate = '2024-04-21';
await page.getByLabel('Start date').fill(invalidStartDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('Start date').fill(validBounds.startDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid end date
const invalidEndDate = '2024-04-19';
await page.getByLabel('End date').fill(invalidEndDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End date').fill(validBounds.endDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid start time
const invalidStartTime = '16:04:21';
await page.getByLabel('Start time').fill(invalidStartTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('Start time').fill(validBounds.startTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid end time
const invalidEndTime = '00:04:19';
await page.getByLabel('End time').fill(invalidEndTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End time').fill(validBounds.endTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Verify that the time bounds remain unchanged after invalid inputs
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
// Discard changes and verify that bounds remain unchanged
await setTimeConductorBounds(page, {
startDate: validBounds.startDate,
startTime: '04:20:00',
endDate: validBounds.endDate,
endTime: '04:20:20',
submitChanges: false
});
// Verify that the original time bounds are still displayed after discarding changes
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
});
/**
* Verify that offsets and url params are preserved when switching
* between fixed timespan and real-time mode.

View File

@ -1,5 +1,5 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*

View File

@ -1,5 +1,5 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*

View File

@ -64,7 +64,7 @@ test.describe('Tabs View', () => {
page.goto(tabsView.url);
// select first tab
await page.getByLabel(`${table.name} tab`, { exact: true }).click();
await page.getByLabel(`${table.name} tab - selected`, { exact: true }).click();
// ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();

View File

@ -85,16 +85,6 @@ test.describe('Visual - Default @a11y', () => {
await percySnapshot(page, `Display Layout Create Menu (theme: '${theme}')`);
});
test('Visual - Default Gauge', async ({ page, theme }) => {
await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Default Gauge'
});
// Take a snapshot of the newly created Gauge object
await percySnapshot(page, `Default Gauge (theme: '${theme}')`);
});
test.afterEach(async ({ page }, testInfo) => {
await scanForA11yViolations(page, testInfo.title);
});

View File

@ -22,7 +22,11 @@
import percySnapshot from '@percy/playwright';
import { createDomainObjectWithDefaults } from '../../appActions.js';
import {
createDomainObjectWithDefaults,
createStableStateTelemetry,
linkParameterToObject
} from '../../appActions.js';
import { MISSION_TIME, VISUAL_FIXED_URL } from '../../constants.js';
import { test } from '../../pluginFixtures.js';
@ -47,16 +51,13 @@ test.describe('Visual - Display Layout @clock', () => {
name: 'Child Right Layout',
parent: parentLayout.uuid
});
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
name: 'SWG 1',
parent: child1Layout.uuid
});
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
name: 'SWG 2',
parent: child2Layout.uuid
});
const stableStateTelemetry = await createStableStateTelemetry(page);
await linkParameterToObject(page, stableStateTelemetry.name, child1Layout.name);
await linkParameterToObject(page, stableStateTelemetry.name, child2Layout.name);
// Pause the clock at a time where the telemetry is stable 20 minutes in the future
await page.clock.pauseAt(new Date(MISSION_TIME + 1200000));
await page.goto(parentLayout.url, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Edit Object' }).click();

View File

@ -0,0 +1,81 @@
/*****************************************************************************
* 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 percySnapshot from '@percy/playwright';
import { createDomainObjectWithDefaults } from '../../appActions.js';
import { scanForA11yViolations, test } from '../../avpFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.js';
test.describe('Visual - Gauges', () => {
test.beforeEach(async ({ page }) => {
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
});
test('Visual - Default Gauge', async ({ page, theme }) => {
await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Default Gauge'
});
// Take a snapshot of the newly created Gauge object
await percySnapshot(page, `Default Gauge (theme: '${theme}')`);
});
test('Visual - Needle Gauge with State Generator', async ({ page, theme }) => {
const needleGauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Needle Gauge'
});
//Modify the Gauge to be a Needle Gauge
await page.getByLabel('More actions').click();
await page.getByLabel('Edit Properties...').click();
await page.getByLabel('Gauge type', { exact: true }).selectOption('dial-needle');
await page.getByText('Ok').click();
//Add a State Generator to the Gauge
await page.goto(needleGauge.url + '?hideTree=true&hideInspector=true', {
waitUntil: 'domcontentloaded'
});
// Take a snapshot of the newly created Gauge object
await percySnapshot(page, `Needle Gauge with no telemetry source (theme: '${theme}')`);
//Add a State Generator to the Gauge. Note this requires that snapshots are taken within 5 seconds
await page.getByLabel('Create', { exact: true }).click();
await page.getByLabel('State Generator').click();
await page.getByLabel('Modal Overlay').getByLabel('Navigate to Needle Gauge').click();
await page.getByLabel('Save').click();
//Add a State Generator to the Gauge
await page.goto(needleGauge.url + '?hideTree=true&hideInspector=true', {
waitUntil: 'domcontentloaded'
});
// Take a snapshot of the newly created Gauge object
await percySnapshot(page, `Needle Gauge with State Generator (theme: '${theme}')`);
});
test.afterEach(async ({ page }, testInfo) => {
await scanForA11yViolations(page, testInfo.title);
});
});

View File

@ -98,7 +98,7 @@ test.describe('Visual - Notebook @a11y', () => {
await page.getByLabel('Expand My Items folder').click();
await page.goto(notebook.url);
await page.goto(notebook.url, { waitUntil: 'networkidle' });
await page
.getByLabel('Navigate to Dropped Overlay Plot')

View File

@ -26,14 +26,25 @@ import fs from 'fs';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js';
import { scanForA11yViolations, test } from '../../avpFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.js';
import { setBoundsToSpanAllActivities, setDraftStatusForPlan } from '../../helper/planningUtils.js';
import {
getFirstActivity,
setBoundsToSpanAllActivities,
setDraftStatusForPlan
} from '../../helper/planningUtils.js';
const examplePlanSmall2 = JSON.parse(
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url))
);
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
test.describe('Visual - Gantt Chart @a11y', () => {
test.beforeEach(async ({ page }) => {
// Set the clock to the end of the first activity in the plan
// This is so we can see the "now" line in the plan view
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
await page.clock.resume();
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
});
test('Gantt Chart View', async ({ page, theme }) => {

View File

@ -27,14 +27,21 @@ import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appAct
import { scanForA11yViolations, test } from '../../avpFixtures.js';
import { waitForAnimations } from '../../baseFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.js';
import { setBoundsToSpanAllActivities } from '../../helper/planningUtils.js';
import { getFirstActivity, setBoundsToSpanAllActivities } from '../../helper/planningUtils.js';
const examplePlanSmall2 = JSON.parse(
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url))
);
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
test.describe('Visual - Time Strip @a11y', () => {
test.beforeEach(async ({ page }) => {
// Set the clock to the end of the first activity in the plan
// This is so we can see the "now" line in the plan view
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
await page.clock.resume();
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
});
test('Time Strip View', async ({ page, theme }) => {

View File

@ -42,6 +42,7 @@ const examplePlanSmall2 = JSON.parse(
);
const FIRST_ACTIVITY_SMALL_1 = getFirstActivity(examplePlanSmall1);
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
test.describe('Visual - Timelist progress bar @clock @a11y', () => {
test.beforeEach(async ({ page }) => {
@ -59,6 +60,11 @@ test.describe('Visual - Timelist progress bar @clock @a11y', () => {
test.describe('Visual - Plan View @a11y', () => {
test.beforeEach(async ({ page }) => {
// Set the clock to the end of the first activity in the plan
// This is so we can see the "now" line in the plan view
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
await page.clock.resume();
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
});

View File

@ -53,7 +53,7 @@ test.describe('Grand Search @a11y', () => {
theme
}) => {
// Navigate to display layout
await page.goto(displayLayout.url);
await page.goto(displayLayout.url, { waitUntil: 'networkidle' });
// Search for the object
await page.getByRole('searchbox', { name: 'Search Input' }).click();

View File

@ -20,6 +20,7 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { DEFAULT_SHELVE_DURATIONS } from '../../src/api/faultmanagement/FaultManagementAPI.js';
import { acknowledgeFault, randomFaults, shelveFault } from './utils.js';
export default function (staticFaults = false) {
@ -56,6 +57,9 @@ export default function (staticFaults = false) {
return Promise.resolve({
success: true
});
},
getShelveDurations() {
return DEFAULT_SHELVE_DURATIONS;
}
});
};

View File

@ -1,4 +1,27 @@
/*****************************************************************************
* 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.
*****************************************************************************/
const SEVERITIES = ['WATCH', 'WARNING', 'CRITICAL'];
const MOONWALK_TIMESTAMP = 14159040000;
const NAMESPACE = '/Example/fault-';
const getRandom = {
severity: () => SEVERITIES[Math.floor(Math.random() * 3)],
@ -13,7 +36,8 @@ const getRandom = {
val = num;
severity = SEVERITIES[severityIndex - 1];
time = num;
// Subtract `num` from the timestamp so that the faults are in order
time = MOONWALK_TIMESTAMP - num; // Mon, 21 Jul 1969 02:56:00 GMT 🌔👨‍🚀👨‍🚀👨‍🚀
}
return {
@ -43,14 +67,7 @@ const getRandom = {
}
};
export function shelveFault(
fault,
opts = {
shelved: true,
comment: '',
shelveDuration: 90000
}
) {
export function shelveFault(fault, opts = { shelved: true, comment: '', shelveDuration: 90000 }) {
fault.shelved = true;
setTimeout(() => {
@ -65,8 +82,8 @@ export function acknowledgeFault(fault) {
export function randomFaults(staticFaults, count = 5) {
let faults = [];
for (let x = 1, y = count + 1; x < y; x++) {
faults.push(getRandom.fault(x, staticFaults));
for (let i = 1; i <= count; i++) {
faults.push(getRandom.fault(i, staticFaults));
}
return faults;

View File

@ -20,6 +20,8 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { seededRandom } from 'utils/random.js';
const DEFAULT_IMAGE_SAMPLES = [
'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18731.jpg',
'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18732.jpg',
@ -162,8 +164,8 @@ export default function () {
};
}
function getCompassValues(min, max) {
return min + Math.random() * (max - min);
function getCompassValues(min, max, timestamp) {
return min + seededRandom(timestamp) * (max - min);
}
function getImageSamples(configuration) {
@ -283,9 +285,9 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) {
utc: Math.floor(timestamp / delay) * delay,
local: Math.floor(timestamp / delay) * delay,
url,
sunOrientation: getCompassValues(0, 360),
cameraAzimuth: getCompassValues(0, 360),
heading: getCompassValues(0, 360),
sunOrientation: getCompassValues(0, 360, timestamp),
cameraAzimuth: getCompassValues(0, 360, timestamp),
heading: getCompassValues(0, 360, timestamp),
transformations: navCamTransformations,
imageDownloadName
};

View File

@ -1,5 +1,5 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*

276
package-lock.json generated
View File

@ -24,7 +24,6 @@
"@vue/compiler-sfc": "3.4.3",
"babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1",
"codecov": "3.8.3",
"comma-separated-values": "3.6.4",
"copy-webpack-plugin": "12.0.2",
"cspell": "7.3.8",
@ -67,6 +66,7 @@
"moment": "2.30.1",
"moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41",
"nano": "10.1.4",
"npm-run-all2": "6.1.2",
"nyc": "15.1.0",
"painterro": "1.2.87",
@ -93,7 +93,7 @@
"webpack-merge": "5.10.0"
},
"engines": {
"node": ">=18.14.2 <22"
"node": ">=18.14.2 <23"
}
},
"e2e": {
@ -104,7 +104,7 @@
"@axe-core/playwright": "4.8.5",
"@percy/cli": "1.27.4",
"@percy/playwright": "1.0.4",
"@playwright/test": "1.45.2"
"@playwright/test": "1.48.1"
}
},
"e2e/node_modules/@percy/cli": {
@ -1548,12 +1548,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.2.tgz",
"integrity": "sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ==",
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz",
"integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.45.2"
"playwright": "1.48.1"
},
"bin": {
"playwright": "cli.js"
@ -1586,15 +1587,6 @@
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==",
"dev": true
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
"dev": true,
"engines": {
"node": ">= 6"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@ -2308,18 +2300,6 @@
"node": ">=8.9"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@ -2454,16 +2434,6 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/argv": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/argv/-/argv-0.0.2.tgz",
"integrity": "sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"engines": {
"node": ">=0.6.10"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -2494,6 +2464,12 @@
"@mdn/browser-compat-data": "^5.2.34"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/axe-core": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.4.tgz",
@ -2503,6 +2479,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-loader": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.0.tgz",
@ -3013,26 +3000,6 @@
"node": ">=0.10.0"
}
},
"node_modules/codecov": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/codecov/-/codecov-3.8.3.tgz",
"integrity": "sha512-Y8Hw+V3HgR7V71xWH2vQ9lyS358CbGCldWlJFR0JirqoGtOoas3R3/OclRTvgUYFK29mmJICDPauVKmpqbwhOA==",
"deprecated": "https://about.codecov.io/blog/codecov-uploader-deprecation-plan/",
"dev": true,
"dependencies": {
"argv": "0.0.2",
"ignore-walk": "3.0.4",
"js-yaml": "3.14.1",
"teeny-request": "7.1.1",
"urlgrey": "1.0.0"
},
"bin": {
"codecov": "bin/codecov"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -3063,6 +3030,18 @@
"node": ">=0.1.90"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comma-separated-values": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/comma-separated-values/-/comma-separated-values-3.6.4.tgz",
@ -4153,6 +4132,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -5582,21 +5570,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-url-parser": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz",
"integrity": "sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==",
"dev": true,
"dependencies": {
"punycode": "^1.3.2"
}
},
"node_modules/fast-url-parser/node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"dev": true
},
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@ -5821,6 +5794,20 @@
"node": ">=8.0.0"
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -6387,20 +6374,6 @@
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-agent": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz",
"integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==",
"dev": true,
"dependencies": {
"@tootallnate/once": "1",
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
@ -6431,19 +6404,6 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"dev": true
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -6486,15 +6446,6 @@
"node": ">= 4"
}
},
"node_modules/ignore-walk": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz",
"integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==",
"dev": true,
"dependencies": {
"minimatch": "^3.0.4"
}
},
"node_modules/image-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz",
@ -7998,6 +7949,35 @@
"multicast-dns": "cli.js"
}
},
"node_modules/nano": {
"version": "10.1.4",
"resolved": "https://registry.npmjs.org/nano/-/nano-10.1.4.tgz",
"integrity": "sha512-bJOFIPLExIbF6mljnfExXX9Cub4W0puhDjVMp+qV40xl/DBvgKao7St4+6/GB6EoHZap7eFnrnx4mnp5KYgwJA==",
"dev": true,
"dependencies": {
"axios": "^1.7.4",
"node-abort-controller": "^3.1.1",
"qs": "^6.13.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/nano/node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dev": true,
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@ -8037,6 +8017,12 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"dev": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -8764,12 +8750,13 @@
}
},
"node_modules/playwright": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.2.tgz",
"integrity": "sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g==",
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz",
"integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.45.2"
"playwright-core": "1.48.1"
},
"bin": {
"playwright": "cli.js"
@ -8782,10 +8769,11 @@
}
},
"node_modules/playwright-core": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.2.tgz",
"integrity": "sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw==",
"version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz",
"integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
@ -8799,6 +8787,7 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@ -9284,6 +9273,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@ -10346,15 +10341,6 @@
"node": ">= 0.6"
}
},
"node_modules/stream-events": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
"integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
"dev": true,
"dependencies": {
"stubs": "^3.0.0"
}
},
"node_modules/streamroller": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
@ -10537,12 +10523,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stubs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"dev": true
},
"node_modules/style-loader": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz",
@ -10608,31 +10588,6 @@
"node": ">=6"
}
},
"node_modules/teeny-request": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.1.1.tgz",
"integrity": "sha512-iwY6rkW5DDGq8hE2YgNQlKbptYpY5Nn2xecjQiNjOXWbKzPGUfmeUBCSQbbr306d7Z7U2N0TPl+/SwYRfua1Dg==",
"dev": true,
"dependencies": {
"http-proxy-agent": "^4.0.0",
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.1",
"stream-events": "^1.0.5",
"uuid": "^8.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/teeny-request/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/terser": {
"version": "5.29.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz",
@ -11013,15 +10968,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/urlgrey": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/urlgrey/-/urlgrey-1.0.0.tgz",
"integrity": "sha512-hJfIzMPJmI9IlLkby8QrsCykQ+SXDeO2W5Q9QTW3QpqZVTx4a/K7p8/5q+/isD8vsbVaFgql/gvAoQCRQ2Cb5w==",
"dev": true,
"dependencies": {
"fast-url-parser": "^1.1.3"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -27,7 +27,6 @@
"@vue/compiler-sfc": "3.4.3",
"babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1",
"codecov": "3.8.3",
"comma-separated-values": "3.6.4",
"copy-webpack-plugin": "12.0.2",
"cspell": "7.3.8",
@ -70,6 +69,7 @@
"moment": "2.30.1",
"moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41",
"nano": "10.1.4",
"npm-run-all2": "6.1.2",
"nyc": "15.1.0",
"painterro": "1.2.87",
@ -128,12 +128,9 @@
"test:perf:contract": "npm test --workspace e2e -- --config=playwright-performance-dev.config.js",
"test:perf:localhost": "npm test --workspace e2e -- --config=playwright-performance-prod.config.js --project=chrome",
"test:perf:memory": "npm test --workspace e2e -- --config=playwright-performance-prod.config.js --project=chrome-memory",
"update-about-dialog-copyright": "perl -pi -e 's/20\\d\\d\\-202\\d/2014\\-2023/gm' ./src/ui/layout/AboutDialog.vue",
"update-about-dialog-copyright": "perl -pi -e 's/20\\d\\d\\-202\\d/2014\\-2024/gm' ./src/ui/layout/AboutDialog.vue",
"update-copyright-date": "npm run update-about-dialog-copyright && grep -lr --null --include=*.{js,scss,vue,ts,sh,html,md,frag} 'Copyright (c) 20' . | xargs -r0 perl -pi -e 's/Copyright\\s\\(c\\)\\s20\\d\\d\\-20\\d\\d/Copyright \\(c\\)\\ 2014\\-2024/gm'",
"cov:e2e:report": "nyc report --reporter=lcovonly --report-dir=./coverage/e2e",
"cov:e2e:full:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-full",
"cov:e2e:ci:publish": "codecov --disable=gcov -f ./coverage/e2e/lcov.info -F e2e-ci",
"cov:unit:publish": "codecov --disable=gcov -f ./coverage/unit/lcov.info -F unit",
"prepare": "npm run build:prod && npx tsc"
},
"homepage": "https://nasa.github.io/openmct",
@ -142,7 +139,7 @@
"url": "git+https://github.com/nasa/openmct.git"
},
"engines": {
"node": ">=18.14.2 <22"
"node": ">=18.14.2 <23"
},
"browserslist": [
"Firefox ESR",

View File

@ -20,6 +20,32 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
/** @type {ShelveDuration[]} */
export const DEFAULT_SHELVE_DURATIONS = [
{
name: '5 Minutes',
value: 300000
},
{
name: '10 Minutes',
value: 600000
},
{
name: '15 Minutes',
value: 900000
},
{
name: 'Unlimited',
value: null
}
];
/**
* Provides an API for managing faults within Open MCT.
* It allows for the addition of a fault provider, checking for provider support, and
* performing various operations such as requesting, subscribing to, acknowledging,
* and shelving faults.
*/
export default class FaultManagementAPI {
/**
* @param {import("openmct").OpenMCT} openmct
@ -29,14 +55,20 @@ export default class FaultManagementAPI {
}
/**
* @param {*} provider
* Sets the provider for the Fault Management API.
* The provider should implement methods for acknowledging and shelving faults.
*
* @param {*} provider - The provider to be set.
*/
addProvider(provider) {
this.provider = provider;
}
/**
* @returns {boolean}
* Checks if the current provider supports fault management actions.
* Specifically, it checks if the provider has methods for acknowledging and shelving faults.
*
* @returns {boolean} - Returns true if the provider supports fault management actions, otherwise false.
*/
supportsActions() {
return (
@ -45,48 +77,82 @@ export default class FaultManagementAPI {
}
/**
* @param {import('openmct').DomainObject} domainObject
* @returns {Promise.<FaultAPIResponse[]>}
* Requests fault data for a given domain object.
* This method checks if the current provider supports the request operation for the given domain object.
* If supported, it delegates the request to the provider's request method.
* If not supported, it returns a rejected promise.
*
* @param {import('openmct').DomainObject} domainObject - The domain object for which fault data is requested.
* @returns {Promise.<FaultAPIResponse[]>} - A promise that resolves to an array of fault API responses.
*/
request(domainObject) {
if (!this.provider?.supportsRequest(domainObject)) {
return Promise.reject();
return Promise.reject('Provider does not support request operation');
}
return this.provider.request(domainObject);
}
/**
* @param {import('openmct').DomainObject} domainObject
* @param {Function} callback
* @returns {Function} unsubscribe
* Subscribes to fault data updates for a given domain object.
* This method checks if the current provider supports the subscribe operation for the given domain object.
* If supported, it delegates the subscription to the provider's subscribe method.
* If not supported, it returns a rejected promise.
*
* @param {import('openmct').DomainObject} domainObject - The domain object for which to subscribe to fault data updates.
* @param {Function} callback - The callback function to be called with fault data updates.
* @returns {Function} unsubscribe - A function to unsubscribe from the fault data updates.
*/
subscribe(domainObject, callback) {
if (!this.provider?.supportsSubscribe(domainObject)) {
return Promise.reject();
return Promise.reject('Provider does not support subscribe operation');
}
return this.provider.subscribe(domainObject, callback);
}
/**
* @param {Fault} fault
* @param {*} ackData
* Acknowledges a fault using the provider's acknowledgeFault method.
*
* @param {Fault} fault - The fault object to be acknowledged.
* @param {*} ackData - Additional data required for acknowledging the fault.
* @returns {Promise.<T>} - A promise that resolves when the fault is acknowledged.
*/
acknowledgeFault(fault, ackData) {
return this.provider.acknowledgeFault(fault, ackData);
}
/**
* @param {Fault} fault
* @param {*} shelveData
* @returns {Promise.<T>}
* Shelves a fault using the provider's shelveFault method.
*
* @param {Fault} fault - The fault object to be shelved.
* @param {*} shelveData - Additional data required for shelving the fault.
* @returns {Promise.<T>} - A promise that resolves when the fault is shelved.
*/
shelveFault(fault, shelveData) {
return this.provider.shelveFault(fault, shelveData);
}
/**
* Retrieves the available shelve durations from the provider, or the default durations if the
* provider does not provide any.
* @returns {ShelveDuration[] | undefined}
*/
getShelveDurations() {
if (!this.provider) {
return;
}
return this.provider.getShelveDurations?.() ?? DEFAULT_SHELVE_DURATIONS;
}
}
/**
* @typedef {Object} ShelveDuration
* @property {string} name - The name of the shelve duration
* @property {number|null} value - The value of the shelve duration in milliseconds, or null for unlimited
*/
/**
* @typedef {Object} TriggerValueInfo
* @property {number} value

View File

@ -20,25 +20,46 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import installWorker from './WebSocketWorker.js';
/**
* Describes the strategy to be used when batching WebSocket messages
*
* @typedef BatchingStrategy
* @property {Function} shouldBatchMessage a function that accepts a single
* argument - the raw message received from the websocket. Every message
* received will be evaluated against this function so it should be performant.
* Note also that this function is executed in a worker, so it must be
* completely self-contained with no external dependencies. The function
* should return `true` if the message should be batched, and `false` if not.
* @property {Function} getBatchIdFromMessage a function that accepts a
* single argument - the raw message received from the websocket. Only messages
* where `shouldBatchMessage` has evaluated to true will be passed into this
* function. The function should return a unique value on which to batch the
* messages. For example a telemetry, channel, or parameter identifier.
* @typedef RequestIdleCallbackOptions
* @prop {Number} timeout If the number of milliseconds represented by this
* parameter has elapsed and the callback has not already been called, invoke
* the callback.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
*/
/**
* Provides a reliable and convenient WebSocket abstraction layer that handles
* a lot of boilerplate common to managing WebSocket connections such as:
* Mocks requestIdleCallback for Safari using setTimeout. Functionality will be
* identical to setTimeout in Safari, which is to fire the callback function
* after the provided timeout period.
*
* In browsers that support requestIdleCallback, this const is just a
* pointer to the native function.
*
* @param {Function} callback a callback to be invoked during the next idle period, or
* after the specified timeout
* @param {RequestIdleCallbackOptions} options
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
*
*/
function requestIdleCallbackPolyfill(callback, options) {
return (
// eslint-disable-next-line compat/compat
window.requestIdleCallback ??
((fn, { timeout }) =>
setTimeout(() => {
fn({ didTimeout: false });
}, timeout))
);
}
const requestIdleCallback = requestIdleCallbackPolyfill();
const ONE_SECOND = 1000;
/**
* Provides a WebSocket abstraction layer that handles a lot of boilerplate common
* to managing WebSocket connections such as:
* - Establishing a WebSocket connection to a server
* - Reconnecting on error, with a fallback strategy
* - Queuing messages so that clients can send messages without concern for the current
@ -49,22 +70,19 @@ import installWorker from './WebSocketWorker.js';
* and batching of messages without blocking either the UI or server.
*
*/
// Shim for Internet Explorer, I mean Safari. It doesn't support requestIdleCallback, but it's in a tech preview, so it will be dropping soon.
const requestIdleCallback =
// eslint-disable-next-line compat/compat
window.requestIdleCallback ?? ((fn, { timeout }) => setTimeout(fn, timeout));
const ONE_SECOND = 1000;
const FIVE_SECONDS = 5 * ONE_SECOND;
class BatchingWebSocket extends EventTarget {
#worker;
#openmct;
#showingRateLimitNotification;
#maxBatchSize;
#applicationIsInitializing;
#maxBatchWait;
#maxBufferSize;
#throttleRate;
#firstBatchReceived;
#lastBatchReceived;
#peakBufferSize = Number.NEGATIVE_INFINITY;
/**
* @param {import('openmct.js').OpenMCT} openmct
*/
constructor(openmct) {
super();
// Install worker, register listeners etc.
@ -74,9 +92,8 @@ class BatchingWebSocket extends EventTarget {
this.#worker = new Worker(workerUrl);
this.#openmct = openmct;
this.#showingRateLimitNotification = false;
this.#maxBatchSize = Number.POSITIVE_INFINITY;
this.#maxBatchWait = ONE_SECOND;
this.#applicationIsInitializing = true;
this.#maxBufferSize = Number.POSITIVE_INFINITY;
this.#throttleRate = ONE_SECOND;
this.#firstBatchReceived = false;
const routeMessageToHandler = this.#routeMessageToHandler.bind(this);
@ -89,20 +106,6 @@ class BatchingWebSocket extends EventTarget {
},
{ once: true }
);
openmct.once('start', () => {
// An idle callback is a pretty good indication that a complex display is done loading. At that point set the batch size more conservatively.
// Force it after 5 seconds if it hasn't happened yet.
requestIdleCallback(
() => {
this.#applicationIsInitializing = false;
this.setMaxBatchSize(this.#maxBatchSize);
},
{
timeout: FIVE_SECONDS
}
);
});
}
/**
@ -137,57 +140,48 @@ class BatchingWebSocket extends EventTarget {
}
/**
* Set the strategy used to both decide which raw messages to batch, and how to group
* them.
* @param {BatchingStrategy} strategy The batching strategy to use when evaluating
* raw messages from the WebSocket.
*/
setBatchingStrategy(strategy) {
const serializedStrategy = {
shouldBatchMessage: strategy.shouldBatchMessage.toString(),
getBatchIdFromMessage: strategy.getBatchIdFromMessage.toString()
};
this.#worker.postMessage({
type: 'setBatchingStrategy',
serializedStrategy
});
}
/**
* @param {number} maxBatchSize the maximum length of a batch of messages. For example,
* the maximum number of telemetry values to batch before dropping them
* @param {number} maxBufferSize the maximum length of the receive buffer in characters.
* Note that this is a fail-safe that is only invoked if performance drops to the
* point where Open MCT cannot keep up with the amount of telemetry it is receiving.
* In this event it will sacrifice the oldest telemetry in the batch in favor of the
* most recent telemetry. The user will be informed that telemetry has been dropped.
*
* This should be set appropriately for the expected data rate. eg. If telemetry
* is received at 10Hz for each telemetry point, then a minimal combination of batch
* size and rate is 10 and 1000 respectively. Ideally you would add some margin, so
* 15 would probably be a better batch size.
* This should be set appropriately for the expected data rate. eg. If typical usage
* sees 2000 messages arriving at a client per second, with an average message size
* of 500 bytes, then 2000 * 500 = 1000000 characters will be right on the limit.
* In this scenario, a buffer size of 1500000 character might be more appropriate
* to allow some overhead for bursty telemetry, and temporary UI load during page
* load.
*
* The PerformanceIndicator plugin (openmct.plugins.PerformanceIndicator) gives
* statistics on buffer utilization. It can be used to scale the buffer appropriately.
*/
setMaxBatchSize(maxBatchSize) {
this.#maxBatchSize = maxBatchSize;
if (!this.#applicationIsInitializing) {
this.#sendMaxBatchSizeToWorker(this.#maxBatchSize);
}
setMaxBufferSize(maxBatchSize) {
this.#maxBufferSize = maxBatchSize;
this.#sendMaxBufferSizeToWorker(this.#maxBufferSize);
}
setMaxBatchWait(wait) {
this.#maxBatchWait = wait;
this.#sendBatchWaitToWorker(this.#maxBatchWait);
setThrottleRate(throttleRate) {
this.#throttleRate = throttleRate;
this.#sendThrottleRateToWorker(this.#throttleRate);
}
#sendMaxBatchSizeToWorker(maxBatchSize) {
setThrottleMessagePattern(throttleMessagePattern) {
this.#worker.postMessage({
type: 'setMaxBatchSize',
maxBatchSize
type: 'setThrottleMessagePattern',
throttleMessagePattern
});
}
#sendBatchWaitToWorker(maxBatchWait) {
#sendMaxBufferSizeToWorker(maxBufferSize) {
this.#worker.postMessage({
type: 'setMaxBatchWait',
maxBatchWait
type: 'setMaxBufferSize',
maxBufferSize
});
}
#sendThrottleRateToWorker(throttleRate) {
this.#worker.postMessage({
type: 'setThrottleRate',
throttleRate
});
}
@ -203,9 +197,38 @@ class BatchingWebSocket extends EventTarget {
#routeMessageToHandler(message) {
if (message.data.type === 'batch') {
this.start = Date.now();
const batch = message.data.batch;
if (batch.dropped === true && !this.#showingRateLimitNotification) {
const now = performance.now();
let currentBufferLength = message.data.currentBufferLength;
let maxBufferSize = message.data.maxBufferSize;
let parameterCount = batch.length;
if (this.#peakBufferSize < currentBufferLength) {
this.#peakBufferSize = currentBufferLength;
}
if (this.#openmct.performance !== undefined) {
if (!isNaN(this.#lastBatchReceived)) {
const elapsed = (now - this.#lastBatchReceived) / 1000;
this.#lastBatchReceived = now;
this.#openmct.performance.measurements.set(
'Parameters/s',
Math.floor(parameterCount / elapsed)
);
}
this.#openmct.performance.measurements.set(
'Buff. Util. (bytes)',
`${currentBufferLength} / ${maxBufferSize}`
);
this.#openmct.performance.measurements.set(
'Peak Buff. Util. (bytes)',
`${this.#peakBufferSize} / ${maxBufferSize}`
);
}
this.start = Date.now();
const dropped = message.data.dropped;
if (dropped === true && !this.#showingRateLimitNotification) {
const notification = this.#openmct.notifications.alert(
'Telemetry dropped due to client rate limiting.',
{ hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' }
@ -240,18 +263,16 @@ class BatchingWebSocket extends EventTarget {
console.warn(`Event loop is too busy to process batch.`);
this.#waitUntilIdleAndRequestNextBatch(batch);
} else {
// After ingesting a telemetry batch, wait until the event loop is idle again before
// informing the worker we are ready for another batch.
this.#readyForNextBatch();
}
} else {
if (waitedFor > ONE_SECOND) {
if (waitedFor > this.#throttleRate) {
console.warn(`Warning, batch processing took ${waitedFor}ms`);
}
this.#readyForNextBatch();
}
},
{ timeout: ONE_SECOND }
{ timeout: this.#throttleRate }
);
}
}

View File

@ -231,26 +231,20 @@ export default class TelemetryAPI {
* @returns {TelemetryRequestOptions} the options, with defaults filled in
*/
standardizeRequestOptions(options = {}) {
if (!Object.hasOwn(options, 'start')) {
const bounds = options.timeContext?.getBounds();
if (bounds?.start) {
options.start = options.timeContext.getBounds().start;
} else {
options.start = this.openmct.time.getBounds().start;
}
}
if (!Object.hasOwn(options, 'end')) {
const bounds = options.timeContext?.getBounds();
if (bounds?.end) {
options.end = options.timeContext.getBounds().end;
} else {
options.end = this.openmct.time.getBounds().end;
}
if (!Object.hasOwn(options, 'timeContext')) {
options.timeContext = this.openmct.time;
}
if (!Object.hasOwn(options, 'domain')) {
options.domain = this.openmct.time.getTimeSystem().key;
options.domain = options.timeContext.getTimeSystem().key;
}
if (!Object.hasOwn(options, 'start')) {
options.start = options.timeContext.getBounds().start;
}
if (!Object.hasOwn(options, 'end')) {
options.end = options.timeContext.getBounds().end;
}
return options;

View File

@ -269,36 +269,40 @@ describe('Telemetry API', () => {
await telemetryAPI.request(domainObject);
const { signal } = new AbortController();
expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(jasmine.any(Object), {
expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(domainObject, {
signal,
start: 0,
end: 1,
domain: 'system'
domain: 'system',
timeContext: openmct.time
});
expect(telemetryProvider.request).toHaveBeenCalledWith(jasmine.any(Object), {
expect(telemetryProvider.request).toHaveBeenCalledWith(domainObject, {
signal,
start: 0,
end: 1,
domain: 'system'
domain: 'system',
timeContext: openmct.time
});
telemetryProvider.supportsRequest.calls.reset();
telemetryProvider.request.calls.reset();
await telemetryAPI.request(domainObject, {});
expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(jasmine.any(Object), {
expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(domainObject, {
signal,
start: 0,
end: 1,
domain: 'system'
domain: 'system',
timeContext: openmct.time
});
expect(telemetryProvider.request).toHaveBeenCalledWith(jasmine.any(Object), {
expect(telemetryProvider.request).toHaveBeenCalledWith(domainObject, {
signal,
start: 0,
end: 1,
domain: 'system'
domain: 'system',
timeContext: openmct.time
});
});
@ -313,18 +317,20 @@ describe('Telemetry API', () => {
domain: 'someDomain'
});
const { signal } = new AbortController();
expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(jasmine.any(Object), {
expect(telemetryProvider.supportsRequest).toHaveBeenCalledWith(domainObject, {
start: 20,
end: 30,
domain: 'someDomain',
signal
signal,
timeContext: openmct.time
});
expect(telemetryProvider.request).toHaveBeenCalledWith(jasmine.any(Object), {
expect(telemetryProvider.request).toHaveBeenCalledWith(domainObject, {
start: 20,
end: 30,
domain: 'someDomain',
signal
signal,
timeContext: openmct.time
});
});
describe('telemetry batching support', () => {

View File

@ -62,9 +62,6 @@ export default class TelemetryCollection extends EventEmitter {
this.futureBuffer = [];
this.parseTime = undefined;
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
if (!Object.hasOwn(options, 'timeContext')) {
options.timeContext = this.openmct.time;
}
this.options = options;
this.unsubscribe = undefined;
this.pageState = undefined;
@ -84,6 +81,9 @@ export default class TelemetryCollection extends EventEmitter {
this._error(LOADED_ERROR);
}
if (!Object.hasOwn(this.options, 'timeContext')) {
this.options.timeContext = this.openmct.time;
}
this._setTimeSystem(this.options.timeContext.getTimeSystem());
this.lastBounds = this.options.timeContext.getBounds();
this._watchBounds();
@ -116,8 +116,7 @@ export default class TelemetryCollection extends EventEmitter {
}
/**
* This will start the requests for historical and realtime data,
* as well as setting up initial values and watchers
* @returns {Array} All bounded telemetry
*/
getAll() {
return this.boundedTelemetry;
@ -128,7 +127,7 @@ export default class TelemetryCollection extends EventEmitter {
* @private
*/
async _requestHistoricalTelemetry() {
let options = this.openmct.telemetry.standardizeRequestOptions({ ...this.options });
const options = this.openmct.telemetry.standardizeRequestOptions({ ...this.options });
const historicalProvider = this.openmct.telemetry.findRequestProvider(
this.domainObject,
options

View File

@ -24,10 +24,6 @@ export default function installWorker() {
const ONE_SECOND = 1000;
const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000];
/**
* @typedef {import('./BatchingWebSocket').BatchingStrategy} BatchingStrategy
*/
/**
* Provides a WebSocket connection that is resilient to errors and dropouts.
* On an error or dropout, will automatically reconnect.
@ -215,17 +211,17 @@ export default function installWorker() {
case 'message':
this.#websocket.enqueueMessage(message.data.message);
break;
case 'setBatchingStrategy':
this.setBatchingStrategy(message);
break;
case 'readyForNextBatch':
this.#messageBatcher.readyForNextBatch();
break;
case 'setMaxBatchSize':
this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize);
case 'setMaxBufferSize':
this.#messageBatcher.setMaxBufferSize(message.data.maxBufferSize);
break;
case 'setMaxBatchWait':
this.#messageBatcher.setMaxBatchWait(message.data.maxBatchWait);
case 'setThrottleRate':
this.#messageBatcher.setThrottleRate(message.data.throttleRate);
break;
case 'setThrottleMessagePattern':
this.#messageBatcher.setThrottleMessagePattern(message.data.throttleMessagePattern);
break;
default:
throw new Error(`Unknown message type: ${type}`);
@ -238,122 +234,69 @@ export default function installWorker() {
disconnect() {
this.#websocket.disconnect();
}
setBatchingStrategy(message) {
const { serializedStrategy } = message.data;
const batchingStrategy = {
// eslint-disable-next-line no-new-func
shouldBatchMessage: new Function(`return ${serializedStrategy.shouldBatchMessage}`)(),
// eslint-disable-next-line no-new-func
getBatchIdFromMessage: new Function(`return ${serializedStrategy.getBatchIdFromMessage}`)()
// Will also include maximum batch length here
};
this.#messageBatcher.setBatchingStrategy(batchingStrategy);
}
}
/**
* Received messages from the WebSocket, and passes them along to the
* Worker interface and back to the main thread.
* Responsible for buffering messages
*/
class WebSocketToWorkerMessageBroker {
#worker;
#messageBatcher;
constructor(messageBatcher, worker) {
this.#messageBatcher = messageBatcher;
this.#worker = worker;
}
routeMessageToHandler(data) {
if (this.#messageBatcher.shouldBatchMessage(data)) {
this.#messageBatcher.addMessageToBatch(data);
} else {
this.#worker.postMessage({
type: 'message',
message: data
});
}
}
}
/**
* Responsible for batching messages according to the defined batching strategy.
*/
class MessageBatcher {
#batch;
#batchingStrategy;
#hasBatch = false;
#maxBatchSize;
class MessageBuffer {
#buffer;
#currentBufferLength;
#dropped;
#maxBufferSize;
#readyForNextBatch;
#worker;
#throttledSendNextBatch;
#throttleMessagePattern;
constructor(worker) {
// No dropping telemetry unless we're explicitly told to.
this.#maxBatchSize = Number.POSITIVE_INFINITY;
this.#maxBufferSize = Number.POSITIVE_INFINITY;
this.#readyForNextBatch = false;
this.#worker = worker;
this.#resetBatch();
this.setMaxBatchWait(ONE_SECOND);
this.setThrottleRate(ONE_SECOND);
}
#resetBatch() {
this.#batch = {};
this.#hasBatch = false;
//this.#batch = {};
this.#buffer = [];
this.#currentBufferLength = 0;
this.#dropped = false;
}
/**
* @param {BatchingStrategy} strategy
*/
setBatchingStrategy(strategy) {
this.#batchingStrategy = strategy;
}
/**
* Applies the `shouldBatchMessage` function from the supplied batching strategy
* to each message to determine if it should be added to a batch. If not batched,
* the message is immediately sent over the worker to the main thread.
* @param {any} message the message received from the WebSocket. See the WebSocket
* documentation for more details -
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
* @returns
*/
shouldBatchMessage(message) {
return (
this.#batchingStrategy.shouldBatchMessage &&
this.#batchingStrategy.shouldBatchMessage(message)
);
}
/**
* Adds the given message to a batch. The batch group that the message is added
* to will be determined by the value returned by `getBatchIdFromMessage`.
* @param {any} message the message received from the WebSocket. See the WebSocket
* documentation for more details -
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
*/
addMessageToBatch(message) {
const batchId = this.#batchingStrategy.getBatchIdFromMessage(message);
let batch = this.#batch[batchId];
if (batch === undefined) {
this.#hasBatch = true;
batch = this.#batch[batchId] = [message];
} else {
batch.push(message);
}
if (batch.length > this.#maxBatchSize) {
console.warn(
`Exceeded max batch size of ${this.#maxBatchSize} for ${batchId}. Dropping value.`
);
batch.shift();
this.#batch.dropped = true;
addMessageToBuffer(message) {
this.#buffer.push(message);
this.#currentBufferLength += message.length;
for (
let i = 0;
this.#currentBufferLength > this.#maxBufferSize && i < this.#buffer.length;
i++
) {
const messageToConsider = this.#buffer[i];
if (this.#shouldThrottle(messageToConsider)) {
this.#buffer.splice(i, 1);
this.#currentBufferLength -= messageToConsider.length;
this.#dropped = true;
}
}
if (this.#readyForNextBatch) {
this.#throttledSendNextBatch();
}
}
setMaxBatchSize(maxBatchSize) {
this.#maxBatchSize = maxBatchSize;
#shouldThrottle(message) {
return (
this.#throttleMessagePattern !== undefined && this.#throttleMessagePattern.test(message)
);
}
setMaxBatchWait(maxBatchWait) {
this.#throttledSendNextBatch = throttle(this.#sendNextBatch.bind(this), maxBatchWait);
setMaxBufferSize(maxBufferSize) {
this.#maxBufferSize = maxBufferSize;
}
setThrottleRate(throttleRate) {
this.#throttledSendNextBatch = throttle(this.#sendNextBatch.bind(this), throttleRate);
}
/**
* Indicates that client code is ready to receive the next batch of
@ -362,21 +305,33 @@ export default function installWorker() {
* any new data is available.
*/
readyForNextBatch() {
if (this.#hasBatch) {
if (this.#hasData()) {
this.#throttledSendNextBatch();
} else {
this.#readyForNextBatch = true;
}
}
#sendNextBatch() {
const batch = this.#batch;
const buffer = this.#buffer;
const dropped = this.#dropped;
const currentBufferLength = this.#currentBufferLength;
this.#resetBatch();
this.#worker.postMessage({
type: 'batch',
batch
dropped,
currentBufferLength: currentBufferLength,
maxBufferSize: this.#maxBufferSize,
batch: buffer
});
this.#readyForNextBatch = false;
this.#hasBatch = false;
}
#hasData() {
return this.#currentBufferLength > 0;
}
setThrottleMessagePattern(priorityMessagePattern) {
this.#throttleMessagePattern = new RegExp(priorityMessagePattern, 'm');
}
}
@ -408,15 +363,14 @@ export default function installWorker() {
}
const websocket = new ResilientWebSocket(self);
const messageBatcher = new MessageBatcher(self);
const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher);
const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self);
const messageBuffer = new MessageBuffer(self);
const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBuffer);
self.addEventListener('message', (message) => {
workerBroker.routeMessageToHandler(message);
});
websocket.registerMessageCallback((data) => {
websocketBroker.routeMessageToHandler(data);
messageBuffer.addMessageToBuffer(data);
});
self.websocketInstance = websocket;

View File

@ -257,7 +257,9 @@ export default {
return {
end,
start
start,
size: 1,
strategy: 'latest'
};
},
loadComposition() {
@ -330,7 +332,11 @@ export default {
this.domainObject.configuration.axes.xKey === undefined ||
this.domainObject.configuration.axes.yKey === undefined
) {
return;
const { xKey, yKey } = this.identifyAxesKeys(axisMetadata);
this.openmct.objects.mutate(this.domainObject, 'configuration.axes', {
xKey,
yKey
});
}
let xValues = [];
@ -429,6 +435,30 @@ export default {
subscribeToAll() {
const telemetryObjects = Object.values(this.telemetryObjects);
telemetryObjects.forEach(this.subscribeToObject);
},
identifyAxesKeys(metadata) {
const { xAxisMetadata, yAxisMetadata } = metadata;
let xKey;
let yKey;
// If xAxisMetadata contains array values, use the first one for xKey
const arrayValues = xAxisMetadata.filter((metaDatum) => metaDatum.isArrayValue);
const nonArrayValues = xAxisMetadata.filter((metaDatum) => !metaDatum.isArrayValue);
if (arrayValues.length > 0) {
xKey = arrayValues[0].key;
yKey = arrayValues.length > 1 ? arrayValues[1].key : yAxisMetadata.key;
} else if (nonArrayValues.length > 0) {
xKey = nonArrayValues[0].key;
yKey = 'none';
} else {
// Fallback if no valid xKey or yKey is found
xKey = 'none';
yKey = 'none';
}
return { xKey, yKey };
}
}
};

View File

@ -39,7 +39,7 @@ export default class ConditionManager extends EventEmitter {
this.shouldEvaluateNewTelemetry = this.shouldEvaluateNewTelemetry.bind(this);
this.compositionLoad = this.composition.load();
this.subscriptions = {};
this.telemetryCollections = {};
this.telemetryObjects = {};
this.testData = {
conditionTestInputs: this.conditionSetDomainObject.configuration.conditionTestData,
@ -48,55 +48,46 @@ export default class ConditionManager extends EventEmitter {
this.initialize();
}
async requestLatestValue(endpoint) {
const options = {
subscribeToTelemetry(telemetryObject) {
const keyString = this.openmct.objects.makeKeyString(telemetryObject.identifier);
if (this.telemetryCollections[keyString]) {
return;
}
const requestOptions = {
size: 1,
strategy: 'latest'
};
const latestData = await this.openmct.telemetry.request(endpoint, options);
if (!latestData) {
throw new Error('Telemetry request failed by returning a falsy response');
}
if (latestData.length === 0) {
return;
}
this.telemetryReceived(endpoint, latestData[0]);
}
subscribeToTelemetry(endpoint) {
const telemetryKeyString = this.openmct.objects.makeKeyString(endpoint.identifier);
if (this.subscriptions[telemetryKeyString]) {
return;
}
const metadata = this.openmct.telemetry.getMetadata(endpoint);
this.telemetryObjects[telemetryKeyString] = Object.assign({}, endpoint, {
telemetryMetaData: metadata ? metadata.valueMetadatas : []
});
// get latest telemetry value (in case subscription is cached and no new data is coming in)
this.requestLatestValue(endpoint);
this.subscriptions[telemetryKeyString] = this.openmct.telemetry.subscribe(
endpoint,
this.telemetryReceived.bind(this, endpoint)
this.telemetryCollections[keyString] = this.openmct.telemetry.requestCollection(
telemetryObject,
requestOptions
);
const metadata = this.openmct.telemetry.getMetadata(telemetryObject);
const telemetryMetaData = metadata ? metadata.valueMetadatas : [];
this.telemetryObjects[keyString] = { ...telemetryObject, telemetryMetaData };
this.telemetryCollections[keyString].on(
'add',
this.telemetryReceived.bind(this, telemetryObject)
);
this.telemetryCollections[keyString].load();
this.updateConditionTelemetryObjects();
}
unsubscribeFromTelemetry(endpointIdentifier) {
const id = this.openmct.objects.makeKeyString(endpointIdentifier);
if (!this.subscriptions[id]) {
console.log('no subscription to remove');
const keyString = this.openmct.objects.makeKeyString(endpointIdentifier);
if (!this.telemetryCollections[keyString]) {
return;
}
this.subscriptions[id]();
delete this.subscriptions[id];
delete this.telemetryObjects[id];
this.telemetryCollections[keyString].destroy();
this.telemetryCollections[keyString] = null;
this.telemetryObjects[keyString] = null;
this.removeConditionTelemetryObjects();
//force re-computation of condition set result as we might be in a state where
@ -107,7 +98,7 @@ export default class ConditionManager extends EventEmitter {
this.timeSystems,
this.openmct.time.getTimeSystem()
);
this.updateConditionResults({ id: id });
this.updateConditionResults({ id: keyString });
this.updateCurrentCondition(latestTimestamp);
if (Object.keys(this.telemetryObjects).length === 0) {
@ -410,11 +401,13 @@ export default class ConditionManager extends EventEmitter {
return this.openmct.time.getBounds().end >= currentTimestamp;
}
telemetryReceived(endpoint, datum) {
telemetryReceived(endpoint, data) {
if (!this.isTelemetryUsed(endpoint)) {
return;
}
const datum = data[0];
const normalizedDatum = this.createNormalizedDatum(datum, endpoint);
const timeSystemKey = this.openmct.time.getTimeSystem().key;
let timestamp = {};
@ -507,8 +500,9 @@ export default class ConditionManager extends EventEmitter {
destroy() {
this.composition.off('add', this.subscribeToTelemetry, this);
this.composition.off('remove', this.unsubscribeFromTelemetry, this);
Object.values(this.subscriptions).forEach((unsubscribe) => unsubscribe());
delete this.subscriptions;
Object.values(this.telemetryCollections).forEach((telemetryCollection) =>
telemetryCollection.destroy()
);
this.conditions.forEach((condition) => {
condition.destroy();

View File

@ -160,8 +160,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"
>

View File

@ -28,11 +28,7 @@
{ 'is-style-invisible': styleItem.style && styleItem.style.isStyleInvisible },
{ 'c-style-thumb--mixed': mixedStyles.indexOf('backgroundColor') > -1 }
]"
:style="[
styleItem.style.imageUrl
? { backgroundImage: 'url(' + styleItem.style.imageUrl + ')' }
: itemStyle
]"
:style="[encodedImageUrl ? { backgroundImage: 'url(' + encodedImageUrl + ')' } : itemStyle]"
class="c-style-thumb"
>
<span
@ -62,7 +58,7 @@
@change="updateStyleValue"
/>
<ToolbarButton
v-if="hasProperty(styleItem.style.imageUrl)"
v-if="hasProperty(encodedImageUrl)"
class="c-style__toolbar-button--image-url"
:options="imageUrlOption"
@change="updateStyleValue"
@ -93,6 +89,8 @@ import ToolbarButton from '@/ui/toolbar/components/ToolbarButton.vue';
import ToolbarColorPicker from '@/ui/toolbar/components/ToolbarColorPicker.vue';
import ToolbarToggleButton from '@/ui/toolbar/components/ToolbarToggleButton.vue';
import { encode_url } from '../../../../utils/encoding';
export default {
name: 'StyleEditor',
components: {
@ -183,11 +181,14 @@ export default {
},
property: 'imageUrl',
formKeys: ['url'],
value: { url: this.styleItem.style.imageUrl },
value: { url: this.encodedImageUrl },
isEditing: this.isEditing,
nonSpecific: this.mixedStyles.indexOf('imageUrl') > -1
};
},
encodedImageUrl() {
return encode_url(this.styleItem.style.imageUrl);
},
isStyleInvisibleOption() {
return {
value: this.styleItem.style.isStyleInvisible,

View File

@ -720,50 +720,69 @@ describe('the plugin', function () {
};
});
it('should evaluate as old when telemetry is not received in the allotted time', (done) => {
it('should evaluate as old when telemetry is not received in the allotted time', async () => {
let onAddResolve;
const onAddCalledPromise = new Promise((resolve) => {
onAddResolve = resolve;
});
const mockTelemetryCollection = {
load: jasmine.createSpy('load'),
on: jasmine.createSpy('on').and.callFake((event, callback) => {
if (event === 'add') {
onAddResolve();
}
})
};
openmct.telemetry = jasmine.createSpyObj('telemetry', [
'subscribe',
'getMetadata',
'request',
'getValueFormatter',
'abortAllRequests'
'abortAllRequests',
'requestCollection'
]);
openmct.telemetry.request.and.returnValue(Promise.resolve([]));
openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry,
valueMetadatas: []
valueMetadatas: testTelemetryObject.telemetry.values,
valuesForHints: jasmine
.createSpy('valuesForHints')
.and.returnValue(testTelemetryObject.telemetry.values),
value: jasmine.createSpy('value').and.callFake((key) => {
return testTelemetryObject.telemetry.values.find((value) => value.key === key);
})
});
openmct.telemetry.request.and.returnValue(Promise.resolve([]));
openmct.telemetry.requestCollection.and.returnValue(mockTelemetryCollection);
openmct.telemetry.getValueFormatter.and.returnValue({
parse: function (value) {
return value;
}
});
let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct);
conditionMgr.on('conditionSetResultUpdated', mockListener);
conditionMgr.telemetryObjects = {
'test-object': testTelemetryObject
};
conditionMgr.updateConditionTelemetryObjects();
setTimeout(() => {
expect(mockListener).toHaveBeenCalledWith({
output: 'Any old telemetry',
id: {
namespace: '',
key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9'
},
conditionId: '39584410-cbf9-499e-96dc-76f27e69885d',
utc: undefined
});
done();
}, 400);
// Wait for the 'on' callback to be called
await onAddCalledPromise;
// Simulate the passage of time and no data received
await new Promise((resolve) => setTimeout(resolve, 400));
expect(mockListener).toHaveBeenCalledWith({
output: 'Any old telemetry',
id: {
namespace: '',
key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9'
},
conditionId: '39584410-cbf9-499e-96dc-76f27e69885d',
utc: undefined
});
});
it('should not evaluate as old when telemetry is received in the allotted time', (done) => {
openmct.telemetry.getMetadata = jasmine.createSpy('getMetadata');
openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry,
valueMetadatas: testTelemetryObject.telemetry.values
});
it('should not evaluate as old when telemetry is received in the allotted time', async () => {
const testDatum = {
'some-key2': '',
utc: 1,
@ -771,8 +790,49 @@ describe('the plugin', function () {
'some-key': null,
id: 'test-object'
};
openmct.telemetry.request = jasmine.createSpy('request');
let onAddResolve;
let onAddCallback;
const onAddCalledPromise = new Promise((resolve) => {
onAddResolve = resolve;
});
const mockTelemetryCollection = {
load: jasmine.createSpy('load'),
on: jasmine.createSpy('on').and.callFake((event, callback) => {
if (event === 'add') {
onAddCallback = callback;
onAddResolve();
}
})
};
openmct.telemetry = jasmine.createSpyObj('telemetry', [
'getMetadata',
'getValueFormatter',
'request',
'subscribe',
'requestCollection'
]);
openmct.telemetry.subscribe.and.returnValue(function () {});
openmct.telemetry.request.and.returnValue(Promise.resolve([testDatum]));
openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry,
valueMetadatas: testTelemetryObject.telemetry.values,
valuesForHints: jasmine
.createSpy('valuesForHints')
.and.returnValue(testTelemetryObject.telemetry.values),
value: jasmine.createSpy('value').and.callFake((key) => {
return testTelemetryObject.telemetry.values.find((value) => value.key === key);
})
});
openmct.telemetry.requestCollection.and.returnValue(mockTelemetryCollection);
openmct.telemetry.getValueFormatter.and.returnValue({
parse: function (value) {
return value;
}
});
const date = 1;
conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input =
['0.4'];
@ -782,19 +842,25 @@ describe('the plugin', function () {
'test-object': testTelemetryObject
};
conditionMgr.updateConditionTelemetryObjects();
conditionMgr.telemetryReceived(testTelemetryObject, testDatum);
setTimeout(() => {
expect(mockListener).toHaveBeenCalledWith({
output: 'Default',
id: {
namespace: '',
key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9'
},
conditionId: '2532d90a-e0d6-4935-b546-3123522da2de',
utc: date
});
done();
}, 300);
// Wait for the 'on' callback to be called
await onAddCalledPromise;
// Simulate receiving telemetry data
onAddCallback([testDatum]);
// Wait a bit for the condition manager to process the data
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockListener).toHaveBeenCalledWith({
output: 'Default',
id: {
namespace: '',
key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9'
},
conditionId: '2532d90a-e0d6-4935-b546-3123522da2de',
utc: date
});
});
});
@ -902,17 +968,25 @@ describe('the plugin', function () {
openmct.telemetry.getMetadata = jasmine.createSpy('getMetadata');
openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry,
valueMetadatas: []
valueMetadatas: testTelemetryObject.telemetry.values,
valuesForHints: jasmine
.createSpy('valuesForHints')
.and.returnValue(testTelemetryObject.telemetry.values),
value: jasmine.createSpy('value').and.callFake((key) => {
return testTelemetryObject.telemetry.values.find((value) => value.key === key);
})
});
conditionMgr.on('conditionSetResultUpdated', mockListener);
conditionMgr.telemetryObjects = {
'test-object': testTelemetryObject
};
conditionMgr.updateConditionTelemetryObjects();
conditionMgr.telemetryReceived(testTelemetryObject, {
'some-key': 2,
utc: date
});
conditionMgr.telemetryReceived(testTelemetryObject, [
{
'some-key': 2,
utc: date
}
]);
let result = conditionMgr.conditions.map((condition) => condition.result);
expect(result[2]).toBeUndefined();
});
@ -1002,26 +1076,37 @@ describe('the plugin', function () {
}
};
openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
// const mockTransactionService = jasmine.createSpyObj(
// 'transactionService',
// ['commit']
// );
openmct.telemetry = jasmine.createSpyObj('telemetry', [
'isTelemetryObject',
'request',
'subscribe',
'getMetadata',
'getValueFormatter',
'request'
'requestCollection'
]);
openmct.telemetry.isTelemetryObject.and.returnValue(true);
openmct.telemetry.subscribe.and.returnValue(function () {});
openmct.telemetry.request.and.returnValue(Promise.resolve([]));
openmct.telemetry.isTelemetryObject.and.returnValue(true);
openmct.telemetry.getMetadata.and.returnValue(testTelemetryObject.telemetry);
openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry,
valueMetadatas: testTelemetryObject.telemetry.values,
valuesForHints: jasmine
.createSpy('valuesForHints')
.and.returnValue(testTelemetryObject.telemetry.values),
value: jasmine.createSpy('value').and.callFake((key) => {
return testTelemetryObject.telemetry.values.find((value) => value.key === key);
})
});
openmct.telemetry.getValueFormatter.and.returnValue({
parse: function (value) {
return value;
}
});
openmct.telemetry.getMetadata.and.returnValue(testTelemetryObject.telemetry);
openmct.telemetry.request.and.returnValue(Promise.resolve([]));
openmct.telemetry.requestCollection.and.returnValue({
load: jasmine.createSpy('load'),
on: jasmine.createSpy('on')
});
const styleRuleManger = new StyleRuleManager(stylesObject, openmct, null, true);
spyOn(styleRuleManger, 'subscribeToConditionSet');

View File

@ -35,6 +35,7 @@
</template>
<script>
import { encode_url } from '../../../utils/encoding';
import conditionalStylesMixin from '../mixins/objectStyles-mixin.js';
import LayoutFrame from './LayoutFrame.vue';
@ -80,12 +81,12 @@ export default {
return this.isEditing || !this.itemStyle?.isStyleInvisible;
},
style() {
let backgroundImage = 'url(' + this.item.url + ')';
let backgroundImage = `url('${encode_url(this.item.url)}')`;
let border = '1px solid ' + this.item.stroke;
if (this.itemStyle) {
if (this.itemStyle.imageUrl !== undefined) {
backgroundImage = 'url(' + this.itemStyle.imageUrl + ')';
backgroundImage = `url('${encode_url(this.itemStyle.imageUrl)}')`;
}
border = this.itemStyle.border;

View File

@ -109,8 +109,9 @@ class DuplicateAction {
let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier);
let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier);
let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier);
const isLocked = parentCandidate.locked === true;
if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) {
if (isLocked || !this.openmct.objects.isPersistable(parentCandidate.identifier)) {
return false;
}
@ -139,10 +140,9 @@ class DuplicateAction {
const parentType = parent && this.openmct.types.get(parent.type);
const child = objectPath[0];
const childType = child && this.openmct.types.get(child.type);
const locked = child.locked ? child.locked : parent && parent.locked;
const isPersistable = this.openmct.objects.isPersistable(child.identifier);
if (locked || !isPersistable) {
if (!isPersistable) {
return false;
}

View File

@ -21,7 +21,12 @@
-->
<template>
<div class="c-fault-mgmt__list data-selectable" :class="classesFromState">
<div
role="listitem"
:aria-label="listItemAriaLabel"
class="c-fault-mgmt__list data-selectable"
:class="classesFromState"
>
<div class="c-fault-mgmt-item c-fault-mgmt__list-checkbox">
<input
type="checkbox"
@ -33,29 +38,44 @@
<div class="c-fault-mgmt-item">
<div
class="c-fault-mgmt__list-severity"
:aria-label="fault.severity"
:title="fault.severity"
:aria-label="severityAriaLabel"
:class="['is-severity-' + severity]"
></div>
</div>
<div class="c-fault-mgmt-item c-fault-mgmt__list-content">
<div class="c-fault-mgmt-item c-fault-mgmt__list-pathname">
<div class="c-fault-mgmt__list-path">{{ fault.namespace }}</div>
<div class="c-fault-mgmt__list-faultname">{{ fault.name }}</div>
<div class="c-fault-mgmt__list-path" aria-label="Fault namespace">
{{ fault.namespace }}
</div>
<div class="c-fault-mgmt__list-faultname" aria-label="Fault name">{{ fault.name }}</div>
</div>
<div class="c-fault-mgmt__list-content-right">
<div class="c-fault-mgmt-item c-fault-mgmt__list-trigVal">
<div class="c-fault-mgmt-item__value" :class="tripValueClassname" title="Trip Value">
<div
class="c-fault-mgmt-item__value"
:class="tripValueClassname"
title="Trip Value"
aria-label="Trip Value"
>
{{ fault.triggerValueInfo.value }}
</div>
</div>
<div class="c-fault-mgmt-item c-fault-mgmt__list-curVal">
<div class="c-fault-mgmt-item__value" :class="liveValueClassname" title="Live Value">
<div
class="c-fault-mgmt-item__value"
:class="liveValueClassname"
title="Live Value"
aria-label="Live Value"
>
{{ fault.currentValueInfo.value }}
</div>
</div>
<div class="c-fault-mgmt-item c-fault-mgmt__list-trigTime">
<div class="c-fault-mgmt-item__value" title="Last Trigger Time">
<div
class="c-fault-mgmt-item__value"
title="Last Trigger Time"
aria-label="Last Trigger Time"
>
{{ fault.triggerTime }}
</div>
</div>
@ -98,7 +118,7 @@ export default {
emits: ['acknowledge-selected', 'shelve-selected', 'toggle-selected', 'clear-all-selected'],
computed: {
checkBoxAriaLabel() {
return `Select fault: ${this.fault.name}`;
return `Select fault: ${this.fault.name || 'Unknown'} in ${this.fault.namespace || 'Unknown'}`;
},
classesFromState() {
const exclusiveStates = [
@ -165,6 +185,12 @@ export default {
classname += SEVERITY_CLASS[triggerValueInfo.monitoringResult] || '';
return classname.trim();
},
listItemAriaLabel() {
return `Fault triggered at ${this.fault.triggerTime || 'Unknown'} with severity ${this.fault.severity || 'Unknown'} in ${this.fault.namespace || 'Unknown'}`;
},
severityAriaLabel() {
return `Severity: ${this.fault.severity || 'Unknown'}`;
}
},
methods: {

View File

@ -68,7 +68,6 @@
import {
FAULT_MANAGEMENT_ALARMS,
FAULT_MANAGEMENT_GLOBAL_ALARMS,
FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS,
FILTER_ITEMS,
SORT_ITEMS
} from './constants.js';
@ -88,6 +87,13 @@ const SEARCH_KEYS = [
'namespace'
];
// Helper function for filtering faults
function filterFaultsByTerm(faults, searchTerm) {
return faults.filter((fault) =>
SEARCH_KEYS.some((key) => fault[key]?.toString().toLowerCase().includes(searchTerm))
);
}
export default {
components: {
FaultManagementListHeader,
@ -111,23 +117,18 @@ export default {
},
filteredFaultsList() {
const filterName = FILTER_ITEMS[this.filterIndex];
let list = this.faultsList;
// Exclude shelved alarms from all views except the Shelved view
if (filterName !== 'Shelved') {
list = list.filter((fault) => fault.shelved !== true);
}
let list = this.faultsList.filter((fault) =>
filterName === 'Shelved' ? fault.shelved : !fault.shelved
);
if (filterName === 'Acknowledged') {
list = list.filter((fault) => fault.acknowledged);
} else if (filterName === 'Unacknowledged') {
list = list.filter((fault) => !fault.acknowledged);
} else if (filterName === 'Shelved') {
list = list.filter((fault) => fault.shelved);
}
if (this.searchTerm.length > 0) {
list = list.filter(this.filterUsingSearchTerm);
list = filterFaultsByTerm(list, this.searchTerm);
}
list.sort(SORT_ITEMS[this.sortBy].sortFunction);
@ -138,6 +139,9 @@ export default {
return this.openmct.faults.supportsActions();
}
},
created() {
this.shelveDurations = this.openmct.faults.getShelveDurations();
},
mounted() {
this.unsubscribe = this.openmct.faults.subscribe(this.domainObject, this.updateFault);
},
@ -158,14 +162,13 @@ export default {
});
}
},
updateFaultList() {
this.openmct.faults.request(this.domainObject).then((faultsData) => {
if (faultsData?.length > 0) {
this.faultsList = faultsData.map((fd) => fd.fault);
} else {
this.faultsList = [];
}
});
async updateFaultList() {
const faultsData = await this.openmct.faults.request(this.domainObject);
if (faultsData?.length > 0) {
this.faultsList = faultsData.map((fd) => fd.fault);
} else {
this.faultsList = [];
}
},
filterUsingSearchTerm(fault) {
if (!fault) {
@ -223,14 +226,29 @@ export default {
);
},
async toggleAcknowledgeSelected(faults = this.selectedFaults) {
let title = '';
if (faults.length > 1) {
title = `Acknowledge ${faults.length} selected faults`;
} else if (faults.length === 1) {
title = `Acknowledge fault: ${faults[0].name}`;
}
const title = this.getAcknowledgeTitle(faults);
const formStructure = {
const formStructure = this.getAcknowledgeFormStructure(title);
try {
const data = await this.openmct.forms.showForm(formStructure);
this.acknowledgeFaults(faults, data);
} catch (err) {
console.error(err);
} finally {
this.resetSelectedFaultMap();
}
},
getAcknowledgeTitle(faults) {
if (faults.length > 1) {
return `Acknowledge ${faults.length} selected faults`;
} else if (faults.length === 1) {
return `Acknowledge fault: ${faults[0].name}`;
}
return '';
},
getAcknowledgeFormStructure(title) {
return {
title,
sections: [
{
@ -253,17 +271,11 @@ export default {
}
}
};
try {
const data = await this.openmct.forms.showForm(formStructure);
faults.forEach((fault) => {
this.openmct.faults.acknowledgeFault(fault, data);
});
} catch (err) {
console.error(err);
} finally {
this.resetSelectedFaultMap();
}
},
acknowledgeFaults(faults, data) {
faults.forEach((fault) => {
this.openmct.faults.acknowledgeFault(fault, data);
});
},
resetSelectedFaultMap() {
Object.keys(this.selectedFaultMap).forEach((key) => {
@ -273,7 +285,7 @@ export default {
async toggleShelveSelected(faults = this.selectedFaults, shelveData = {}) {
const { shelved = true } = shelveData;
if (shelved) {
let title =
const title =
faults.length > 1
? `Shelve ${faults.length} selected faults`
: `Shelve fault: ${faults[0].name}`;
@ -295,10 +307,10 @@ export default {
key: 'shelveDuration',
control: 'select',
name: 'Shelve duration',
options: FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS,
options: this.shelveDurations,
required: false,
cssClass: 'l-input-lg',
value: FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS[0].value
value: this.shelveDurations[0].value
}
]
}
@ -319,18 +331,16 @@ export default {
shelveData.comment = data.comment || '';
shelveData.shelveDuration =
data.shelveDuration !== undefined
? data.shelveDuration
: FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS[0].value;
data.shelveDuration === undefined ? this.shelveDurations[0].value : data.shelveDuration;
} else {
shelveData = {
shelved: false
};
}
Object.values(faults).forEach((selectedFault) => {
this.openmct.faults.shelveFault(selectedFault, shelveData);
});
await Promise.all(
faults.map((selectedFault) => this.openmct.faults.shelveFault(selectedFault, shelveData))
);
this.selectedFaultMap = {};
},

View File

@ -42,24 +42,6 @@ export const FAULT_MANAGEMENT_TYPE = 'faultManagement';
export const FAULT_MANAGEMENT_INSPECTOR = 'faultManagementInspector';
export const FAULT_MANAGEMENT_ALARMS = 'alarms';
export const FAULT_MANAGEMENT_GLOBAL_ALARMS = 'global-alarm-status';
export const FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS = [
{
name: '5 Minutes',
value: 300000
},
{
name: '10 Minutes',
value: 600000
},
{
name: '15 Minutes',
value: 900000
},
{
name: 'Indefinite',
value: 0
}
];
export const FAULT_MANAGEMENT_VIEW = 'faultManagement.view';
export const FAULT_MANAGEMENT_NAMESPACE = 'faults.taxonomy';
export const FILTER_ITEMS = ['Standard View', 'Acknowledged', 'Unacknowledged', 'Shelved'];

View File

@ -45,7 +45,7 @@ class EditPropertiesAction extends PropertiesAction {
const definition = this._getTypeDefinition(object.type);
const persistable = this.openmct.objects.isPersistable(object.identifier);
return persistable && definition && definition.creatable;
return persistable && definition && definition.creatable && !object.locked;
}
invoke(objectPath) {

View File

@ -38,7 +38,7 @@
<img
ref="img"
class="c-thumb__image"
:src="`${image.thumbnailUrl || image.url}`"
:src="imageSrc"
fetchpriority="low"
@load="imageLoadCompleted"
/>
@ -54,6 +54,8 @@
</template>
<script>
import { encode_url } from '../../../utils/encoding';
const THUMB_PADDING = 4;
const BORDER_WIDTH = 2;
@ -96,6 +98,9 @@ export default {
};
},
computed: {
imageSrc() {
return `${encode_url(this.image.thumbnailUrl) || encode_url(this.image.url)}`;
},
ariaLabel() {
return `Image thumbnail from ${this.image.formattedTime}${this.showAnnotationIndicator ? ', has annotations' : ''}`;
},

View File

@ -370,6 +370,7 @@ export default {
createImageWrapper(index, image, showImagePlaceholders) {
const id = `${ID_PREFIX}${image.time}`;
let imageWrapper = document.createElement('div');
imageWrapper.ariaLabel = id;
imageWrapper.classList.add(IMAGE_WRAPPER_CLASS);
imageWrapper.style.left = `${this.xScale(image.time)}px`;
this.setNSAttributesForElement(imageWrapper, {

View File

@ -222,6 +222,7 @@ import { TIME_CONTEXT_EVENTS } from '@/api/time/constants.js';
import imageryData from '@/plugins/imagery/mixins/imageryData.js';
import { VIEW_LARGE_ACTION_KEY } from '@/plugins/viewLargeAction/viewLargeAction.js';
import { encode_url } from '../../../utils/encoding';
import eventHelpers from '../lib/eventHelpers.js';
import AnnotationsCanvas from './AnnotationsCanvas.vue';
import Compass from './Compass/CompassComponent.vue';
@ -364,7 +365,7 @@ export default {
filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
backgroundImage: `${
this.imageUrl
? `url(${this.imageUrl}),
? `url(${encode_url(this.imageUrl)}),
repeating-linear-gradient(
45deg,
transparent,
@ -620,7 +621,7 @@ export default {
if (matchIndex > -1) {
this.setFocusedImage(matchIndex);
} else {
this.paused();
this.paused(false);
}
}
@ -789,7 +790,7 @@ export default {
},
getVisibleLayerStyles(layer) {
return {
backgroundImage: `url(${layer.source})`,
backgroundImage: `url(${encode_url(layer.source)})`,
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX / 2}px, ${
this.imageTranslateY / 2
}px)`,
@ -1082,7 +1083,7 @@ export default {
paused(state) {
this.isPaused = Boolean(state);
if (!state) {
if (!this.isPaused) {
this.previousFocusedImage = null;
this.setFocusedImage(this.nextImageIndex);
this.autoScroll = true;

View File

@ -90,16 +90,17 @@ export default {
dataCleared() {
this.imageHistory = [];
},
dataRemoved(dataToRemove) {
this.imageHistory = this.imageHistory.filter((existingDatum) => {
const shouldKeep = dataToRemove.some((datumToRemove) => {
const existingDatumTimestamp = this.parseTime(existingDatum);
const datumToRemoveTimestamp = this.parseTime(datumToRemove);
dataRemoved(removed) {
const removedTimestamps = {};
removed.forEach((_removed) => {
const removedTimestamp = this.parseTime(_removed);
removedTimestamps[removedTimestamp] = true;
});
return existingDatumTimestamp !== datumToRemoveTimestamp;
});
this.imageHistory = this.imageHistory.filter((image) => {
const imageTimestamp = this.parseTime(image);
return shouldKeep;
return !removedTimestamps[imageTimestamp];
});
},
setDataTimeContext() {

View File

@ -96,6 +96,8 @@ export default {
const createdTimestamp = this.domainObject.created;
const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER;
const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER;
const locked = this.domainObject.locked;
const lockedBy = this.domainObject.lockedBy ?? UNKNOWN_USER;
const modifiedTimestamp = this.domainObject.modified
? this.domainObject.modified
: this.domainObject.created;
@ -148,6 +150,13 @@ export default {
});
}
if (locked === true) {
details.push({
name: 'Locked By',
value: lockedBy
});
}
if (version) {
details.push({
name: 'Version',

View File

@ -19,14 +19,23 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
const PERFORMANCE_OVERLAY_RENDER_INTERVAL = 1000;
export default function PerformanceIndicator() {
return function install(openmct) {
let frames = 0;
let lastCalculated = performance.now();
const indicator = openmct.indicators.simpleIndicator();
openmct.performance = {
measurements: new Map()
};
const indicator = openmct.indicators.simpleIndicator();
indicator.key = 'performance-indicator';
indicator.text('~ fps');
indicator.description('Performance Indicator');
indicator.statusClass('s-status-info');
indicator.on('click', showOverlay);
openmct.indicators.add(indicator);
let rafHandle = requestAnimationFrame(incrementFrames);
@ -58,5 +67,58 @@ export default function PerformanceIndicator() {
indicator.statusClass('s-status-error');
}
}
function showOverlay() {
const overlayStylesText = `
#c-performance-indicator--overlay {
background-color:rgba(0,0,0,0.5);
position: absolute;
width: 300px;
left: calc(50% - 300px);
}
`;
const overlayMarkup = `
<div id="c-performance-indicator--overlay" title="Performance Overlay">
<table id="c-performance-indicator--table">
<tr class="c-performance-indicator--row"><td class="c-performance-indicator--measurement-name"></td><td class="c-performance-indicator--measurement-value"></td></tr>
</table>
</div>
`;
const overlayTemplate = document.createElement('div');
overlayTemplate.innerHTML = overlayMarkup;
const overlay = overlayTemplate.cloneNode(true);
overlay.querySelector('.c-performance-indicator--row').remove();
const overlayStyles = document.createElement('style');
overlayStyles.appendChild(document.createTextNode(overlayStylesText));
document.head.appendChild(overlayStyles);
document.body.appendChild(overlay);
indicator.off('click', showOverlay);
const interval = setInterval(() => {
overlay.querySelector('#c-performance-indicator--table').innerHTML = '';
for (const [name, value] of openmct.performance.measurements.entries()) {
const newRow = overlayTemplate
.querySelector('.c-performance-indicator--row')
.cloneNode(true);
newRow.querySelector('.c-performance-indicator--measurement-name').innerText = name;
newRow.querySelector('.c-performance-indicator--measurement-value').innerText = value;
overlay.querySelector('#c-performance-indicator--table').appendChild(newRow);
}
}, PERFORMANCE_OVERLAY_RENDER_INTERVAL);
indicator.on(
'click',
() => {
overlayStyles.remove();
overlay.remove();
indicator.on('click', showOverlay);
clearInterval(interval);
},
{ once: true, capture: true }
);
}
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,191 @@
import http from 'http';
import nano from 'nano';
import { parseArgs } from 'util';
const COUCH_URL = process.env.OPENMCT_COUCH_URL || 'http://127.0.0.1:5984';
const COUCH_DB_NAME = process.env.OPENMCT_DATABASE_NAME || 'openmct';
const {
values: { couchUrl, database, lock, unlock, startObjectKeystring, user, pass }
} = parseArgs({
options: {
couchUrl: {
type: 'string',
default: COUCH_URL
},
database: {
type: 'string',
short: 'd',
default: COUCH_DB_NAME
},
lock: {
type: 'boolean',
short: 'l'
},
unlock: {
type: 'boolean',
short: 'u'
},
startObjectKeystring: {
type: 'string',
short: 'o',
default: 'mine'
},
user: {
type: 'string'
},
pass: {
type: 'string'
}
}
});
const BATCH_SIZE = 100;
const SOCKET_POOL_SIZE = 100;
const locked = lock === true;
console.info(`Connecting to ${couchUrl}/${database}`);
console.info(`${locked ? 'Locking' : 'Unlocking'} all children of ${startObjectKeystring}`);
const poolingAgent = new http.Agent({
keepAlive: true,
maxSockets: SOCKET_POOL_SIZE
});
const db = nano({
url: couchUrl,
requestDefaults: {
agent: poolingAgent
}
}).use(database);
db.auth(user, pass);
if (!unlock && !lock) {
throw new Error('Either -l or -u option is required');
}
const startObjectIdentifier = keystringToIdentifier(startObjectKeystring);
const documentBatch = [];
const alreadySeen = new Set();
let updatedDocumentCount = 0;
await processObjectTreeFrom(startObjectIdentifier);
//Persist final batch
await persistBatch();
console.log(`Processed ${updatedDocumentCount} documents`);
function processObjectTreeFrom(parentObjectIdentifier) {
//1. Fetch document for identifier;
return fetchDocument(parentObjectIdentifier)
.then(async (document) => {
if (document !== undefined) {
if (!alreadySeen.has(document._id)) {
alreadySeen.add(document._id);
//2. Lock or unlock object
document.model.locked = locked;
document.model.disallowUnlock = locked;
if (locked) {
document.model.lockedBy = 'script';
} else {
delete document.model.lockedBy;
}
//3. Push document to a batch
documentBatch.push(document);
//4. Persist batch if necessary, reporting failures
await persistBatchIfNeeded();
//5. Repeat for each composee
const composition = document.model.composition || [];
return Promise.all(
composition.map((composee) => {
return processObjectTreeFrom(composee);
})
);
}
}
})
.catch((error) => {
console.log(`Error ${error}`);
});
}
async function fetchDocument(identifierOrKeystring) {
let keystring;
if (typeof identifierOrKeystring === 'object') {
keystring = identifierToKeystring(identifierOrKeystring);
} else {
keystring = identifierOrKeystring;
}
try {
const document = await db.get(keystring);
return document;
} catch (error) {
return undefined;
}
}
function persistBatchIfNeeded() {
if (documentBatch.length >= BATCH_SIZE) {
return persistBatch();
} else {
//Noop - batch is not big enough yet
return;
}
}
async function persistBatch() {
try {
const localBatch = [].concat(documentBatch);
//Immediately clear the shared batch array. This asynchronous process is non-blocking, and
//we don't want to try and persist the same batch multiple times while we are waiting for
//the subsequent bulk operation to complete.
updatedDocumentCount += documentBatch.length;
documentBatch.splice(0, documentBatch.length);
const response = await db.bulk({ docs: localBatch });
if (response instanceof Array) {
response.forEach((r) => {
console.info(JSON.stringify(r));
});
} else {
console.info(JSON.stringify(response));
}
} catch (error) {
if (error instanceof Array) {
error.forEach((e) => console.error(JSON.stringify(e)));
} else {
console.error(`${error.statusCode} - ${error.reason}`);
}
}
}
function keystringToIdentifier(keystring) {
const tokens = keystring.split(':');
if (tokens.length === 2) {
return {
namespace: tokens[0],
key: tokens[1]
};
} else {
return {
namespace: '',
key: tokens[0]
};
}
}
function identifierToKeystring(identifier) {
if (typeof identifier === 'string') {
return identifier;
} else if (typeof identifier === 'object') {
if (identifier.namespace) {
return `${identifier.namespace}:${identifier.key}`;
} else {
return identifier.key;
}
}
}

View File

@ -160,6 +160,24 @@ add_index_and_views() {
echo "Unable to create annotation_keystring_index"
echo $response
fi
# Add auth database for locked objects
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/auth \
--header 'Content-Type: application/json' \
--data '{
"_id": "_design/auth",
"language": "javascript",
"validate_doc_update": "function (newDoc, oldDoc, userCtx) { if (userCtx.roles.indexOf('\''_admin'\'') !== -1) { return; } else if (oldDoc === null) { return; } else if (oldDoc.model.type === '\''timer'\'' || oldDoc.model.type === '\''notebook'\'' || oldDoc.model.type === '\''restricted-notebook'\'') { if (oldDoc.model.name !== newDoc.model.name) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; } } else if (oldDoc.model.locked === true && oldDoc.model.disallowUnlock === true) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; }}"
}')
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created _design/auth design document for locked objects"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "_design/auth already exists, skipping creation"
else
echo "Unable to create _design/auth"
echo $response
fi
}
# Main script execution

View File

@ -69,7 +69,6 @@ const INNER_TEXT_PADDING = 15;
const TEXT_LEFT_PADDING = 5;
const ROW_PADDING = 5;
const SWIMLANE_PADDING = 3;
const RESIZE_POLL_INTERVAL = 200;
const ROW_HEIGHT = 22;
const MAX_TEXT_WIDTH = 300;
const MIN_ACTIVITY_WIDTH = 2;
@ -143,13 +142,15 @@ export default {
this.canvasContext = canvas.getContext('2d');
this.setDimensions();
this.setTimeContext();
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
this.handleConfigurationChange(this.configuration);
this.planViewConfiguration.on('change', this.handleConfigurationChange);
this.loadComposition();
this.resizeObserver = new ResizeObserver(this.resize);
this.resizeObserver.observe(this.$refs.plan);
},
beforeUnmount() {
clearInterval(this.resizeTimer);
this.resizeObserver.disconnect();
this.stopFollowingTimeContext();
if (this.unlisten) {
this.unlisten();
@ -242,11 +243,12 @@ export default {
if (this.planObject) {
this.showReplacePlanDialog(domainObject);
} else {
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.setupPlan(domainObject);
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
}
},
handleConfigurationChange(newConfiguration) {
this.configuration = this.planViewConfiguration.getConfiguration();
Object.keys(newConfiguration).forEach((key) => {
this[key] = newConfiguration[key];
});
@ -422,7 +424,10 @@ export default {
return currentRow || SWIMLANE_PADDING;
},
generateActivities() {
const groupNames = getValidatedGroups(this.domainObject, this.planData);
if (!this.planObject) {
return;
}
const groupNames = getValidatedGroups(this.planObject, this.planData);
if (!groupNames.length) {
return;

View File

@ -1,5 +1,5 @@
<!--
Open MCT, Copyright (c) 2014-2023, United States Government
Open MCT, Copyright (c) 2014-2024, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.

View File

@ -1,5 +1,5 @@
<!--
Open MCT, Copyright (c) 2014-2023, United States Government
Open MCT, Copyright (c) 2014-2024, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.

View File

@ -1113,6 +1113,7 @@ export default {
}
this.listenTo(window, 'mouseup', this.onMouseUp, this);
// TODO: Why do we need this mousemove listener when we have a mousemove listener on the canvas above?
this.listenTo(window, 'mousemove', this.trackMousePosition, this);
// track frozen state on mouseDown to be read on mouseUp
@ -1133,6 +1134,7 @@ export default {
onMouseUp(event) {
this.stopListening(window, 'mouseup', this.onMouseUp, this);
// TODO: Why do we need this when we have a mousemove listener on the canvas above?
this.stopListening(window, 'mousemove', this.trackMousePosition, this);
if (this.isMouseClick() && event.shiftKey) {

View File

@ -230,7 +230,9 @@ export default class PlotSeries extends Model {
const newPoints = _(data)
.concat(points)
.sortBy(this.getXVal)
.uniq(true, (point) => [this.getXVal(point), this.getYVal(point)].join())
.sortedUniqBy((point) => {
return [this.getXVal(point), this.getYVal(point)].join();
})
.value();
this.reset(newPoints);
} catch (error) {
@ -429,7 +431,7 @@ export default class PlotSeries extends Model {
let data = this.getSeriesData();
let insertIndex = data.length;
const currentYVal = this.getYVal(newData);
const lastYVal = this.getYVal(data[insertIndex - 1]);
const lastYVal = insertIndex > 0 ? this.getYVal(data[insertIndex - 1]) : undefined;
if (this.isValueInvalid(currentYVal) && this.isValueInvalid(lastYVal)) {
console.warn(`[Plot] Invalid Y Values detected: ${currentYVal} ${lastYVal}`);
@ -505,8 +507,12 @@ export default class PlotSeries extends Model {
const pointsToRemove = startIndex + (data.length - endIndex + 1);
if (pointsToRemove > 0) {
if (pointsToRemove < 1000) {
// Remove all points up to the start index
data.slice(0, startIndex).forEach(this.remove, this);
data.slice(endIndex, data.length).forEach(this.remove, this);
// Re-calculate the endIndex since the data array has changed,
// then remove items from endIndex to the end of the array
const newEndIndex = endIndex - startIndex + 1;
data.slice(newEndIndex, data.length).forEach(this.remove, this);
this.updateSeriesData(data);
this.resetStats();
} else {

View File

@ -128,35 +128,22 @@ export default {
}
},
updateStyle(styleObj) {
let elemToStyle = this.getStyleReceiver();
const elemToStyle = this.getStyleReceiver();
if (!styleObj || elemToStyle === undefined) {
if (!styleObj || !elemToStyle) {
return;
}
// handle visibility separately
if (styleObj.isStyleInvisible !== undefined) {
elemToStyle.classList.toggle(STYLE_CONSTANTS.isStyleInvisible, styleObj.isStyleInvisible);
styleObj.isStyleInvisible = null;
}
let keys = Object.keys(styleObj);
keys.forEach((key) => {
if (elemToStyle) {
if (typeof styleObj[key] === 'string' && styleObj[key].indexOf('__no_value') > -1) {
if (elemToStyle.style[key]) {
elemToStyle.style[key] = '';
}
} else {
if (
!styleObj.isStyleInvisible &&
elemToStyle.classList.contains(STYLE_CONSTANTS.isStyleInvisible)
) {
elemToStyle.classList.remove(STYLE_CONSTANTS.isStyleInvisible);
} else if (
styleObj.isStyleInvisible &&
!elemToStyle.classList.contains(styleObj.isStyleInvisible)
) {
elemToStyle.classList.add(styleObj.isStyleInvisible);
}
elemToStyle.style[key] = styleObj[key];
}
Object.entries(styleObj).forEach(([key, value]) => {
if (typeof value !== 'string' || !value.includes('__no_value')) {
elemToStyle.style[key] = value;
} else {
elemToStyle.style[key] = ''; // remove the property
}
});
}

View File

@ -1,5 +1,5 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*

View File

@ -1,5 +1,5 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*

View File

@ -152,17 +152,18 @@ export default class RemoteClock extends DefaultClock {
*/
#waitForReady() {
const waitForInitialTick = (resolve) => {
if (this.lastTick > 0) {
const offsets = this.openmct.time.getClockOffsets();
// Don't ever resolve, this triggers the bug.
/*
resolve({
start: this.lastTick + offsets.start,
end: this.lastTick + offsets.end
});*/
} else {
setTimeout(() => waitForInitialTick(resolve), 100);
}
const tickListener = () => {
if (this.lastTick > 0) {
const offsets = this.openmct.time.getClockOffsets();
this.openmct.time.off('tick', tickListener); // Unregister the tick listener
resolve({
start: this.lastTick + offsets.start,
end: this.lastTick + offsets.end
});
}
};
this.openmct.time.on('tick', tickListener);
};
return new Promise(waitForInitialTick);

View File

@ -20,16 +20,43 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* Intercepts requests to ensure the remote clock is ready.
*
* @param {import('../../openmct').OpenMCT} openmct - The OpenMCT instance.
* @param {import('../../openmct').Identifier} _remoteClockIdentifier - The identifier for the remote clock.
* @param {Function} waitForBounds - A function that returns a promise resolving to the initial bounds.
* @returns {Object} The request interceptor.
*/
function remoteClockRequestInterceptor(openmct, _remoteClockIdentifier, waitForBounds) {
let remoteClockLoaded = false;
return {
appliesTo: () => {
/**
* Determines if the interceptor applies to the given request.
*
* @param {Object} _ - Unused parameter.
* @param {import('../../api/telemetry/TelemetryAPI').TelemetryRequestOptions} request - The request object.
* @returns {boolean} True if the interceptor applies, false otherwise.
*/
appliesTo: (_, request) => {
// Get the activeClock from the Global Time Context
/** @type {import("../../api/time/TimeContext").default} */
const { activeClock } = openmct.time;
// this type of request does not rely on clock having bounds
if (request.strategy === 'latest' && request.timeContext.isRealTime()) {
return false;
}
return activeClock?.key === 'remote-clock' && !remoteClockLoaded;
},
/**
* Invokes the interceptor to modify the request.
*
* @param {Object} request - The request object.
* @returns {Promise<Object>} The modified request object.
*/
invoke: async (request) => {
const timeContext = request?.timeContext ?? openmct.time;

View File

@ -38,11 +38,11 @@
v-for="(tab, index) in tabsList"
:ref="tab.keyString"
:key="tab.keyString"
:aria-label="`${tab.domainObject.name} tab`"
:aria-label="`${tab.domainObject.name} tab${tab.keyString === currentTab.keyString ? ' - selected' : ''}`"
class="c-tab c-tabs-view__tab js-tab"
role="tab"
:class="{
'is-current': isCurrent(tab)
'is-current': tab.keyString === currentTab.keyString
}"
@click="showTab(tab, index)"
@mouseover.ctrl="showToolTip(tab)"
@ -74,7 +74,7 @@
:key="tab.keyString"
:style="getTabStyles(tab)"
class="c-tabs-view__object-holder"
:class="{ 'c-tabs-view__object-holder--hidden': !isCurrent(tab) }"
:class="{ 'c-tabs-view__object-holder--hidden': tab.keyString !== currentTab.keyString }"
>
<ObjectView
v-if="shouldLoadTab(tab)"
@ -353,7 +353,10 @@ export default {
this.internalDomainObject = domainObject;
},
persistCurrentTabIndex(index) {
this.openmct.objects.mutate(this.internalDomainObject, 'currentTabIndex', index);
//only persist if the domain object is not locked. The object mutate API will deal with whether the object is persistable or not.
if (!this.internalDomainObject.locked) {
this.openmct.objects.mutate(this.internalDomainObject, 'currentTabIndex', index);
}
},
storeCurrentTabIndexInURL(index) {
let currentTabIndexInURL = this.openmct.router.getSearchParam(this.searchTabKey);

View File

@ -25,7 +25,7 @@ import _ from 'lodash';
import StalenessUtils from '../../utils/staleness.js';
import TableRowCollection from './collections/TableRowCollection.js';
import { MODE, ORDER } from './constants.js';
import { MODE } from './constants.js';
import TelemetryTableColumn from './TelemetryTableColumn.js';
import TelemetryTableConfiguration from './TelemetryTableConfiguration.js';
import TelemetryTableNameColumn from './TelemetryTableNameColumn.js';
@ -130,14 +130,7 @@ export default class TelemetryTable extends EventEmitter {
createTableRowCollections() {
this.tableRows = new TableRowCollection();
//Fetch any persisted default sort
let sortOptions = this.configuration.getConfiguration().sortOptions;
//If no persisted sort order, default to sorting by time system, descending.
sortOptions = sortOptions || {
key: this.openmct.time.getTimeSystem().key,
direction: ORDER.DESCENDING
};
const sortOptions = this.configuration.getSortOptions();
this.updateRowLimit();
@ -172,8 +165,8 @@ export default class TelemetryTable extends EventEmitter {
this.removeTelemetryCollection(keyString);
let sortOptions = this.configuration.getConfiguration().sortOptions;
requestOptions.order = sortOptions?.direction ?? ORDER.DESCENDING; // default to descending
let sortOptions = this.configuration.getSortOptions();
requestOptions.order = sortOptions.direction;
if (this.telemetryMode === MODE.PERFORMANCE) {
requestOptions.size = this.rowLimit;
@ -442,12 +435,13 @@ export default class TelemetryTable extends EventEmitter {
}
sortBy(sortOptions) {
this.tableRows.sortBy(sortOptions);
this.configuration.setSortOptions(sortOptions);
if (this.openmct.editor.isEditing()) {
let configuration = this.configuration.getConfiguration();
configuration.sortOptions = sortOptions;
this.configuration.updateConfiguration(configuration);
if (this.telemetryMode === MODE.PERFORMANCE) {
this.tableRows.setSortOptions(sortOptions);
this.clearAndResubscribe();
} else {
this.tableRows.sortBy(sortOptions);
}
}

View File

@ -23,7 +23,11 @@
import { EventEmitter } from 'eventemitter3';
import _ from 'lodash';
import { ORDER } from './constants';
export default class TelemetryTableConfiguration extends EventEmitter {
#sortOptions;
constructor(domainObject, openmct, options) {
super();
@ -44,6 +48,26 @@ export default class TelemetryTableConfiguration extends EventEmitter {
this.notPersistable = !this.openmct.objects.isPersistable(this.domainObject.identifier);
}
getSortOptions() {
return (
this.#sortOptions ||
this.getConfiguration().sortOptions || {
key: this.openmct.time.getTimeSystem().key,
direction: ORDER.DESCENDING
}
);
}
setSortOptions(sortOptions) {
this.#sortOptions = sortOptions;
if (this.openmct.editor.isEditing()) {
let configuration = this.getConfiguration();
configuration.sortOptions = sortOptions;
this.updateConfiguration(configuration);
}
}
getConfiguration() {
let configuration = this.domainObject.configuration || {};
configuration.hiddenColumns = configuration.hiddenColumns || {};
@ -118,7 +142,7 @@ export default class TelemetryTableConfiguration extends EventEmitter {
getAllHeaders() {
let flattenedColumns = _.flatten(Object.values(this.columns));
/* eslint-disable you-dont-need-lodash-underscore/uniq */
let headers = _.uniq(flattenedColumns, false, (column) => column.getKey()).reduce(
let headers = _.uniqBy(flattenedColumns, (column) => column.getKey()).reduce(
fromColumnsToHeadersMap,
{}
);

View File

@ -150,13 +150,13 @@ export default class TableRowCollection extends EventEmitter {
}
insertOrUpdateRows(rowsToAdd, addToBeginning) {
rowsToAdd.forEach((row) => {
rowsToAdd.forEach((row, addRowsIndex) => {
const index = this.getInPlaceUpdateIndex(row);
if (index > -1) {
this.updateRowInPlace(row, index);
} else {
if (addToBeginning) {
this.rows.unshift(row);
this.rows.splice(addRowsIndex, 0, row);
} else {
this.rows.push(row);
}
@ -273,7 +273,7 @@ export default class TableRowCollection extends EventEmitter {
*/
sortBy(sortOptions) {
if (arguments.length > 0) {
this.sortOptions = sortOptions;
this.setSortOptions(sortOptions);
this.rows = _.orderBy(
this.rows,
(row) => row.getParsedValue(sortOptions.key),
@ -286,6 +286,10 @@ export default class TableRowCollection extends EventEmitter {
return Object.assign({}, this.sortOptions);
}
setSortOptions(sortOptions) {
this.sortOptions = sortOptions;
}
setColumnFilter(columnKey, filter) {
filter = filter.trim().toLowerCase();
let wasBlank = this.columnFilters[columnKey] === undefined;

View File

@ -150,7 +150,7 @@ export default {
mounted() {
this.handleNewBounds = _.throttle(this.handleNewBounds, 300, {
leading: true,
trailing: false
trailing: true
});
this.setTimeSystem(this.copy(this.openmct.time.getTimeSystem()));
this.openmct.time.on(TIME_CONTEXT_EVENTS.timeSystemChanged, this.setTimeSystem);
@ -181,6 +181,8 @@ export default {
}
},
stopFollowingTime() {
this.handleNewBounds.cancel();
if (this.timeContext) {
this.timeContext.off(TIME_CONTEXT_EVENTS.boundsChanged, this.handleNewBounds);
this.timeContext.off(TIME_CONTEXT_EVENTS.clockOffsetsChanged, this.setViewFromOffsets);

View File

@ -11,18 +11,19 @@
>
<input
ref="startDate"
v-model="formattedBounds.start"
v-model="formattedBounds.startDate"
class="c-input--datetime"
type="text"
autocorrect="off"
spellcheck="false"
aria-label="Start date"
@input="validateAllBounds('startDate')"
@input="validateInput('startDate')"
@change="reportValidity('startDate')"
/>
<DatePicker
v-if="isUTCBased"
class="c-ctrl-wrapper--menus-right"
:default-date-time="formattedBounds.start"
:default-date-time="formattedBounds.startDate"
:formatter="timeFormatter"
@date-selected="startDateSelected"
/>
@ -37,7 +38,8 @@
autocorrect="off"
spellcheck="false"
aria-label="Start time"
@input="validateAllBounds('startDate')"
@input="validateInput('startTime')"
@change="reportValidity('startTime')"
/>
</div>
@ -48,18 +50,19 @@
>
<input
ref="endDate"
v-model="formattedBounds.end"
v-model="formattedBounds.endDate"
class="c-input--datetime"
type="text"
autocorrect="off"
spellcheck="false"
aria-label="End date"
@input="validateAllBounds('endDate')"
@input="validateInput('endDate')"
@change="reportValidity('endDate')"
/>
<DatePicker
v-if="isUTCBased"
class="c-ctrl-wrapper--menus-left"
:default-date-time="formattedBounds.end"
:default-date-time="formattedBounds.endDate"
:formatter="timeFormatter"
@date-selected="endDateSelected"
/>
@ -74,14 +77,15 @@
autocorrect="off"
spellcheck="false"
aria-label="End time"
@input="validateAllBounds('endDate')"
@input="validateInput('endTime')"
@change="reportValidity('endTime')"
/>
</div>
<div class="pr-time-input pr-time-input--buttons">
<button
class="c-button c-button--major icon-check"
:disabled="isDisabled"
:disabled="hasInputValidityError"
aria-label="Submit time bounds"
@click.prevent="handleFormSubmission(true)"
></button>
@ -125,6 +129,7 @@ export default {
return {
timeFormatter: this.getFormatter(timeSystem.timeFormat),
durationFormatter: this.getFormatter(timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER),
timeSystemKey: timeSystem.key,
bounds: {
start: bounds.start,
end: bounds.end
@ -136,9 +141,29 @@ export default {
endTime: ''
},
isUTCBased: timeSystem.isUTCBased,
isDisabled: false
inputValidityMap: {
startDate: { valid: true },
startTime: { valid: true },
endDate: { valid: true },
endTime: { valid: true }
},
logicalValidityMap: {
limit: { valid: true },
bounds: { valid: true }
}
};
},
computed: {
hasInputValidityError() {
return Object.values(this.inputValidityMap).some((isValid) => !isValid.valid);
},
hasLogicalValidationErrors() {
return Object.values(this.logicalValidityMap).some((isValid) => !isValid.valid);
},
isValid() {
return !this.hasInputValidityError && !this.hasLogicalValidationErrors;
}
},
watch: {
inputBounds: {
handler(newBounds) {
@ -168,25 +193,17 @@ export default {
this.setBounds(bounds);
this.setViewFromBounds(bounds);
},
clearAllValidation() {
[this.$refs.startDate, this.$refs.endDate].forEach(this.clearValidationForInput);
},
clearValidationForInput(input) {
if (input) {
input.setCustomValidity('');
input.title = '';
}
},
setBounds(bounds) {
this.bounds = bounds;
},
setViewFromBounds(bounds) {
this.formattedBounds.start = this.timeFormatter.format(bounds.start).split(' ')[0];
this.formattedBounds.end = this.timeFormatter.format(bounds.end).split(' ')[0];
this.formattedBounds.startDate = this.timeFormatter.format(bounds.start).split(' ')[0];
this.formattedBounds.endDate = this.timeFormatter.format(bounds.end).split(' ')[0];
this.formattedBounds.startTime = this.durationFormatter.format(Math.abs(bounds.start));
this.formattedBounds.endTime = this.durationFormatter.format(Math.abs(bounds.end));
},
setTimeSystem(timeSystem) {
this.timeSystemKey = timeSystem.key;
this.timeFormatter = this.getFormatter(timeSystem.timeFormat);
this.durationFormatter = this.getFormatter(
timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER
@ -201,10 +218,10 @@ export default {
setBoundsFromView(dismiss) {
if (this.$refs.fixedDeltaInput.checkValidity()) {
let start = this.timeFormatter.parse(
`${this.formattedBounds.start} ${this.formattedBounds.startTime}`
`${this.formattedBounds.startDate} ${this.formattedBounds.startTime}`
);
let end = this.timeFormatter.parse(
`${this.formattedBounds.end} ${this.formattedBounds.endTime}`
`${this.formattedBounds.endDate} ${this.formattedBounds.endTime}`
);
this.$emit('update', { start, end });
@ -215,96 +232,93 @@ export default {
return false;
}
},
handleFormSubmission(shouldDismiss) {
this.validateAllBounds('startDate');
this.validateAllBounds('endDate');
clearAllValidation() {
Object.keys(this.inputValidityMap).forEach(this.clearValidation);
},
clearValidation(refName) {
const input = this.getInput(refName);
if (!this.isDisabled) {
input.setCustomValidity('');
input.title = '';
},
handleFormSubmission(shouldDismiss) {
this.validateLimit();
this.reportValidity('limit');
this.validateBounds();
this.reportValidity('bounds');
if (this.isValid) {
this.setBoundsFromView(shouldDismiss);
}
},
validateAllBounds(ref) {
this.isDisabled = false;
validateInput(refName) {
this.clearAllValidation();
if (!this.areBoundsFormatsValid()) {
this.isDisabled = true;
return false;
}
const inputType = refName.includes('Date') ? 'Date' : 'Time';
const formatter = inputType === 'Date' ? this.timeFormatter : this.durationFormatter;
const validationResult = formatter.validate(this.formattedBounds[refName])
? { valid: true }
: { valid: false, message: `Invalid ${inputType}` };
let validationResult = { valid: true };
const currentInput = this.$refs[ref];
this.inputValidityMap[refName] = validationResult;
},
validateBounds() {
const bounds = {
start: this.timeFormatter.parse(
`${this.formattedBounds.startDate} ${this.formattedBounds.startTime}`
),
end: this.timeFormatter.parse(
`${this.formattedBounds.endDate} ${this.formattedBounds.endTime}`
)
};
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
let boundsValues = {
start: this.timeFormatter.parse(
`${this.formattedBounds.start} ${this.formattedBounds.startTime}`
),
end: this.timeFormatter.parse(
`${this.formattedBounds.end} ${this.formattedBounds.endTime}`
)
this.logicalValidityMap.bounds = this.openmct.time.validateBounds(bounds);
},
validateLimit(bounds) {
const limit = this.configuration?.menuOptions
?.filter((option) => option.timeSystem === this.timeSystemKey)
?.find((option) => option.limit)?.limit;
if (this.isUTCBased && limit && bounds.end - bounds.start > limit) {
this.logicalValidityMap.limit = {
valid: false,
message: 'Start and end difference exceeds allowable limit'
};
//TODO: Do we need limits here? We have conductor limits disabled right now
// const limit = this.getBoundsLimit();
const limit = false;
if (this.isUTCBased && limit && boundsValues.end - boundsValues.start > limit) {
if (input === currentInput) {
validationResult = {
valid: false,
message: 'Start and end difference exceeds allowable limit'
};
}
} else if (input === currentInput) {
validationResult = this.openmct.time.validateBounds(boundsValues);
}
return this.handleValidationResults(input, validationResult);
});
} else {
this.logicalValidityMap.limit = { valid: true };
}
},
areBoundsFormatsValid() {
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
const formattedDate =
input === this.$refs.startDate
? `${this.formattedBounds.start} ${this.formattedBounds.startTime}`
: `${this.formattedBounds.end} ${this.formattedBounds.endTime}`;
reportValidity(refName) {
const input = this.getInput(refName);
const validationResult = this.inputValidityMap[refName] ?? this.logicalValidityMap[refName];
const validationResult = this.timeFormatter.validate(formattedDate)
? { valid: true }
: { valid: false, message: 'Invalid date' };
return this.handleValidationResults(input, validationResult);
});
},
getBoundsLimit() {
const configuration = this.configuration.menuOptions
.filter((option) => option.timeSystem === this.timeSystem.key)
.find((option) => option.limit);
const limit = configuration ? configuration.limit : undefined;
return limit;
},
handleValidationResults(input, validationResult) {
if (validationResult.valid !== true) {
input.setCustomValidity(validationResult.message);
input.title = validationResult.message;
this.isDisabled = true;
this.hasLogicalValidationErrors = true;
} else {
input.setCustomValidity('');
input.title = '';
}
this.$refs.fixedDeltaInput.reportValidity();
},
getInput(refName) {
if (Object.keys(this.inputValidityMap).includes(refName)) {
return this.$refs[refName];
}
return validationResult.valid;
return this.$refs.startDate;
},
startDateSelected(date) {
this.formattedBounds.start = this.timeFormatter.format(date).split(' ')[0];
this.validateAllBounds('startDate');
this.formattedBounds.startDate = this.timeFormatter.format(date).split(' ')[0];
this.validateInput('startDate');
this.reportValidity('startDate');
},
endDateSelected(date) {
this.formattedBounds.end = this.timeFormatter.format(date).split(' ')[0];
this.validateAllBounds('endDate');
this.formattedBounds.endDate = this.timeFormatter.format(date).split(' ')[0];
this.validateInput('endDate');
this.reportValidity('endDate');
},
hide($event) {
if ($event.target.className.indexOf('c-button icon-x') > -1) {

View File

@ -99,6 +99,7 @@ export default {
this.composition.off('remove', this.removeItem);
this.composition.off('reorder', this.reorder);
this.stopFollowingTimeContext();
this.handleContentResize.cancel();
this.contentResizeObserver.disconnect();
},
mounted() {

View File

@ -461,7 +461,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o
$colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4);
$colorGaugeLimitLow: $colorGaugeLimitHigh;
$colorGaugeNeedle: rgba(#fff, 0);
$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge.
$transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
$gaugeMeterValueShadow: rgba(255, 255, 255, 0);

View File

@ -477,7 +477,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o
$colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4);
$colorGaugeLimitLow: $colorGaugeLimitHigh;
$colorGaugeNeedle: rgba(#fff, 0);
$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge.
$transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
$gaugeMeterValueShadow: rgba(255, 255, 255, 0);

View File

@ -460,7 +460,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o
$colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.2);
$colorGaugeLimitLow: $colorGaugeLimitHigh;
$colorGaugeNeedle: rgba(#fff, 0);
$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge.
$transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
$gaugeMeterValueShadow: rgba(255, 255, 255, 0);

View File

@ -79,6 +79,7 @@ export default {
const svgWidth = ref(0);
const svgHeight = ref(0);
const axisTransform = ref('translate(0,20)');
const alignmentOffset = ref(0);
const nowMarkerStyle = reactive({
height: '0px',
left: '0px'
@ -100,6 +101,7 @@ export default {
svgWidth,
svgHeight,
axisTransform,
alignmentOffset,
nowMarkerStyle,
openmct
};

View File

@ -1,16 +1,13 @@
/******************************************************** PROGRESS BAR */
@keyframes progressIndeterminate {
0% {
left: 0;
width: 0;
transform:scaleX(0);
}
70% {
left: 0;
width: 100%;
90% {
transform:scaleX(1);
opacity: 1;
}
100% {
left: 100%;
opacity: 0;
}
}
@ -24,11 +21,10 @@
&__bar {
background: $colorProgressBar;
height: 100%;
min-height: $progressBarMinH;
transform-origin: left;
&.--indeterminate {
position: absolute;
@include abs();
animation: progressIndeterminate 1.5s ease-in infinite;
}
}

View File

@ -33,7 +33,7 @@
<h1 class="l-title s-title">Open MCT</h1>
<div class="l-description s-description">
<p>
Open MCT, Copyright &copy; 2014-2023, United States Government as represented by the
Open MCT, Copyright &copy; 2014-2024, United States Government as represented by the
Administrator of the National Aeronautics and Space Administration. All rights reserved.
</p>
<p>

View File

@ -32,6 +32,7 @@
<script>
import mount from 'utils/mount';
import { encode_url } from '../../utils/encoding';
import AboutDialog from './AboutDialog.vue';
export default {
@ -39,7 +40,7 @@ export default {
mounted() {
const branding = this.openmct.branding();
if (branding.smallLogoImage) {
this.$refs.aboutLogo.style.backgroundImage = `url('${branding.smallLogoImage}')`;
this.$refs.aboutLogo.style.backgroundImage = `url('${encode_url(branding.smallLogoImage)}')`;
}
},
methods: {

View File

@ -24,6 +24,7 @@
<div class="l-browse-bar__start">
<button
v-if="hasParent"
aria-label="Navigate up to parent"
class="l-browse-bar__nav-to-parent-button c-icon-button c-icon-button--major icon-arrow-nav-to-parent"
title="Navigate up to parent"
@click="goToParent"
@ -36,7 +37,7 @@
ref="objectName"
class="l-browse-bar__object-name c-object-label__name"
:class="{ 'c-input-inline': isPersistable }"
:contenteditable="isPersistable"
:contenteditable="isNameEditable"
@blur="updateName"
@keydown.enter.prevent
@keyup.enter.prevent="updateNameOnEnterKeyPress"
@ -78,7 +79,7 @@
></button>
<button
v-if="isViewEditable & !isEditing"
v-if="shouldShowLock"
:aria-label="lockedOrUnlockedTitle"
:title="lockedOrUnlockedTitle"
:class="{
@ -88,6 +89,13 @@
@click="toggleLock(!domainObject.locked)"
></button>
<span
v-else-if="domainObject?.locked"
class="icon-lock"
aria-label="Locked for editing, cannot be unlocked."
title="Locked for editing, cannot be unlocked."
></span>
<button
v-if="isViewEditable && !isEditing && !domainObject.locked"
class="l-browse-bar__actions__edit c-button c-button--major icon-pencil"
@ -180,6 +188,18 @@ export default {
};
},
computed: {
isNameEditable() {
return this.isPersistable && !this.domainObject.locked;
},
shouldShowLock() {
if (this.domainObject === undefined) {
return false;
}
if (this.domainObject.disallowUnlock) {
return false;
}
return this.domainObject.locked || (this.isViewEditable && !this.isEditing);
},
statusClass() {
return this.status ? `is-status--${this.status}` : '';
},
@ -253,11 +273,19 @@ export default {
return false;
},
lockedOrUnlockedTitle() {
let title;
if (this.domainObject.locked) {
return 'Locked for editing - click to unlock.';
if (this.domainObject.lockedBy !== undefined) {
title = `Locked for editing by ${this.domainObject.lockedBy}. `;
} else {
title = 'Locked for editing. ';
}
title += 'Click to unlock.';
} else {
return 'Unlocked for editing - click to lock.';
title = 'Unlocked for editing, click to lock.';
}
return title;
},
domainObjectName() {
return this.domainObject?.name ?? '';
@ -288,7 +316,6 @@ export default {
document.addEventListener('click', this.closeViewAndSaveMenu);
this.promptUserbeforeNavigatingAway = this.promptUserbeforeNavigatingAway.bind(this);
window.addEventListener('beforeunload', this.promptUserbeforeNavigatingAway);
this.openmct.editor.on('isEditing', (isEditing) => {
this.isEditing = isEditing;
});
@ -421,8 +448,27 @@ export default {
this.actionCollection.off('update', this.updateActionItems);
delete this.actionCollection;
},
toggleLock(flag) {
this.openmct.objects.mutate(this.domainObject, 'locked', flag);
async toggleLock(flag) {
if (!this.domainObject.disallowUnlock) {
const wasTransactionActive = this.openmct.objects.isTransactionActive();
let transaction;
if (!wasTransactionActive) {
transaction = this.openmct.objects.startTransaction();
}
this.openmct.objects.mutate(this.domainObject, 'locked', flag);
const user = await this.openmct.user.getCurrentUser();
if (user !== undefined) {
this.openmct.objects.mutate(this.domainObject, 'lockedBy', user.id);
}
if (!wasTransactionActive) {
await transaction.commit();
this.openmct.objects.endTransaction();
}
}
},
setStatus(status) {
this.status = status;

25
src/utils/encoding.js Normal file
View File

@ -0,0 +1,25 @@
/*****************************************************************************
* 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.
*****************************************************************************/
export function encode_url(url) {
return url ? encodeURI(url) : url;
}

12
src/utils/random.js Normal file
View File

@ -0,0 +1,12 @@
/**
* Generates a pseudo-random number based on a seed.
*
* @param {number} seed - The seed value to generate the random number.
* @returns {number} A pseudo-random number between 0 (inclusive) and 1 (exclusive).
*/
function seededRandom(seed = Date.now()) {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
export { seededRandom };