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: executors:
pw-focal-development: pw-focal-development:
docker: docker:
- image: mcr.microsoft.com/playwright:v1.45.2-focal - image: mcr.microsoft.com/playwright:v1.48.1-focal
environment: environment:
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed 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_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_LOGLEVEL: 'debug' # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742)
PERCY_PARALLEL_TOTAL: 2 PERCY_PARALLEL_TOTAL: 2
ubuntu: ubuntu:
machine: machine:
@ -17,7 +17,7 @@ executors:
docker_layer_caching: true docker_layer_caching: true
commands: commands:
build_and_install: build_and_install:
description: "All steps used to build and install." description: 'All steps used to build and install.'
parameters: parameters:
node-version: node-version:
type: string type: string
@ -27,7 +27,7 @@ commands:
node-version: << parameters.node-version >> node-version: << parameters.node-version >>
- node/install-packages - node/install-packages
generate_and_store_version_and_filesystem_artifacts: generate_and_store_version_and_filesystem_artifacts:
description: "Track important packages and files" description: 'Track important packages and files'
steps: steps:
- run: | - run: |
[[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts) [[ $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 ls -latR >> /tmp/artifacts/dir.txt
- store_artifacts: - store_artifacts:
path: /tmp/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: 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: parameters:
suite: suite:
type: string type: string
steps: steps:
- run: npm run cov:e2e:report || true - 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: jobs:
npm-audit: npm-audit:
parameters: parameters:
@ -81,7 +112,15 @@ jobs:
mkdir -p dist/reports/tests/ mkdir -p dist/reports/tests/
TESTFILES=$(circleci tests glob "src/**/*Spec.js") TESTFILES=$(circleci tests glob "src/**/*Spec.js")
echo "$TESTFILES" | circleci tests run --command="xargs npm run test" --verbose 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: - store_test_results:
path: dist/reports/tests/ path: dist/reports/tests/
- store_artifacts: - store_artifacts:
@ -96,13 +135,13 @@ jobs:
suite: #ci or full suite: #ci or full
type: string type: string
executor: pw-focal-development executor: pw-focal-development
parallelism: 7 parallelism: 8
steps: steps:
- build_and_install: - build_and_install:
node-version: lts/hydrogen node-version: lts/hydrogen
- when: #Only install chrome-beta when running the 'full' suite to save $$$ - when: #Only install chrome-beta when running the 'full' suite to save $$$
condition: condition:
equal: ["full", <<parameters.suite>>] equal: ['full', <<parameters.suite>>]
steps: steps:
- run: npx playwright install chrome-beta - run: npx playwright install chrome-beta
- run: - run:
@ -159,7 +198,7 @@ jobs:
steps: steps:
- build_and_install: - build_and_install:
node-version: lts/hydrogen 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: | - run: |
export $(cat src/plugins/persistence/couch/.env.ci | xargs) export $(cat src/plugins/persistence/couch/.env.ci | xargs)
docker compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach 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 overall-circleci-commit-status: #These jobs run on every commit
jobs: jobs:
- lint: - lint:
name: node20-lint name: node22-lint
node-version: lts/iron node-version: '22'
- unit-test: - unit-test:
name: node18-chrome name: node18-chrome
node-version: lts/hydrogen node-version: lts/hydrogen
@ -265,8 +304,8 @@ workflows:
the-nightly: #These jobs do not run on PRs, but against master at night the-nightly: #These jobs do not run on PRs, but against master at night
jobs: jobs:
- unit-test: - unit-test:
name: node20-chrome-nightly name: node22-chrome-nightly
node-version: lts/iron node-version: '22'
- unit-test: - unit-test:
name: node18-chrome name: node18-chrome
node-version: lts/hydrogen node-version: lts/hydrogen
@ -284,7 +323,7 @@ workflows:
- e2e-couchdb - e2e-couchdb
triggers: triggers:
- schedule: - schedule:
cron: "0 0 * * *" cron: '0 0 * * *'
filters: filters:
branches: branches:
only: only:

View File

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

View File

@ -30,7 +30,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ 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 ci --no-audit --progress=false
- name: Run E2E Tests (Repeated 10 Times) - name: Run E2E Tests (Repeated 10 Times)

View File

@ -28,7 +28,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ 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 ci --no-audit --progress=false
- run: npm run test:perf:localhost - run: npm run test:perf:localhost
- run: npm run test:perf:contract - run: npm run test:perf:contract

View File

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

View File

@ -27,6 +27,12 @@ module.exports = {
rules: { rules: {
'playwright/no-raw-locators': 'off' '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 //Select from object
await percySnapshot(page, `object selected (theme: ${theme})`) 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 #### 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 * 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. * default view type.
@ -479,6 +510,10 @@ async function setTimeConductorBounds(page, { submitChanges = true, ...bounds })
// Open the time conductor popup // Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); 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) { if (startDate) {
await page.getByLabel('Start date').fill(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 { export {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createExampleTelemetryObject, createExampleTelemetryObject,
createNotification, createNotification,
createPlanFromJSON, createPlanFromJSON,
createStableStateTelemetry,
expandEntireTree, expandEntireTree,
getCanvasPixels, getCanvasPixels,
linkParameterToObject,
navigateToObjectWithFixedTimeBounds, navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime, navigateToObjectWithRealTime,
setEndOffset, setEndOffset,

View File

@ -25,6 +25,7 @@ import { expect } from '../pluginFixtures.js';
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
export async function navigateToFaultManagementWithExample(page) { export async function navigateToFaultManagementWithExample(page) {
await page.addInitScript({ await page.addInitScript({
@ -36,6 +37,7 @@ export async function navigateToFaultManagementWithExample(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
export async function navigateToFaultManagementWithStaticExample(page) { export async function navigateToFaultManagementWithStaticExample(page) {
await page.addInitScript({ await page.addInitScript({
@ -47,6 +49,7 @@ export async function navigateToFaultManagementWithStaticExample(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
export async function navigateToFaultManagementWithoutExample(page) { export async function navigateToFaultManagementWithoutExample(page) {
await page.addInitScript({ await page.addInitScript({
@ -58,6 +61,7 @@ export async function navigateToFaultManagementWithoutExample(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
async function navigateToFaultItemInTree(page) { async function navigateToFaultItemInTree(page) {
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
@ -77,6 +81,8 @@ async function navigateToFaultItemInTree(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
export async function acknowledgeFault(page, rowNumber) { export async function acknowledgeFault(page, rowNumber) {
await openFaultRowMenu(page, rowNumber); await openFaultRowMenu(page, rowNumber);
@ -86,6 +92,8 @@ export async function acknowledgeFault(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {...number} nums
* @returns {Promise<void>}
*/ */
export async function shelveMultipleFaults(page, ...nums) { export async function shelveMultipleFaults(page, ...nums) {
const selectRows = nums.map((num) => { const selectRows = nums.map((num) => {
@ -99,6 +107,8 @@ export async function shelveMultipleFaults(page, ...nums) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {...number} nums
* @returns {Promise<void>}
*/ */
export async function acknowledgeMultipleFaults(page, ...nums) { export async function acknowledgeMultipleFaults(page, ...nums) {
const selectRows = nums.map((num) => { const selectRows = nums.map((num) => {
@ -106,50 +116,43 @@ export async function acknowledgeMultipleFaults(page, ...nums) {
}); });
await Promise.all(selectRows); await Promise.all(selectRows);
await page.locator('button:has-text("Acknowledge")').click(); await page.getByLabel('Acknowledge selected faults').click();
await page.getByLabel('Save').click(); await page.getByLabel('Save').click();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
export async function shelveFault(page, rowNumber) { export async function shelveFault(page, rowNumber) {
await openFaultRowMenu(page, rowNumber); await openFaultRowMenu(page, rowNumber);
await page.locator('.c-menu >> text="Shelve"').click(); await page.getByLabel('Shelve', { exact: true }).click();
// Click [aria-label="Save"]
await page.getByLabel('Save').click(); await page.getByLabel('Save').click();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ * @param {'severity' | 'newest-first' | 'oldest-first'} sort
export async function changeViewTo(page, view) { * @returns {Promise<void>}
await page.locator('.c-fault-mgmt__search-row select').first().selectOption(view);
}
/**
* @param {import('@playwright/test').Page} page
*/ */
export async function sortFaultsBy(page, sort) { 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 {import('@playwright/test').Page} page
* @param {'acknowledged' | 'shelved' | 'standard view'} view
* @returns {Promise<void>}
*/ */
export async function enterSearchTerm(page, term) { export async function changeViewTo(page, view) {
await page.locator('.c-fault-mgmt-search [aria-label="Search Input"]').fill(term); await page.getByTitle('View Filter').getByRole('combobox').selectOption(view);
}
/**
* @param {import('@playwright/test').Page} page
*/
export async function clearSearch(page) {
await enterSearchTerm(page, '');
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
export async function selectFaultItem(page, rowNumber) { export async function selectFaultItem(page, rowNumber) {
await page await page
@ -165,71 +168,37 @@ export async function selectFaultItem(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ * @param {number} rowNumber
export async function getHighestSeverity(page) { * @returns {import('@playwright/test').Locator}
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
*/ */
export function getFault(page, rowNumber) { export function getFault(page, rowNumber) {
const fault = page.locator( const fault = page.getByLabel('Fault triggered at').nth(rowNumber - 1);
`.c-faults-list-view-item-body > .c-fault-mgmt__list >> nth=${rowNumber - 1}`
);
return fault; return fault;
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} name
* @returns {import('@playwright/test').Locator}
*/ */
export function getFaultByName(page, name) { 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; return fault;
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/ */
export async function getFaultName(page, rowNumber) { export async function getFaultName(page, rowNumber) {
const faultName = await page const faultName = await page
.locator(`.c-fault-mgmt__list-faultname >> nth=${rowNumber - 1}`) .getByLabel('Fault name', { exact: true })
.nth(rowNumber - 1)
.textContent(); .textContent();
return faultName; return faultName;
@ -237,21 +206,13 @@ export async function getFaultName(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ * @param {number} rowNumber
export async function getFaultSeverity(page, rowNumber) { * @returns {Promise<string>}
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
*/ */
export async function getFaultNamespace(page, rowNumber) { export async function getFaultNamespace(page, rowNumber) {
const faultNamespace = await page const faultNamespace = await page
.locator(`.c-fault-mgmt__list-path >> nth=${rowNumber - 1}`) .getByLabel('Fault namespace')
.nth(rowNumber - 1)
.textContent(); .textContent();
return faultNamespace; return faultNamespace;
@ -259,10 +220,13 @@ export async function getFaultNamespace(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/ */
export async function getFaultTriggerTime(page, rowNumber) { export async function getFaultTriggerTime(page, rowNumber) {
const faultTriggerTime = await page 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(); .textContent();
return faultTriggerTime.toString().trim(); return faultTriggerTime.toString().trim();
@ -270,11 +234,14 @@ export async function getFaultTriggerTime(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
export async function openFaultRowMenu(page, rowNumber) { export async function openFaultRowMenu(page, rowNumber) {
// select // select
await page await page
.getByLabel('Disposition actions') .getByLabel('Fault triggered at')
.nth(rowNumber - 1) .nth(rowNumber - 1)
.getByLabel('Disposition Actions')
.click(); .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) { export function getEarliestStartTime(planJson) {
const activities = Object.values(planJson).flat(); const activities = Object.values(planJson).flat();
return Math.min(...activities.map((activity) => activity.start)); return Math.min(...activities.map((activity) => activity.start));
} }
@ -139,6 +140,7 @@ export function getEarliestStartTime(planJson) {
*/ */
export function getLatestEndTime(planJson) { export function getLatestEndTime(planJson) {
const activities = Object.values(planJson).flat(); const activities = Object.values(planJson).flat();
return Math.max(...activities.map((activity) => activity.end)); return Math.max(...activities.map((activity) => activity.end));
} }
@ -151,6 +153,7 @@ export function getFirstActivity(planJson) {
const groups = Object.keys(planJson); const groups = Object.keys(planJson);
const firstGroupKey = groups[0]; const firstGroupKey = groups[0];
const firstGroupItems = planJson[firstGroupKey]; const firstGroupItems = planJson[firstGroupKey];
return firstGroupItems[0]; return firstGroupItems[0];
} }

View File

@ -16,7 +16,7 @@
"devDependencies": { "devDependencies": {
"@percy/cli": "1.27.4", "@percy/cli": "1.27.4",
"@percy/playwright": "1.0.4", "@percy/playwright": "1.0.4",
"@playwright/test": "1.45.2", "@playwright/test": "1.48.1",
"@axe-core/playwright": "4.8.5" "@axe-core/playwright": "4.8.5"
}, },
"author": { "author": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -26,8 +26,10 @@ import {
createExampleTelemetryObject, createExampleTelemetryObject,
createNotification, createNotification,
createPlanFromJSON, createPlanFromJSON,
createStableStateTelemetry,
expandEntireTree, expandEntireTree,
getCanvasPixels, getCanvasPixels,
linkParameterToObject,
navigateToObjectWithFixedTimeBounds, navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime, navigateToObjectWithRealTime,
setEndOffset, setEndOffset,
@ -339,4 +341,23 @@ test.describe('AppActions @framework', () => {
// Expect this step to fail // Expect this step to fail
await waitForPlotsToRender(page, { timeout: 1000 }); 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' 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', () => { test.describe('Condition Set Composition', () => {

View File

@ -24,19 +24,13 @@ import {
acknowledgeFault, acknowledgeFault,
acknowledgeMultipleFaults, acknowledgeMultipleFaults,
changeViewTo, changeViewTo,
clearSearch,
enterSearchTerm,
getFault, getFault,
getFaultByName, getFaultByName,
getFaultName, getFaultName,
getFaultNamespace, getFaultNamespace,
getFaultResultCount,
getFaultSeverity,
getFaultTriggerTime, getFaultTriggerTime,
getHighestSeverity,
getLowestSeverity,
navigateToFaultManagementWithExample,
navigateToFaultManagementWithoutExample, navigateToFaultManagementWithoutExample,
navigateToFaultManagementWithStaticExample,
selectFaultItem, selectFaultItem,
shelveFault, shelveFault,
shelveMultipleFaults, shelveMultipleFaults,
@ -46,7 +40,7 @@ import { expect, test } from '../../../../pluginFixtures.js';
test.describe('The Fault Management Plugin using example faults', () => { test.describe('The Fault Management Plugin using example faults', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await navigateToFaultManagementWithExample(page); await navigateToFaultManagementWithStaticExample(page);
}); });
test('Shows a criticality icon for every fault', async ({ 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); 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 page
}) => { }) => {
await selectFaultItem(page, 1); await selectFaultItem(page, 1);
@ -67,9 +61,7 @@ test.describe('The Fault Management Plugin using example faults', () => {
.getByLabel('Source inspector properties') .getByLabel('Source inspector properties')
.getByLabel('inspector property value'); .getByLabel('inspector property value');
await expect( await expect(page.getByLabel('Fault triggered at').first()).toHaveClass(/is-selected/);
page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').first()
).toHaveClass(/is-selected/);
await expect(inspectorFaultName).toHaveCount(1); 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, 1);
await selectFaultItem(page, 2); await selectFaultItem(page, 2);
const selectedRows = page.locator( const selectedRows = page.getByRole('checkbox', { checked: true });
'.c-fault-mgmt__list.is-selected .c-fault-mgmt__list-faultname' await expect(selectedRows).toHaveCount(2);
);
expect(await selectedRows.count()).toEqual(2);
await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('tab', { name: 'Config' }).click();
const firstSelectedFaultName = await selectedRows.nth(0).textContent(); const firstSelectedFaultName = await selectedRows.nth(0).textContent();
const secondSelectedFaultName = await selectedRows.nth(1).textContent(); const secondSelectedFaultName = await selectedRows.nth(1).textContent();
const firstNameInInspectorCount = await page await expect(
.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`) page.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`)
.count(); ).toHaveCount(0);
const secondNameInInspectorCount = await page await expect(
.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`) page.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`)
.count(); ).toHaveCount(0);
expect(firstNameInInspectorCount).toEqual(0);
expect(secondNameInInspectorCount).toEqual(0);
}); });
test('Allows you to shelve a fault', async ({ page }) => { 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); const faultFiveTriggerTime = await getFaultTriggerTime(page, 5);
// should be all faults (5) // should be all faults (5)
let faultResultCount = await getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
expect(faultResultCount).toEqual(5);
// search namespace // search namespace
await enterSearchTerm(page, faultThreeNamespace); await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultThreeNamespace);
faultResultCount = await getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect(faultResultCount).toEqual(1);
expect(await getFaultNamespace(page, 1)).toEqual(faultThreeNamespace); expect(await getFaultNamespace(page, 1)).toEqual(faultThreeNamespace);
// all faults // all faults
await clearSearch(page); await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('');
faultResultCount = await getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
expect(faultResultCount).toEqual(5);
// search name // search name
await enterSearchTerm(page, faultTwoName); await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultTwoName);
faultResultCount = await getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect(faultResultCount).toEqual(1);
expect(await getFaultName(page, 1)).toEqual(faultTwoName); expect(await getFaultName(page, 1)).toEqual(faultTwoName);
// all faults // all faults
await clearSearch(page); await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('');
faultResultCount = await getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
expect(faultResultCount).toEqual(5);
// search triggerTime // search triggerTime
await enterSearchTerm(page, faultFiveTriggerTime); await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultFiveTriggerTime);
faultResultCount = await getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect(faultResultCount).toEqual(1);
expect(await getFaultTriggerTime(page, 1)).toEqual(faultFiveTriggerTime); expect(await getFaultTriggerTime(page, 1)).toEqual(faultFiveTriggerTime);
}); });
test('Allows you to sort faults', async ({ page }) => { 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 faultOneName = 'Example Fault 1';
const faultFiveName = 'Example Fault 5'; const faultFiveName = 'Example Fault 5';
let firstFaultName = await getFaultName(page, 1); let firstFaultName = await getFaultName(page, 1);
@ -237,10 +240,19 @@ test.describe('The Fault Management Plugin using example faults', () => {
await sortFaultsBy(page, 'severity'); await sortFaultsBy(page, 'severity');
const sortedHighestSeverity = await getFaultSeverity(page, 1); const firstFaultSeverityLabel = await page
const sortedLowestSeverity = await getFaultSeverity(page, 5); .getByLabel('Severity:')
expect(sortedHighestSeverity).toEqual(highestSeverity); .first()
expect(sortedLowestSeverity).toEqual(lowestSeverity); .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 }) => { test('Shows no faults when no faults are provided', async ({ page }) => {
const faultCount = await page.locator('c-fault-mgmt__list').count(); await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
expect(faultCount).toEqual(0);
await changeViewTo(page, 'acknowledged'); await changeViewTo(page, 'acknowledged');
const acknowledgedCount = await page.locator('c-fault-mgmt__list').count(); await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
expect(acknowledgedCount).toEqual(0);
await changeViewTo(page, 'shelved'); await changeViewTo(page, 'shelved');
const shelvedCount = await page.locator('c-fault-mgmt__list').count(); await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
expect(shelvedCount).toEqual(0);
}); });
test('Will return no faults when searching', async ({ page }) => { 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(); await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
expect(faultCount).toEqual(0);
}); });
}); });

View File

@ -30,16 +30,19 @@ import {
navigateToObjectWithRealTime, navigateToObjectWithRealTime,
setRealTimeMode setRealTimeMode
} from '../../../../appActions.js'; } 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'; import { expect, test } from '../../../../pluginFixtures.js';
const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt']; const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt'];
const tagHotkey = ['Shift', 'Alt']; const tagHotkey = ['Shift', 'Alt'];
const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan'; const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan';
const thumbnailUrlParamsRegexp = /\?w=100&h=100/; 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. //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', () => { test.describe('Example Imagery Object', () => {
@ -93,9 +96,6 @@ test.describe('Example Imagery Object', () => {
expect(newPage.url()).toContain('.jpg'); 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 ({ test('Can adjust image brightness/contrast by dragging the sliders', async ({
page, page,
browserName 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; let displayLayout;
test.beforeEach(async ({ page }) => { 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 // Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
@ -428,12 +423,7 @@ test.describe('Example Imagery in Display Layout @clock', () => {
await expect.soft(pausePlayButton).toHaveClass(/is-paused/); await expect.soft(pausePlayButton).toHaveClass(/is-paused/);
}); });
test('Imagery View operations @clock', async ({ page }) => { test('Imagery View operations', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
// Edit mode // Edit mode
await page.getByLabel('Edit Object').click(); 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; let flexibleLayout;
test.beforeEach(async ({ page }) => { 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' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' }); 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(); 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.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox');
test.info().annotations.push({ test.info().annotations.push({
type: 'issue', 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; 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' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
tabsView = await createDomainObjectWithDefaults(page, { type: 'Tabs View' }); 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 // Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); 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); 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 * 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 * 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 * 5. Imagery is updated as new images stream in, regardless of pause status
* 6. Old images are discarded when new images stream in * 6. Old images are discarded when their timestamps fall out of bounds
* 7. Image brightness/contrast can be adjusted by dragging the sliders * 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 * @param {import('@playwright/test').Page} page
*/ */
async function performImageryViewOperationsAndAssert(page, layoutObject) { async function performImageryViewOperationsAndAssert(page, layoutObject) {
// Verify that imagery thumbnails use a thumbnail url await test.step('Verify that imagery thumbnails use a thumbnail url', async () => {
const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image'); const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image');
const mainImage = page.locator('.c-imagery__main-image__image'); const mainImage = page.locator('.c-imagery__main-image__image');
await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp); await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp);
await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp); await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp);
});
// Click previous image button // Click previous image button
const previousImageButton = page.getByLabel('Previous image'); const previousImageButton = page.getByLabel('Previous image');
await expect(previousImageButton).toBeVisible(); await expect(previousImageButton).toBeVisible();
@ -736,19 +721,6 @@ async function performImageryViewOperationsAndAssert(page, layoutObject) {
// Unpause imagery // Unpause imagery
await page.locator('.pause-play').click(); 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 // Open the image filter menu
await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click(); await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click();
@ -815,24 +787,6 @@ async function assertBackgroundImageBrightness(page, expected) {
expect(actual).toBe(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 * @param {import('@playwright/test').Page} page
*/ */
@ -918,14 +872,17 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function buttonZoomOnImageAndAssert(page) { async function buttonZoomOnImageAndAssert(page) {
await test.step('Can zoom using buttons', async () => {
// Lock the zoom and pan so it doesn't reset if a new image comes in // 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 page.getByLabel('Focused Image Element').hover({ trial: true });
const lockButton = page.getByRole('button', { const lockButton = page.getByRole('button', {
name: 'Lock current zoom and pan across all images' name: 'Lock current zoom and pan across all images'
}); });
if (!(await lockButton.isVisible())) {
await page.getByLabel('Focused Image Element').hover({ trial: true }); await lockButton.isVisible();
} // if (!(await lockButton.isVisible())) {
// await page.getByLabel('Focused Image Element').hover({ trial: true });
// }
await lockButton.click(); await lockButton.click();
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
@ -974,6 +931,7 @@ async function buttonZoomOnImageAndAssert(page) {
const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(finalBoundingBox).toEqual(initialBoundingBox); expect(finalBoundingBox).toEqual(initialBoundingBox);
});
} }
/** /**
@ -1035,24 +993,6 @@ async function resetImageryPanAndZoom(page) {
await expect(page.locator('.c-thumb__viewable-area')).toBeHidden(); 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 * @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 * as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved. * 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); await page.goto(tabsView.url);
// select first tab // 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 // ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); 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 // no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas[id=webglContext]')).toBeHidden(); 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', () => { test.describe('Tabs View CRUD', () => {

View File

@ -117,7 +117,8 @@ test.describe('Telemetry Table', () => {
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() - 5); endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() - 5);
const endDate = endTimeStamp.toISOString().split('T')[0]; 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 }); await setTimeConductorBounds(page, { endDate, endTime });

View File

@ -24,65 +24,210 @@ import {
setEndOffset, setEndOffset,
setFixedTimeMode, setFixedTimeMode,
setRealTimeMode, setRealTimeMode,
setStartOffset, setStartOffset
setTimeConductorBounds
} from '../../../../appActions.js'; } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Time conductor operations', () => { 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 // Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
const year = new Date().getFullYear(); });
// Set initial valid time bounds test('validate date and time inputs are validated on input event', async ({ page }) => {
const startDate = `${year}-01-01`; const submitButtonLocator = page.getByLabel('Submit time bounds');
const startTime = '01:00:00';
const endDate = `${year}-01-01`;
const endTime = '02:00:00';
await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime });
// Open the time conductor popup // Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
// Test invalid start date await test.step('invalid start date disables submit button', async () => {
const invalidStartDate = `${year}-01-02`; 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 page.getByLabel('Start date').fill(invalidStartDate);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled(); await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start date').fill(startDate); await page.getByLabel('Start date').fill(initialStartDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled(); await expect(submitButtonLocator).toBeEnabled();
});
// Test invalid end date await test.step('invalid start time disables submit button', async () => {
const invalidEndDate = `${year - 1}-12-31`; const initialStartTime = await page.getByLabel('Start time').inputValue();
await page.getByLabel('End date').fill(invalidEndDate); const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
await page.getByLabel('End date').fill(endDate);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
// Test invalid start time
const invalidStartTime = '42:00:00';
await page.getByLabel('Start time').fill(invalidStartTime); await page.getByLabel('Start time').fill(invalidStartTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled(); await expect(submitButtonLocator).toBeDisabled();
await page.getByLabel('Start time').fill(startTime); await page.getByLabel('Start time').fill(initialStartTime);
await expect(page.getByLabel('Submit time bounds')).toBeEnabled(); await expect(submitButtonLocator).toBeEnabled();
});
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)}`;
// Test invalid end time
const invalidEndTime = '43:00:00';
await page.getByLabel('End time').fill(invalidEndTime); await page.getByLabel('End time').fill(invalidEndTime);
await expect(page.getByLabel('Submit time bounds')).toBeDisabled(); await expect(page.getByLabel('End time')).not.toHaveAttribute('title', 'Invalid Time');
await page.getByLabel('End time').fill(endTime); await page.getByLabel('End time').press('Tab');
await expect(page.getByLabel('Submit time bounds')).toBeEnabled(); 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');
});
});
// Submit valid time bounds 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(); await page.getByLabel('Submit time bounds').click();
// Verify the submitted time bounds await expect(page.getByLabel('Start date')).toHaveAttribute(
await expect(page.getByLabel('Start bounds')).toHaveText( 'title',
new RegExp(`${startDate} ${startTime}.000Z`) 'Specified start date exceeds end bound'
); );
await expect(page.getByLabel('End bounds')).toHaveText( await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`);
new RegExp(`${endDate} ${endTime}.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(); 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 * Verify that offsets and url params are preserved when switching
* between fixed timespan and real-time mode. * 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 * as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved. * 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 * as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved. * Administration. All rights reserved.
* *

View File

@ -64,7 +64,7 @@ test.describe('Tabs View', () => {
page.goto(tabsView.url); page.goto(tabsView.url);
// select first tab // 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 // ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); 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}')`); 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) => { test.afterEach(async ({ page }, testInfo) => {
await scanForA11yViolations(page, testInfo.title); await scanForA11yViolations(page, testInfo.title);
}); });

View File

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

View File

@ -26,14 +26,25 @@ import fs from 'fs';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js'; import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js';
import { scanForA11yViolations, test } from '../../avpFixtures.js'; import { scanForA11yViolations, test } from '../../avpFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.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( const examplePlanSmall2 = JSON.parse(
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url)) 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.describe('Visual - Gantt Chart @a11y', () => {
test.beforeEach(async ({ page }) => { 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' }); await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
}); });
test('Gantt Chart View', async ({ page, theme }) => { 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 { scanForA11yViolations, test } from '../../avpFixtures.js';
import { waitForAnimations } from '../../baseFixtures.js'; import { waitForAnimations } from '../../baseFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.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( const examplePlanSmall2 = JSON.parse(
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url)) 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.describe('Visual - Time Strip @a11y', () => {
test.beforeEach(async ({ page }) => { 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' }); await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
}); });
test('Time Strip View', async ({ page, theme }) => { 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_1 = getFirstActivity(examplePlanSmall1);
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
test.describe('Visual - Timelist progress bar @clock @a11y', () => { test.describe('Visual - Timelist progress bar @clock @a11y', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -59,6 +60,11 @@ test.describe('Visual - Timelist progress bar @clock @a11y', () => {
test.describe('Visual - Plan View @a11y', () => { test.describe('Visual - Plan View @a11y', () => {
test.beforeEach(async ({ page }) => { 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' }); await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
}); });

View File

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

View File

@ -20,6 +20,7 @@
* at runtime from the About dialog for additional information. * 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'; import { acknowledgeFault, randomFaults, shelveFault } from './utils.js';
export default function (staticFaults = false) { export default function (staticFaults = false) {
@ -56,6 +57,9 @@ export default function (staticFaults = false) {
return Promise.resolve({ return Promise.resolve({
success: true 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 SEVERITIES = ['WATCH', 'WARNING', 'CRITICAL'];
const MOONWALK_TIMESTAMP = 14159040000;
const NAMESPACE = '/Example/fault-'; const NAMESPACE = '/Example/fault-';
const getRandom = { const getRandom = {
severity: () => SEVERITIES[Math.floor(Math.random() * 3)], severity: () => SEVERITIES[Math.floor(Math.random() * 3)],
@ -13,7 +36,8 @@ const getRandom = {
val = num; val = num;
severity = SEVERITIES[severityIndex - 1]; 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 { return {
@ -43,14 +67,7 @@ const getRandom = {
} }
}; };
export function shelveFault( export function shelveFault(fault, opts = { shelved: true, comment: '', shelveDuration: 90000 }) {
fault,
opts = {
shelved: true,
comment: '',
shelveDuration: 90000
}
) {
fault.shelved = true; fault.shelved = true;
setTimeout(() => { setTimeout(() => {
@ -65,8 +82,8 @@ export function acknowledgeFault(fault) {
export function randomFaults(staticFaults, count = 5) { export function randomFaults(staticFaults, count = 5) {
let faults = []; let faults = [];
for (let x = 1, y = count + 1; x < y; x++) { for (let i = 1; i <= count; i++) {
faults.push(getRandom.fault(x, staticFaults)); faults.push(getRandom.fault(i, staticFaults));
} }
return faults; return faults;

View File

@ -20,6 +20,8 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { seededRandom } from 'utils/random.js';
const DEFAULT_IMAGE_SAMPLES = [ 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-18731.jpg',
'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18732.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) { function getCompassValues(min, max, timestamp) {
return min + Math.random() * (max - min); return min + seededRandom(timestamp) * (max - min);
} }
function getImageSamples(configuration) { function getImageSamples(configuration) {
@ -283,9 +285,9 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) {
utc: Math.floor(timestamp / delay) * delay, utc: Math.floor(timestamp / delay) * delay,
local: Math.floor(timestamp / delay) * delay, local: Math.floor(timestamp / delay) * delay,
url, url,
sunOrientation: getCompassValues(0, 360), sunOrientation: getCompassValues(0, 360, timestamp),
cameraAzimuth: getCompassValues(0, 360), cameraAzimuth: getCompassValues(0, 360, timestamp),
heading: getCompassValues(0, 360), heading: getCompassValues(0, 360, timestamp),
transformations: navCamTransformations, transformations: navCamTransformations,
imageDownloadName 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 * as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved. * Administration. All rights reserved.
* *

276
package-lock.json generated
View File

@ -24,7 +24,6 @@
"@vue/compiler-sfc": "3.4.3", "@vue/compiler-sfc": "3.4.3",
"babel-loader": "9.1.0", "babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1", "babel-plugin-istanbul": "6.1.1",
"codecov": "3.8.3",
"comma-separated-values": "3.6.4", "comma-separated-values": "3.6.4",
"copy-webpack-plugin": "12.0.2", "copy-webpack-plugin": "12.0.2",
"cspell": "7.3.8", "cspell": "7.3.8",
@ -67,6 +66,7 @@
"moment": "2.30.1", "moment": "2.30.1",
"moment-duration-format": "2.3.2", "moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41", "moment-timezone": "0.5.41",
"nano": "10.1.4",
"npm-run-all2": "6.1.2", "npm-run-all2": "6.1.2",
"nyc": "15.1.0", "nyc": "15.1.0",
"painterro": "1.2.87", "painterro": "1.2.87",
@ -93,7 +93,7 @@
"webpack-merge": "5.10.0" "webpack-merge": "5.10.0"
}, },
"engines": { "engines": {
"node": ">=18.14.2 <22" "node": ">=18.14.2 <23"
} }
}, },
"e2e": { "e2e": {
@ -104,7 +104,7 @@
"@axe-core/playwright": "4.8.5", "@axe-core/playwright": "4.8.5",
"@percy/cli": "1.27.4", "@percy/cli": "1.27.4",
"@percy/playwright": "1.0.4", "@percy/playwright": "1.0.4",
"@playwright/test": "1.45.2" "@playwright/test": "1.48.1"
} }
}, },
"e2e/node_modules/@percy/cli": { "e2e/node_modules/@percy/cli": {
@ -1548,12 +1548,13 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.45.2", "version": "1.48.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.2.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz",
"integrity": "sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ==", "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.45.2" "playwright": "1.48.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -1586,15 +1587,6 @@
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==",
"dev": true "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": { "node_modules/@types/body-parser": {
"version": "1.19.5", "version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@ -2308,18 +2300,6 @@
"node": ">=8.9" "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": { "node_modules/aggregate-error": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@ -2454,16 +2434,6 @@
"sprintf-js": "~1.0.2" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -2494,6 +2464,12 @@
"@mdn/browser-compat-data": "^5.2.34" "@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": { "node_modules/axe-core": {
"version": "4.8.4", "version": "4.8.4",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.4.tgz", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.4.tgz",
@ -2503,6 +2479,17 @@
"node": ">=4" "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": { "node_modules/babel-loader": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.0.tgz", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.0.tgz",
@ -3013,26 +3000,6 @@
"node": ">=0.10.0" "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": { "node_modules/color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -3063,6 +3030,18 @@
"node": ">=0.1.90" "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": { "node_modules/comma-separated-values": {
"version": "3.6.4", "version": "3.6.4",
"resolved": "https://registry.npmjs.org/comma-separated-values/-/comma-separated-values-3.6.4.tgz", "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" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -5582,21 +5570,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true "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": { "node_modules/fastest-levenshtein": {
"version": "1.0.16", "version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@ -5821,6 +5794,20 @@
"node": ">=8.0.0" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -6387,20 +6374,6 @@
"node": ">=8.0.0" "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": { "node_modules/http-proxy-middleware": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
@ -6431,19 +6404,6 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"dev": true "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": { "node_modules/human-signals": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -6486,15 +6446,6 @@
"node": ">= 4" "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": { "node_modules/image-size": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz",
@ -7998,6 +7949,35 @@
"multicast-dns": "cli.js" "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": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@ -8037,6 +8017,12 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true "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": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -8764,12 +8750,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.45.2", "version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.2.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz",
"integrity": "sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g==", "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.45.2" "playwright-core": "1.48.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -8782,10 +8769,11 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.45.2", "version": "1.48.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.2.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz",
"integrity": "sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw==", "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
@ -8799,6 +8787,7 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@ -9284,6 +9273,12 @@
"node": ">= 0.10" "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": { "node_modules/pump": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@ -10346,15 +10341,6 @@
"node": ">= 0.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": { "node_modules/streamroller": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
@ -10537,12 +10523,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/style-loader": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz",
@ -10608,31 +10588,6 @@
"node": ">=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": { "node_modules/terser": {
"version": "5.29.1", "version": "5.29.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz",
@ -11013,15 +10968,6 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "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", "@vue/compiler-sfc": "3.4.3",
"babel-loader": "9.1.0", "babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1", "babel-plugin-istanbul": "6.1.1",
"codecov": "3.8.3",
"comma-separated-values": "3.6.4", "comma-separated-values": "3.6.4",
"copy-webpack-plugin": "12.0.2", "copy-webpack-plugin": "12.0.2",
"cspell": "7.3.8", "cspell": "7.3.8",
@ -70,6 +69,7 @@
"moment": "2.30.1", "moment": "2.30.1",
"moment-duration-format": "2.3.2", "moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41", "moment-timezone": "0.5.41",
"nano": "10.1.4",
"npm-run-all2": "6.1.2", "npm-run-all2": "6.1.2",
"nyc": "15.1.0", "nyc": "15.1.0",
"painterro": "1.2.87", "painterro": "1.2.87",
@ -128,12 +128,9 @@
"test:perf:contract": "npm test --workspace e2e -- --config=playwright-performance-dev.config.js", "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: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", "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'", "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: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" "prepare": "npm run build:prod && npx tsc"
}, },
"homepage": "https://nasa.github.io/openmct", "homepage": "https://nasa.github.io/openmct",
@ -142,7 +139,7 @@
"url": "git+https://github.com/nasa/openmct.git" "url": "git+https://github.com/nasa/openmct.git"
}, },
"engines": { "engines": {
"node": ">=18.14.2 <22" "node": ">=18.14.2 <23"
}, },
"browserslist": [ "browserslist": [
"Firefox ESR", "Firefox ESR",

View File

@ -20,6 +20,32 @@
* at runtime from the About dialog for additional information. * 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 { export default class FaultManagementAPI {
/** /**
* @param {import("openmct").OpenMCT} openmct * @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) { addProvider(provider) {
this.provider = 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() { supportsActions() {
return ( return (
@ -45,48 +77,82 @@ export default class FaultManagementAPI {
} }
/** /**
* @param {import('openmct').DomainObject} domainObject * Requests fault data for a given domain object.
* @returns {Promise.<FaultAPIResponse[]>} * 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) { request(domainObject) {
if (!this.provider?.supportsRequest(domainObject)) { if (!this.provider?.supportsRequest(domainObject)) {
return Promise.reject(); return Promise.reject('Provider does not support request operation');
} }
return this.provider.request(domainObject); return this.provider.request(domainObject);
} }
/** /**
* @param {import('openmct').DomainObject} domainObject * Subscribes to fault data updates for a given domain object.
* @param {Function} callback * This method checks if the current provider supports the subscribe operation for the given domain object.
* @returns {Function} unsubscribe * 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) { subscribe(domainObject, callback) {
if (!this.provider?.supportsSubscribe(domainObject)) { if (!this.provider?.supportsSubscribe(domainObject)) {
return Promise.reject(); return Promise.reject('Provider does not support subscribe operation');
} }
return this.provider.subscribe(domainObject, callback); return this.provider.subscribe(domainObject, callback);
} }
/** /**
* @param {Fault} fault * Acknowledges a fault using the provider's acknowledgeFault method.
* @param {*} ackData *
* @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) { acknowledgeFault(fault, ackData) {
return this.provider.acknowledgeFault(fault, ackData); return this.provider.acknowledgeFault(fault, ackData);
} }
/** /**
* @param {Fault} fault * Shelves a fault using the provider's shelveFault method.
* @param {*} shelveData *
* @returns {Promise.<T>} * @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) { shelveFault(fault, shelveData) {
return this.provider.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 * @typedef {Object} TriggerValueInfo
* @property {number} value * @property {number} value

View File

@ -20,25 +20,46 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import installWorker from './WebSocketWorker.js'; import installWorker from './WebSocketWorker.js';
/** /**
* Describes the strategy to be used when batching WebSocket messages * @typedef RequestIdleCallbackOptions
* * @prop {Number} timeout If the number of milliseconds represented by this
* @typedef BatchingStrategy * parameter has elapsed and the callback has not already been called, invoke
* @property {Function} shouldBatchMessage a function that accepts a single * the callback.
* argument - the raw message received from the websocket. Every message * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
* 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.
*/ */
/** /**
* Provides a reliable and convenient WebSocket abstraction layer that handles * Mocks requestIdleCallback for Safari using setTimeout. Functionality will be
* a lot of boilerplate common to managing WebSocket connections such as: * 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 * - Establishing a WebSocket connection to a server
* - Reconnecting on error, with a fallback strategy * - Reconnecting on error, with a fallback strategy
* - Queuing messages so that clients can send messages without concern for the current * - 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. * 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 { class BatchingWebSocket extends EventTarget {
#worker; #worker;
#openmct; #openmct;
#showingRateLimitNotification; #showingRateLimitNotification;
#maxBatchSize; #maxBufferSize;
#applicationIsInitializing; #throttleRate;
#maxBatchWait;
#firstBatchReceived; #firstBatchReceived;
#lastBatchReceived;
#peakBufferSize = Number.NEGATIVE_INFINITY;
/**
* @param {import('openmct.js').OpenMCT} openmct
*/
constructor(openmct) { constructor(openmct) {
super(); super();
// Install worker, register listeners etc. // Install worker, register listeners etc.
@ -74,9 +92,8 @@ class BatchingWebSocket extends EventTarget {
this.#worker = new Worker(workerUrl); this.#worker = new Worker(workerUrl);
this.#openmct = openmct; this.#openmct = openmct;
this.#showingRateLimitNotification = false; this.#showingRateLimitNotification = false;
this.#maxBatchSize = Number.POSITIVE_INFINITY; this.#maxBufferSize = Number.POSITIVE_INFINITY;
this.#maxBatchWait = ONE_SECOND; this.#throttleRate = ONE_SECOND;
this.#applicationIsInitializing = true;
this.#firstBatchReceived = false; this.#firstBatchReceived = false;
const routeMessageToHandler = this.#routeMessageToHandler.bind(this); const routeMessageToHandler = this.#routeMessageToHandler.bind(this);
@ -89,20 +106,6 @@ class BatchingWebSocket extends EventTarget {
}, },
{ once: true } { 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 * @param {number} maxBufferSize the maximum length of the receive buffer in characters.
* 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
* Note that this is a fail-safe that is only invoked if performance drops to the * 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. * 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 * 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. * 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 * This should be set appropriately for the expected data rate. eg. If typical usage
* is received at 10Hz for each telemetry point, then a minimal combination of batch * sees 2000 messages arriving at a client per second, with an average message size
* size and rate is 10 and 1000 respectively. Ideally you would add some margin, so * of 500 bytes, then 2000 * 500 = 1000000 characters will be right on the limit.
* 15 would probably be a better batch size. * 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) { setMaxBufferSize(maxBatchSize) {
this.#maxBatchSize = maxBatchSize; this.#maxBufferSize = maxBatchSize;
if (!this.#applicationIsInitializing) { this.#sendMaxBufferSizeToWorker(this.#maxBufferSize);
this.#sendMaxBatchSizeToWorker(this.#maxBatchSize);
} }
setThrottleRate(throttleRate) {
this.#throttleRate = throttleRate;
this.#sendThrottleRateToWorker(this.#throttleRate);
} }
setMaxBatchWait(wait) { setThrottleMessagePattern(throttleMessagePattern) {
this.#maxBatchWait = wait;
this.#sendBatchWaitToWorker(this.#maxBatchWait);
}
#sendMaxBatchSizeToWorker(maxBatchSize) {
this.#worker.postMessage({ this.#worker.postMessage({
type: 'setMaxBatchSize', type: 'setThrottleMessagePattern',
maxBatchSize throttleMessagePattern
}); });
} }
#sendBatchWaitToWorker(maxBatchWait) { #sendMaxBufferSizeToWorker(maxBufferSize) {
this.#worker.postMessage({ this.#worker.postMessage({
type: 'setMaxBatchWait', type: 'setMaxBufferSize',
maxBatchWait maxBufferSize
});
}
#sendThrottleRateToWorker(throttleRate) {
this.#worker.postMessage({
type: 'setThrottleRate',
throttleRate
}); });
} }
@ -203,9 +197,38 @@ class BatchingWebSocket extends EventTarget {
#routeMessageToHandler(message) { #routeMessageToHandler(message) {
if (message.data.type === 'batch') { if (message.data.type === 'batch') {
this.start = Date.now();
const batch = message.data.batch; 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( const notification = this.#openmct.notifications.alert(
'Telemetry dropped due to client rate limiting.', 'Telemetry dropped due to client rate limiting.',
{ hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' } { 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.`); console.warn(`Event loop is too busy to process batch.`);
this.#waitUntilIdleAndRequestNextBatch(batch); this.#waitUntilIdleAndRequestNextBatch(batch);
} else { } 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(); this.#readyForNextBatch();
} }
} else { } else {
if (waitedFor > ONE_SECOND) { if (waitedFor > this.#throttleRate) {
console.warn(`Warning, batch processing took ${waitedFor}ms`); console.warn(`Warning, batch processing took ${waitedFor}ms`);
} }
this.#readyForNextBatch(); 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 * @returns {TelemetryRequestOptions} the options, with defaults filled in
*/ */
standardizeRequestOptions(options = {}) { standardizeRequestOptions(options = {}) {
if (!Object.hasOwn(options, 'start')) { if (!Object.hasOwn(options, 'timeContext')) {
const bounds = options.timeContext?.getBounds(); options.timeContext = this.openmct.time;
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, 'domain')) { 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; return options;

View File

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

View File

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

View File

@ -24,10 +24,6 @@ export default function installWorker() {
const ONE_SECOND = 1000; const ONE_SECOND = 1000;
const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000]; 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. * Provides a WebSocket connection that is resilient to errors and dropouts.
* On an error or dropout, will automatically reconnect. * On an error or dropout, will automatically reconnect.
@ -215,17 +211,17 @@ export default function installWorker() {
case 'message': case 'message':
this.#websocket.enqueueMessage(message.data.message); this.#websocket.enqueueMessage(message.data.message);
break; break;
case 'setBatchingStrategy':
this.setBatchingStrategy(message);
break;
case 'readyForNextBatch': case 'readyForNextBatch':
this.#messageBatcher.readyForNextBatch(); this.#messageBatcher.readyForNextBatch();
break; break;
case 'setMaxBatchSize': case 'setMaxBufferSize':
this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize); this.#messageBatcher.setMaxBufferSize(message.data.maxBufferSize);
break; break;
case 'setMaxBatchWait': case 'setThrottleRate':
this.#messageBatcher.setMaxBatchWait(message.data.maxBatchWait); this.#messageBatcher.setThrottleRate(message.data.throttleRate);
break;
case 'setThrottleMessagePattern':
this.#messageBatcher.setThrottleMessagePattern(message.data.throttleMessagePattern);
break; break;
default: default:
throw new Error(`Unknown message type: ${type}`); throw new Error(`Unknown message type: ${type}`);
@ -238,122 +234,69 @@ export default function installWorker() {
disconnect() { disconnect() {
this.#websocket.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 * Responsible for buffering messages
* Worker interface and back to the main thread.
*/ */
class WebSocketToWorkerMessageBroker { class MessageBuffer {
#worker; #buffer;
#messageBatcher; #currentBufferLength;
#dropped;
constructor(messageBatcher, worker) { #maxBufferSize;
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;
#readyForNextBatch; #readyForNextBatch;
#worker; #worker;
#throttledSendNextBatch; #throttledSendNextBatch;
#throttleMessagePattern;
constructor(worker) { constructor(worker) {
// No dropping telemetry unless we're explicitly told to. // No dropping telemetry unless we're explicitly told to.
this.#maxBatchSize = Number.POSITIVE_INFINITY; this.#maxBufferSize = Number.POSITIVE_INFINITY;
this.#readyForNextBatch = false; this.#readyForNextBatch = false;
this.#worker = worker; this.#worker = worker;
this.#resetBatch(); this.#resetBatch();
this.setMaxBatchWait(ONE_SECOND); this.setThrottleRate(ONE_SECOND);
} }
#resetBatch() { #resetBatch() {
this.#batch = {}; //this.#batch = {};
this.#hasBatch = false; this.#buffer = [];
this.#currentBufferLength = 0;
this.#dropped = false;
} }
/**
* @param {BatchingStrategy} strategy addMessageToBuffer(message) {
*/ this.#buffer.push(message);
setBatchingStrategy(strategy) { this.#currentBufferLength += message.length;
this.#batchingStrategy = strategy;
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;
} }
/**
* 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;
} }
if (this.#readyForNextBatch) { if (this.#readyForNextBatch) {
this.#throttledSendNextBatch(); 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 * 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. * any new data is available.
*/ */
readyForNextBatch() { readyForNextBatch() {
if (this.#hasBatch) { if (this.#hasData()) {
this.#throttledSendNextBatch(); this.#throttledSendNextBatch();
} else { } else {
this.#readyForNextBatch = true; this.#readyForNextBatch = true;
} }
} }
#sendNextBatch() { #sendNextBatch() {
const batch = this.#batch; const buffer = this.#buffer;
const dropped = this.#dropped;
const currentBufferLength = this.#currentBufferLength;
this.#resetBatch(); this.#resetBatch();
this.#worker.postMessage({ this.#worker.postMessage({
type: 'batch', type: 'batch',
batch dropped,
currentBufferLength: currentBufferLength,
maxBufferSize: this.#maxBufferSize,
batch: buffer
}); });
this.#readyForNextBatch = false; 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 websocket = new ResilientWebSocket(self);
const messageBatcher = new MessageBatcher(self); const messageBuffer = new MessageBuffer(self);
const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher); const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBuffer);
const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self);
self.addEventListener('message', (message) => { self.addEventListener('message', (message) => {
workerBroker.routeMessageToHandler(message); workerBroker.routeMessageToHandler(message);
}); });
websocket.registerMessageCallback((data) => { websocket.registerMessageCallback((data) => {
websocketBroker.routeMessageToHandler(data); messageBuffer.addMessageToBuffer(data);
}); });
self.websocketInstance = websocket; self.websocketInstance = websocket;

View File

@ -257,7 +257,9 @@ export default {
return { return {
end, end,
start start,
size: 1,
strategy: 'latest'
}; };
}, },
loadComposition() { loadComposition() {
@ -330,7 +332,11 @@ export default {
this.domainObject.configuration.axes.xKey === undefined || this.domainObject.configuration.axes.xKey === undefined ||
this.domainObject.configuration.axes.yKey === 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 = []; let xValues = [];
@ -429,6 +435,30 @@ export default {
subscribeToAll() { subscribeToAll() {
const telemetryObjects = Object.values(this.telemetryObjects); const telemetryObjects = Object.values(this.telemetryObjects);
telemetryObjects.forEach(this.subscribeToObject); 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.shouldEvaluateNewTelemetry = this.shouldEvaluateNewTelemetry.bind(this);
this.compositionLoad = this.composition.load(); this.compositionLoad = this.composition.load();
this.subscriptions = {}; this.telemetryCollections = {};
this.telemetryObjects = {}; this.telemetryObjects = {};
this.testData = { this.testData = {
conditionTestInputs: this.conditionSetDomainObject.configuration.conditionTestData, conditionTestInputs: this.conditionSetDomainObject.configuration.conditionTestData,
@ -48,55 +48,46 @@ export default class ConditionManager extends EventEmitter {
this.initialize(); this.initialize();
} }
async requestLatestValue(endpoint) { subscribeToTelemetry(telemetryObject) {
const options = { const keyString = this.openmct.objects.makeKeyString(telemetryObject.identifier);
if (this.telemetryCollections[keyString]) {
return;
}
const requestOptions = {
size: 1, size: 1,
strategy: 'latest' strategy: 'latest'
}; };
const latestData = await this.openmct.telemetry.request(endpoint, options);
if (!latestData) { this.telemetryCollections[keyString] = this.openmct.telemetry.requestCollection(
throw new Error('Telemetry request failed by returning a falsy response'); telemetryObject,
} requestOptions
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)
); );
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(); this.updateConditionTelemetryObjects();
} }
unsubscribeFromTelemetry(endpointIdentifier) { unsubscribeFromTelemetry(endpointIdentifier) {
const id = this.openmct.objects.makeKeyString(endpointIdentifier); const keyString = this.openmct.objects.makeKeyString(endpointIdentifier);
if (!this.subscriptions[id]) { if (!this.telemetryCollections[keyString]) {
console.log('no subscription to remove');
return; return;
} }
this.subscriptions[id](); this.telemetryCollections[keyString].destroy();
delete this.subscriptions[id]; this.telemetryCollections[keyString] = null;
delete this.telemetryObjects[id]; this.telemetryObjects[keyString] = null;
this.removeConditionTelemetryObjects(); this.removeConditionTelemetryObjects();
//force re-computation of condition set result as we might be in a state where //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.timeSystems,
this.openmct.time.getTimeSystem() this.openmct.time.getTimeSystem()
); );
this.updateConditionResults({ id: id }); this.updateConditionResults({ id: keyString });
this.updateCurrentCondition(latestTimestamp); this.updateCurrentCondition(latestTimestamp);
if (Object.keys(this.telemetryObjects).length === 0) { if (Object.keys(this.telemetryObjects).length === 0) {
@ -410,11 +401,13 @@ export default class ConditionManager extends EventEmitter {
return this.openmct.time.getBounds().end >= currentTimestamp; return this.openmct.time.getBounds().end >= currentTimestamp;
} }
telemetryReceived(endpoint, datum) { telemetryReceived(endpoint, data) {
if (!this.isTelemetryUsed(endpoint)) { if (!this.isTelemetryUsed(endpoint)) {
return; return;
} }
const datum = data[0];
const normalizedDatum = this.createNormalizedDatum(datum, endpoint); const normalizedDatum = this.createNormalizedDatum(datum, endpoint);
const timeSystemKey = this.openmct.time.getTimeSystem().key; const timeSystemKey = this.openmct.time.getTimeSystem().key;
let timestamp = {}; let timestamp = {};
@ -507,8 +500,9 @@ export default class ConditionManager extends EventEmitter {
destroy() { destroy() {
this.composition.off('add', this.subscribeToTelemetry, this); this.composition.off('add', this.subscribeToTelemetry, this);
this.composition.off('remove', this.unsubscribeFromTelemetry, this); this.composition.off('remove', this.unsubscribeFromTelemetry, this);
Object.values(this.subscriptions).forEach((unsubscribe) => unsubscribe()); Object.values(this.telemetryCollections).forEach((telemetryCollection) =>
delete this.subscriptions; telemetryCollection.destroy()
);
this.conditions.forEach((condition) => { this.conditions.forEach((condition) => {
condition.destroy(); condition.destroy();

View File

@ -160,8 +160,10 @@
</div> </div>
</template> </template>
<div class="c-cdef__separator c-row-separator"></div> <div class="c-cdef__separator c-row-separator"></div>
<div class="c-cdef__controls" :disabled="!telemetry.length"> <div class="c-cdef__controls">
<button <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" class="c-cdef__add-criteria-button c-button c-button--labeled icon-plus"
@click="addCriteria" @click="addCriteria"
> >

View File

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

View File

@ -720,31 +720,57 @@ 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', [ openmct.telemetry = jasmine.createSpyObj('telemetry', [
'subscribe',
'getMetadata', 'getMetadata',
'request', 'request',
'getValueFormatter', 'getValueFormatter',
'abortAllRequests' 'abortAllRequests',
'requestCollection'
]); ]);
openmct.telemetry.request.and.returnValue(Promise.resolve([]));
openmct.telemetry.getMetadata.and.returnValue({ openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry, ...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({ openmct.telemetry.getValueFormatter.and.returnValue({
parse: function (value) { parse: function (value) {
return value; return value;
} }
}); });
let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct); let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct);
conditionMgr.on('conditionSetResultUpdated', mockListener); conditionMgr.on('conditionSetResultUpdated', mockListener);
conditionMgr.telemetryObjects = { conditionMgr.telemetryObjects = {
'test-object': testTelemetryObject 'test-object': testTelemetryObject
}; };
conditionMgr.updateConditionTelemetryObjects(); conditionMgr.updateConditionTelemetryObjects();
setTimeout(() => { // 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({ expect(mockListener).toHaveBeenCalledWith({
output: 'Any old telemetry', output: 'Any old telemetry',
id: { id: {
@ -754,16 +780,9 @@ describe('the plugin', function () {
conditionId: '39584410-cbf9-499e-96dc-76f27e69885d', conditionId: '39584410-cbf9-499e-96dc-76f27e69885d',
utc: undefined utc: undefined
}); });
done();
}, 400);
}); });
it('should not evaluate as old when telemetry is received in the allotted time', (done) => { it('should not evaluate as old when telemetry is received in the allotted time', async () => {
openmct.telemetry.getMetadata = jasmine.createSpy('getMetadata');
openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry,
valueMetadatas: testTelemetryObject.telemetry.values
});
const testDatum = { const testDatum = {
'some-key2': '', 'some-key2': '',
utc: 1, utc: 1,
@ -771,8 +790,49 @@ describe('the plugin', function () {
'some-key': null, 'some-key': null,
id: 'test-object' 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.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; const date = 1;
conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input = conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input =
['0.4']; ['0.4'];
@ -782,8 +842,16 @@ describe('the plugin', function () {
'test-object': testTelemetryObject 'test-object': testTelemetryObject
}; };
conditionMgr.updateConditionTelemetryObjects(); conditionMgr.updateConditionTelemetryObjects();
conditionMgr.telemetryReceived(testTelemetryObject, testDatum);
setTimeout(() => { // 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({ expect(mockListener).toHaveBeenCalledWith({
output: 'Default', output: 'Default',
id: { id: {
@ -793,8 +861,6 @@ describe('the plugin', function () {
conditionId: '2532d90a-e0d6-4935-b546-3123522da2de', conditionId: '2532d90a-e0d6-4935-b546-3123522da2de',
utc: date utc: date
}); });
done();
}, 300);
}); });
}); });
@ -902,17 +968,25 @@ describe('the plugin', function () {
openmct.telemetry.getMetadata = jasmine.createSpy('getMetadata'); openmct.telemetry.getMetadata = jasmine.createSpy('getMetadata');
openmct.telemetry.getMetadata.and.returnValue({ openmct.telemetry.getMetadata.and.returnValue({
...testTelemetryObject.telemetry, ...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.on('conditionSetResultUpdated', mockListener);
conditionMgr.telemetryObjects = { conditionMgr.telemetryObjects = {
'test-object': testTelemetryObject 'test-object': testTelemetryObject
}; };
conditionMgr.updateConditionTelemetryObjects(); conditionMgr.updateConditionTelemetryObjects();
conditionMgr.telemetryReceived(testTelemetryObject, { conditionMgr.telemetryReceived(testTelemetryObject, [
{
'some-key': 2, 'some-key': 2,
utc: date utc: date
}); }
]);
let result = conditionMgr.conditions.map((condition) => condition.result); let result = conditionMgr.conditions.map((condition) => condition.result);
expect(result[2]).toBeUndefined(); expect(result[2]).toBeUndefined();
}); });
@ -1002,26 +1076,37 @@ describe('the plugin', function () {
} }
}; };
openmct.$injector = jasmine.createSpyObj('$injector', ['get']); openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
// const mockTransactionService = jasmine.createSpyObj(
// 'transactionService',
// ['commit']
// );
openmct.telemetry = jasmine.createSpyObj('telemetry', [ openmct.telemetry = jasmine.createSpyObj('telemetry', [
'isTelemetryObject', 'isTelemetryObject',
'request',
'subscribe', 'subscribe',
'getMetadata', 'getMetadata',
'getValueFormatter', 'getValueFormatter',
'request' 'requestCollection'
]); ]);
openmct.telemetry.isTelemetryObject.and.returnValue(true);
openmct.telemetry.subscribe.and.returnValue(function () {}); 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({ openmct.telemetry.getValueFormatter.and.returnValue({
parse: function (value) { parse: function (value) {
return value; return value;
} }
}); });
openmct.telemetry.getMetadata.and.returnValue(testTelemetryObject.telemetry); openmct.telemetry.requestCollection.and.returnValue({
openmct.telemetry.request.and.returnValue(Promise.resolve([])); load: jasmine.createSpy('load'),
on: jasmine.createSpy('on')
});
const styleRuleManger = new StyleRuleManager(stylesObject, openmct, null, true); const styleRuleManger = new StyleRuleManager(stylesObject, openmct, null, true);
spyOn(styleRuleManger, 'subscribeToConditionSet'); spyOn(styleRuleManger, 'subscribeToConditionSet');

View File

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

View File

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

View File

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

View File

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

View File

@ -42,24 +42,6 @@ export const FAULT_MANAGEMENT_TYPE = 'faultManagement';
export const FAULT_MANAGEMENT_INSPECTOR = 'faultManagementInspector'; export const FAULT_MANAGEMENT_INSPECTOR = 'faultManagementInspector';
export const FAULT_MANAGEMENT_ALARMS = 'alarms'; export const FAULT_MANAGEMENT_ALARMS = 'alarms';
export const FAULT_MANAGEMENT_GLOBAL_ALARMS = 'global-alarm-status'; 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_VIEW = 'faultManagement.view';
export const FAULT_MANAGEMENT_NAMESPACE = 'faults.taxonomy'; export const FAULT_MANAGEMENT_NAMESPACE = 'faults.taxonomy';
export const FILTER_ITEMS = ['Standard View', 'Acknowledged', 'Unacknowledged', 'Shelved']; 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 definition = this._getTypeDefinition(object.type);
const persistable = this.openmct.objects.isPersistable(object.identifier); const persistable = this.openmct.objects.isPersistable(object.identifier);
return persistable && definition && definition.creatable; return persistable && definition && definition.creatable && !object.locked;
} }
invoke(objectPath) { invoke(objectPath) {

View File

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

View File

@ -370,6 +370,7 @@ export default {
createImageWrapper(index, image, showImagePlaceholders) { createImageWrapper(index, image, showImagePlaceholders) {
const id = `${ID_PREFIX}${image.time}`; const id = `${ID_PREFIX}${image.time}`;
let imageWrapper = document.createElement('div'); let imageWrapper = document.createElement('div');
imageWrapper.ariaLabel = id;
imageWrapper.classList.add(IMAGE_WRAPPER_CLASS); imageWrapper.classList.add(IMAGE_WRAPPER_CLASS);
imageWrapper.style.left = `${this.xScale(image.time)}px`; imageWrapper.style.left = `${this.xScale(image.time)}px`;
this.setNSAttributesForElement(imageWrapper, { 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 imageryData from '@/plugins/imagery/mixins/imageryData.js';
import { VIEW_LARGE_ACTION_KEY } from '@/plugins/viewLargeAction/viewLargeAction.js'; import { VIEW_LARGE_ACTION_KEY } from '@/plugins/viewLargeAction/viewLargeAction.js';
import { encode_url } from '../../../utils/encoding';
import eventHelpers from '../lib/eventHelpers.js'; import eventHelpers from '../lib/eventHelpers.js';
import AnnotationsCanvas from './AnnotationsCanvas.vue'; import AnnotationsCanvas from './AnnotationsCanvas.vue';
import Compass from './Compass/CompassComponent.vue'; import Compass from './Compass/CompassComponent.vue';
@ -364,7 +365,7 @@ export default {
filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`, filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
backgroundImage: `${ backgroundImage: `${
this.imageUrl this.imageUrl
? `url(${this.imageUrl}), ? `url(${encode_url(this.imageUrl)}),
repeating-linear-gradient( repeating-linear-gradient(
45deg, 45deg,
transparent, transparent,
@ -620,7 +621,7 @@ export default {
if (matchIndex > -1) { if (matchIndex > -1) {
this.setFocusedImage(matchIndex); this.setFocusedImage(matchIndex);
} else { } else {
this.paused(); this.paused(false);
} }
} }
@ -789,7 +790,7 @@ export default {
}, },
getVisibleLayerStyles(layer) { getVisibleLayerStyles(layer) {
return { return {
backgroundImage: `url(${layer.source})`, backgroundImage: `url(${encode_url(layer.source)})`,
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX / 2}px, ${ transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX / 2}px, ${
this.imageTranslateY / 2 this.imageTranslateY / 2
}px)`, }px)`,
@ -1082,7 +1083,7 @@ export default {
paused(state) { paused(state) {
this.isPaused = Boolean(state); this.isPaused = Boolean(state);
if (!state) { if (!this.isPaused) {
this.previousFocusedImage = null; this.previousFocusedImage = null;
this.setFocusedImage(this.nextImageIndex); this.setFocusedImage(this.nextImageIndex);
this.autoScroll = true; this.autoScroll = true;

View File

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

View File

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

View File

@ -19,14 +19,23 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
const PERFORMANCE_OVERLAY_RENDER_INTERVAL = 1000;
export default function PerformanceIndicator() { export default function PerformanceIndicator() {
return function install(openmct) { return function install(openmct) {
let frames = 0; let frames = 0;
let lastCalculated = performance.now(); 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.text('~ fps');
indicator.description('Performance Indicator');
indicator.statusClass('s-status-info'); indicator.statusClass('s-status-info');
indicator.on('click', showOverlay);
openmct.indicators.add(indicator); openmct.indicators.add(indicator);
let rafHandle = requestAnimationFrame(incrementFrames); let rafHandle = requestAnimationFrame(incrementFrames);
@ -58,5 +67,58 @@ export default function PerformanceIndicator() {
indicator.statusClass('s-status-error'); 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 "Unable to create annotation_keystring_index"
echo $response echo $response
fi 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 # Main script execution

View File

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

View File

@ -1113,6 +1113,7 @@ export default {
} }
this.listenTo(window, 'mouseup', this.onMouseUp, this); 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); this.listenTo(window, 'mousemove', this.trackMousePosition, this);
// track frozen state on mouseDown to be read on mouseUp // track frozen state on mouseDown to be read on mouseUp
@ -1133,6 +1134,7 @@ export default {
onMouseUp(event) { onMouseUp(event) {
this.stopListening(window, 'mouseup', this.onMouseUp, this); 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); this.stopListening(window, 'mousemove', this.trackMousePosition, this);
if (this.isMouseClick() && event.shiftKey) { if (this.isMouseClick() && event.shiftKey) {

View File

@ -230,7 +230,9 @@ export default class PlotSeries extends Model {
const newPoints = _(data) const newPoints = _(data)
.concat(points) .concat(points)
.sortBy(this.getXVal) .sortBy(this.getXVal)
.uniq(true, (point) => [this.getXVal(point), this.getYVal(point)].join()) .sortedUniqBy((point) => {
return [this.getXVal(point), this.getYVal(point)].join();
})
.value(); .value();
this.reset(newPoints); this.reset(newPoints);
} catch (error) { } catch (error) {
@ -429,7 +431,7 @@ export default class PlotSeries extends Model {
let data = this.getSeriesData(); let data = this.getSeriesData();
let insertIndex = data.length; let insertIndex = data.length;
const currentYVal = this.getYVal(newData); 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)) { if (this.isValueInvalid(currentYVal) && this.isValueInvalid(lastYVal)) {
console.warn(`[Plot] Invalid Y Values detected: ${currentYVal} ${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); const pointsToRemove = startIndex + (data.length - endIndex + 1);
if (pointsToRemove > 0) { if (pointsToRemove > 0) {
if (pointsToRemove < 1000) { if (pointsToRemove < 1000) {
// Remove all points up to the start index
data.slice(0, startIndex).forEach(this.remove, this); 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.updateSeriesData(data);
this.resetStats(); this.resetStats();
} else { } else {

View File

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

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 * as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved. * 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 * as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved. * Administration. All rights reserved.
* *

View File

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

View File

@ -20,16 +20,43 @@
* at runtime from the About dialog for additional information. * 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) { function remoteClockRequestInterceptor(openmct, _remoteClockIdentifier, waitForBounds) {
let remoteClockLoaded = false; let remoteClockLoaded = false;
return { 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 // Get the activeClock from the Global Time Context
/** @type {import("../../api/time/TimeContext").default} */
const { activeClock } = openmct.time; 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; 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) => { invoke: async (request) => {
const timeContext = request?.timeContext ?? openmct.time; const timeContext = request?.timeContext ?? openmct.time;

View File

@ -38,11 +38,11 @@
v-for="(tab, index) in tabsList" v-for="(tab, index) in tabsList"
:ref="tab.keyString" :ref="tab.keyString"
:key="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" class="c-tab c-tabs-view__tab js-tab"
role="tab" role="tab"
:class="{ :class="{
'is-current': isCurrent(tab) 'is-current': tab.keyString === currentTab.keyString
}" }"
@click="showTab(tab, index)" @click="showTab(tab, index)"
@mouseover.ctrl="showToolTip(tab)" @mouseover.ctrl="showToolTip(tab)"
@ -74,7 +74,7 @@
:key="tab.keyString" :key="tab.keyString"
:style="getTabStyles(tab)" :style="getTabStyles(tab)"
class="c-tabs-view__object-holder" 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 <ObjectView
v-if="shouldLoadTab(tab)" v-if="shouldLoadTab(tab)"
@ -353,7 +353,10 @@ export default {
this.internalDomainObject = domainObject; this.internalDomainObject = domainObject;
}, },
persistCurrentTabIndex(index) { persistCurrentTabIndex(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); this.openmct.objects.mutate(this.internalDomainObject, 'currentTabIndex', index);
}
}, },
storeCurrentTabIndexInURL(index) { storeCurrentTabIndexInURL(index) {
let currentTabIndexInURL = this.openmct.router.getSearchParam(this.searchTabKey); let currentTabIndexInURL = this.openmct.router.getSearchParam(this.searchTabKey);

View File

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

View File

@ -23,7 +23,11 @@
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import _ from 'lodash'; import _ from 'lodash';
import { ORDER } from './constants';
export default class TelemetryTableConfiguration extends EventEmitter { export default class TelemetryTableConfiguration extends EventEmitter {
#sortOptions;
constructor(domainObject, openmct, options) { constructor(domainObject, openmct, options) {
super(); super();
@ -44,6 +48,26 @@ export default class TelemetryTableConfiguration extends EventEmitter {
this.notPersistable = !this.openmct.objects.isPersistable(this.domainObject.identifier); 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() { getConfiguration() {
let configuration = this.domainObject.configuration || {}; let configuration = this.domainObject.configuration || {};
configuration.hiddenColumns = configuration.hiddenColumns || {}; configuration.hiddenColumns = configuration.hiddenColumns || {};
@ -118,7 +142,7 @@ export default class TelemetryTableConfiguration extends EventEmitter {
getAllHeaders() { getAllHeaders() {
let flattenedColumns = _.flatten(Object.values(this.columns)); let flattenedColumns = _.flatten(Object.values(this.columns));
/* eslint-disable you-dont-need-lodash-underscore/uniq */ /* 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, fromColumnsToHeadersMap,
{} {}
); );

View File

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

View File

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

View File

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

View File

@ -461,7 +461,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o
$colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4); $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4);
$colorGaugeLimitLow: $colorGaugeLimitHigh; $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 $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
$gaugeMeterValueShadow: rgba(255, 255, 255, 0); $gaugeMeterValueShadow: rgba(255, 255, 255, 0);

View File

@ -477,7 +477,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o
$colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4); $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4);
$colorGaugeLimitLow: $colorGaugeLimitHigh; $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 $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
$gaugeMeterValueShadow: rgba(255, 255, 255, 0); $gaugeMeterValueShadow: rgba(255, 255, 255, 0);

View File

@ -460,7 +460,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o
$colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeRange: $colorBodyFg; // Range text color
$colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.2); $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.2);
$colorGaugeLimitLow: $colorGaugeLimitHigh; $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 $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions
$marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges
$gaugeMeterValueShadow: rgba(255, 255, 255, 0); $gaugeMeterValueShadow: rgba(255, 255, 255, 0);

View File

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

View File

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

View File

@ -33,7 +33,7 @@
<h1 class="l-title s-title">Open MCT</h1> <h1 class="l-title s-title">Open MCT</h1>
<div class="l-description s-description"> <div class="l-description s-description">
<p> <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. Administrator of the National Aeronautics and Space Administration. All rights reserved.
</p> </p>
<p> <p>

View File

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

View File

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