mirror of
https://github.com/nasa/openmct.git
synced 2025-07-03 21:38:13 +00:00
Compare commits
36 Commits
subscripti
...
watch-plot
Author | SHA1 | Date | |
---|---|---|---|
d053e65a39 | |||
bc36a93b9b | |||
847232d64b | |||
4fbccd4c91 | |||
cd560bceed | |||
e08633214e | |||
a9ad0bf38a | |||
5f8d6899d2 | |||
cd6adbadde | |||
539b43325a | |||
eeb8e9704b | |||
0aceb4b590 | |||
82fa4c1597 | |||
ee5081f807 | |||
3cbaa7bf07 | |||
18e4b9da65 | |||
d42aa545bb | |||
69b81c00ca | |||
068ac4899d | |||
f8d936a834 | |||
5c21c34568 | |||
0eea2e0bbc | |||
61acc91200 | |||
a52982d2bf | |||
1d40b134b6 | |||
735c8236e5 | |||
dc5a3236b3 | |||
60e1eeba8e | |||
1fc6056c51 | |||
b9df97e2bc | |||
b985619d16 | |||
3e31bbef97 | |||
3e5ada8f5f | |||
2b2c74da9c | |||
450cab428f | |||
0340fe18fa |
@ -5,20 +5,20 @@ executors:
|
||||
- image: mcr.microsoft.com/playwright:v1.39.0-focal
|
||||
environment:
|
||||
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
|
||||
PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps
|
||||
PERCY_LOGLEVEL: 'debug' # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742)
|
||||
PERCY_POSTINSTALL_BROWSER: "true" # Needed to store the percy browser in cache deps
|
||||
PERCY_LOGLEVEL: "debug" # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742)
|
||||
ubuntu:
|
||||
machine:
|
||||
image: ubuntu-2204:current
|
||||
docker_layer_caching: true
|
||||
parameters:
|
||||
BUST_CACHE:
|
||||
description: 'Set this with the CircleCI UI Trigger Workflow button (boolean = true) to bust the cache!'
|
||||
description: "Set this with the CircleCI UI Trigger Workflow button (boolean = true) to bust the cache!"
|
||||
default: false
|
||||
type: boolean
|
||||
commands:
|
||||
build_and_install:
|
||||
description: 'All steps used to build and install. Will use cache if found'
|
||||
description: "All steps used to build and install. Will use cache if found"
|
||||
parameters:
|
||||
node-version:
|
||||
type: string
|
||||
@ -30,7 +30,7 @@ commands:
|
||||
node-version: << parameters.node-version >>
|
||||
- run: npm install --no-audit --progress=false
|
||||
restore_cache_cmd:
|
||||
description: 'Custom command for restoring cache with the ability to bust cache. When BUST_CACHE is set to true, jobs will not restore cache'
|
||||
description: "Custom command for restoring cache with the ability to bust cache. When BUST_CACHE is set to true, jobs will not restore cache"
|
||||
parameters:
|
||||
node-version:
|
||||
type: string
|
||||
@ -42,7 +42,7 @@ commands:
|
||||
- restore_cache:
|
||||
key: deps--{{ arch }}--{{ .Branch }}--<< parameters.node-version >>--{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }}
|
||||
save_cache_cmd:
|
||||
description: 'Custom command for saving cache.'
|
||||
description: "Custom command for saving cache."
|
||||
parameters:
|
||||
node-version:
|
||||
type: string
|
||||
@ -53,7 +53,7 @@ commands:
|
||||
- ~/.npm
|
||||
- node_modules
|
||||
generate_and_store_version_and_filesystem_artifacts:
|
||||
description: 'Track important packages and files'
|
||||
description: "Track important packages and files"
|
||||
steps:
|
||||
- run: |
|
||||
[[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts)
|
||||
@ -64,7 +64,7 @@ commands:
|
||||
- store_artifacts:
|
||||
path: /tmp/artifacts/
|
||||
generate_e2e_code_cov_report:
|
||||
description: 'Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test'
|
||||
description: "Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test"
|
||||
parameters:
|
||||
suite:
|
||||
type: string
|
||||
@ -105,7 +105,11 @@ jobs:
|
||||
node-version: <<parameters.node-version>>
|
||||
- browser-tools/install-chrome:
|
||||
replace-existing: false
|
||||
- run: npm run test
|
||||
- run:
|
||||
command: |
|
||||
mkdir -p dist/reports/tests/
|
||||
TESTFILES=$(circleci tests glob "src/**/*Spec.js")
|
||||
echo "$TESTFILES" | circleci tests run --command="xargs npm run test" --verbose
|
||||
- run: npm run cov:unit:publish
|
||||
- save_cache_cmd:
|
||||
node-version: <<parameters.node-version>>
|
||||
@ -123,16 +127,20 @@ jobs:
|
||||
suite: #stable or full
|
||||
type: string
|
||||
executor: pw-focal-development
|
||||
parallelism: 6
|
||||
parallelism: 7
|
||||
steps:
|
||||
- build_and_install:
|
||||
node-version: lts/hydrogen
|
||||
- when: #Only install chrome-beta when running the 'full' suite to save $$$
|
||||
condition:
|
||||
equal: ['full', <<parameters.suite>>]
|
||||
equal: ["full", <<parameters.suite>>]
|
||||
steps:
|
||||
- run: npx playwright install chrome-beta
|
||||
- run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; npm run test:e2e:<<parameters.suite>> -- --shard=${SHARD}/${CIRCLE_NODE_TOTAL}
|
||||
- run:
|
||||
command: |
|
||||
mkdir test-results
|
||||
TESTFILES=$(circleci tests glob "e2e/**/*.spec.js")
|
||||
echo "$TESTFILES" | circleci tests run --command="xargs npm run test:e2e:<<parameters.suite>>" --verbose --split-by=timings
|
||||
- when:
|
||||
condition:
|
||||
equal: [42, 42] # Always run codecov reports regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2
|
||||
@ -239,6 +247,7 @@ jobs:
|
||||
equal: [42, 42] # Always generate version artifacts regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2
|
||||
steps:
|
||||
- generate_and_store_version_and_filesystem_artifacts
|
||||
|
||||
workflows:
|
||||
overall-circleci-commit-status: #These jobs run on every commit
|
||||
jobs:
|
||||
@ -251,8 +260,6 @@ workflows:
|
||||
- e2e-test:
|
||||
name: e2e-stable
|
||||
suite: stable
|
||||
- mem-test
|
||||
- perf-test
|
||||
- visual-a11y-tests:
|
||||
name: visual-test-ci
|
||||
suite: ci
|
||||
@ -278,7 +285,7 @@ workflows:
|
||||
- e2e-couchdb
|
||||
triggers:
|
||||
- schedule:
|
||||
cron: '0 0 * * *'
|
||||
cron: "0 0 * * *"
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
|
@ -492,9 +492,14 @@
|
||||
"gcov",
|
||||
"WCAG",
|
||||
"stackedplot",
|
||||
"Andale"
|
||||
"Andale",
|
||||
"unnormalized",
|
||||
"checksnapshots",
|
||||
"specced",
|
||||
"composables",
|
||||
"countup"
|
||||
],
|
||||
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US"],
|
||||
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
|
||||
"ignorePaths": [
|
||||
"package.json",
|
||||
"dist/**",
|
||||
|
61
.github/workflows/e2e-flakefinder.yml
vendored
Normal file
61
.github/workflows/e2e-flakefinder.yml
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
name: 'pr:e2e:flakefinder'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: master
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- opened
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
jobs:
|
||||
e2e-flakefinder:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'pr:e2e:flakefinder') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/hydrogen'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- run: npx playwright@1.39.0 install
|
||||
- run: npm install --cache ~/.npm --no-audit --progress=false
|
||||
|
||||
- name: Run E2E Tests (Repeated 10 Times)
|
||||
run: npm run test:e2e:stable -- --retries=0 --repeat-each=10 --max-failures=50
|
||||
|
||||
- name: Archive test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: test-results
|
||||
|
||||
- name: Remove pr:e2e:flakefinder label (if present)
|
||||
if: always()
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const labelToRemove = 'pr:e2e:flakefinder';
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
name: labelToRemove
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
|
||||
}
|
58
.github/workflows/e2e-perf.yml
vendored
Normal file
58
.github/workflows/e2e-perf.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
name: 'e2e-perf'
|
||||
on:
|
||||
push:
|
||||
branches: master
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- opened
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
jobs:
|
||||
e2e-full:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'pr:e2e:perf') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/hydrogen'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- run: npx playwright@1.39.0 install
|
||||
- run: npm install --cache ~/.npm --no-audit --progress=false
|
||||
- run: npm run test:perf:localhost
|
||||
- run: npm run test:perf:contract
|
||||
- run: npm run test:perf:memory
|
||||
- name: Archive test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: test-results
|
||||
|
||||
- name: Remove pr:e2e:perf label (if present)
|
||||
if: always()
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const labelToRemove = 'pr:e2e:perf';
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
name: labelToRemove
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
|
||||
}
|
14
.vscode/extensions.json
vendored
Normal file
14
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"Vue.vscode-typescript-vue-plugin",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"rvest.vs-code-prettier-eslint"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": ["octref.vetur"]
|
||||
}
|
@ -109,7 +109,7 @@ For those interested in the mechanics of snapshot testing with Playwright, you c
|
||||
// from our package.json or circleCI configuration file
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v{X.X.X}-focal /bin/bash
|
||||
npm install
|
||||
npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot
|
||||
npm run test:e2e:checksnapshots
|
||||
```
|
||||
|
||||
### Updating Snapshots
|
||||
@ -134,6 +134,12 @@ npm install
|
||||
npm run test:e2e:updatesnapshots
|
||||
```
|
||||
|
||||
Once that's done, you'll need to run the following to verify that the changes do not cause more problems:
|
||||
|
||||
```sh
|
||||
npm run test:e2e:checksnapshots
|
||||
```
|
||||
|
||||
## Automated Accessibility (a11y) Testing
|
||||
|
||||
Open MCT incorporates accessibility testing through two primary methods to ensure its compliance with accessibility standards:
|
||||
|
@ -61,7 +61,6 @@ export async function scanForA11yViolations(page, testCaseName, options = {}) {
|
||||
const builder = new AxeBuilder({ page });
|
||||
builder.withTags(['wcag2aa']);
|
||||
// https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md
|
||||
builder.disableRules(['color-contrast']);
|
||||
const accessibilityScanResults = await builder.analyze();
|
||||
|
||||
// Assert that no violations should be present
|
||||
|
@ -6,7 +6,8 @@
|
||||
"end": 1660343797000,
|
||||
"type": "Group 1",
|
||||
"color": "orange",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 1
|
||||
},
|
||||
{
|
||||
"name": "Past event 2",
|
||||
@ -14,7 +15,8 @@
|
||||
"end": 1660429160000,
|
||||
"type": "Group 1",
|
||||
"color": "orange",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 2
|
||||
},
|
||||
{
|
||||
"name": "Past event 3",
|
||||
@ -22,7 +24,8 @@
|
||||
"end": 1660503981000,
|
||||
"type": "Group 1",
|
||||
"color": "orange",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 3
|
||||
},
|
||||
{
|
||||
"name": "Past event 4",
|
||||
@ -30,7 +33,8 @@
|
||||
"end": 1660624108000,
|
||||
"type": "Group 1",
|
||||
"color": "orange",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 4
|
||||
},
|
||||
{
|
||||
"name": "Past event 5",
|
||||
@ -38,7 +42,8 @@
|
||||
"end": 1660681529000,
|
||||
"type": "Group 1",
|
||||
"color": "orange",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -6,7 +6,8 @@
|
||||
"end": 1660343797000,
|
||||
"type": "Group 1",
|
||||
"color": "orange",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 1
|
||||
},
|
||||
{
|
||||
"name": "Time until supper",
|
||||
@ -14,7 +15,8 @@
|
||||
"end": 1650420410000,
|
||||
"type": "Group 2",
|
||||
"color": "blue",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 2
|
||||
}
|
||||
],
|
||||
"Group 2": [
|
||||
@ -24,7 +26,8 @@
|
||||
"end": 1650320102001,
|
||||
"type": "Group 2",
|
||||
"color": "green",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 3
|
||||
},
|
||||
{
|
||||
"name": "Time since last accident",
|
||||
@ -32,7 +35,8 @@
|
||||
"end": 1650320102002,
|
||||
"type": "Group 1",
|
||||
"color": "yellow",
|
||||
"textColor": "white"
|
||||
"textColor": "white",
|
||||
"id": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
127
e2e/tests/functional/missionStatus.e2e.spec.js
Normal file
127
e2e/tests/functional/missionStatus.e2e.spec.js
Normal file
@ -0,0 +1,127 @@
|
||||
/*****************************************************************************
|
||||
* 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 persistability checks
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { expect, test } from '../../baseFixtures.js';
|
||||
|
||||
test.describe('Mission Status @addInit', () => {
|
||||
const NO_GO = '0';
|
||||
const GO = '1';
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// FIXME: determine if plugins will be added to index.html or need to be injected
|
||||
await page.addInitScript({
|
||||
path: fileURLToPath(new URL('../../helper/addInitExampleUser.js', import.meta.url))
|
||||
});
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByText('Select Role')).toBeVisible();
|
||||
// Description should be empty https://github.com/nasa/openmct/issues/6978
|
||||
await expect(page.getByLabel('Dialog message')).toBeHidden();
|
||||
// set role
|
||||
await page.getByRole('button', { name: 'Select', exact: true }).click();
|
||||
// dismiss role confirmation popup
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
});
|
||||
|
||||
test('Basic functionality', async ({ page }) => {
|
||||
const imageryStatusSelect = page.getByRole('combobox', { name: 'Imagery' });
|
||||
const commandingStatusSelect = page.getByRole('combobox', { name: 'Commanding' });
|
||||
const drivingStatusSelect = page.getByRole('combobox', { name: 'Driving' });
|
||||
const missionStatusPanel = page.getByRole('dialog', { name: 'User Control Panel' });
|
||||
|
||||
await test.step('Mission status panel shows/hides when toggled', async () => {
|
||||
// Ensure that clicking the button toggles the dialog
|
||||
await page.getByLabel('Toggle Mission Status Panel').click();
|
||||
await expect(missionStatusPanel).toBeVisible();
|
||||
await page.getByLabel('Toggle Mission Status Panel').click();
|
||||
await expect(missionStatusPanel).toBeHidden();
|
||||
await page.getByLabel('Toggle Mission Status Panel').click();
|
||||
await expect(missionStatusPanel).toBeVisible();
|
||||
|
||||
// Ensure that clicking the close button closes the dialog
|
||||
await page.getByLabel('Close Mission Status Panel').click();
|
||||
await expect(missionStatusPanel).toBeHidden();
|
||||
await page.getByLabel('Toggle Mission Status Panel').click();
|
||||
await expect(missionStatusPanel).toBeVisible();
|
||||
|
||||
// Ensure clicking off the dialog also closes it
|
||||
await page.getByLabel('My Items Grid View').click();
|
||||
await expect(missionStatusPanel).toBeHidden();
|
||||
await page.getByLabel('Toggle Mission Status Panel').click();
|
||||
await expect(missionStatusPanel).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Mission action statuses have correct defaults and can be set', async () => {
|
||||
await expect(imageryStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(commandingStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(drivingStatusSelect).toHaveValue(NO_GO);
|
||||
|
||||
await setMissionStatus(page, 'Imagery', GO);
|
||||
await expect(imageryStatusSelect).toHaveValue(GO);
|
||||
await expect(commandingStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(drivingStatusSelect).toHaveValue(NO_GO);
|
||||
|
||||
await setMissionStatus(page, 'Commanding', GO);
|
||||
await expect(imageryStatusSelect).toHaveValue(GO);
|
||||
await expect(commandingStatusSelect).toHaveValue(GO);
|
||||
await expect(drivingStatusSelect).toHaveValue(NO_GO);
|
||||
|
||||
await setMissionStatus(page, 'Driving', GO);
|
||||
await expect(imageryStatusSelect).toHaveValue(GO);
|
||||
await expect(commandingStatusSelect).toHaveValue(GO);
|
||||
await expect(drivingStatusSelect).toHaveValue(GO);
|
||||
|
||||
await setMissionStatus(page, 'Imagery', NO_GO);
|
||||
await expect(imageryStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(commandingStatusSelect).toHaveValue(GO);
|
||||
await expect(drivingStatusSelect).toHaveValue(GO);
|
||||
|
||||
await setMissionStatus(page, 'Commanding', NO_GO);
|
||||
await expect(imageryStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(commandingStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(drivingStatusSelect).toHaveValue(GO);
|
||||
|
||||
await setMissionStatus(page, 'Driving', NO_GO);
|
||||
await expect(imageryStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(commandingStatusSelect).toHaveValue(NO_GO);
|
||||
await expect(drivingStatusSelect).toHaveValue(NO_GO);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {'Commanding'|'Imagery'|'Driving'} action
|
||||
* @param {'0'|'1'} status
|
||||
*/
|
||||
async function setMissionStatus(page, action, status) {
|
||||
await page.getByRole('combobox', { name: action }).selectOption(status);
|
||||
await expect(
|
||||
page.getByRole('alert').filter({ hasText: 'Successfully set mission status' })
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Dismiss').click();
|
||||
}
|
@ -27,7 +27,7 @@ import {
|
||||
assertPlanActivities,
|
||||
assertPlanOrderedSwimLanes
|
||||
} from '../../../helper/planningUtils.js';
|
||||
import { test } from '../../../pluginFixtures.js';
|
||||
import { expect, test } from '../../../pluginFixtures.js';
|
||||
|
||||
const testPlan1 = JSON.parse(
|
||||
fs.readFileSync(
|
||||
@ -63,4 +63,47 @@ test.describe('Plan', () => {
|
||||
});
|
||||
await assertPlanOrderedSwimLanes(page, testPlanWithOrderedLanes, planWithSwimLanes.url);
|
||||
});
|
||||
|
||||
test('Allows setting the state of an activity when selected.', async ({ page }) => {
|
||||
const groups = Object.keys(testPlan1);
|
||||
const firstGroupKey = groups[0];
|
||||
const firstGroupItems = testPlan1[firstGroupKey];
|
||||
const firstActivity = firstGroupItems[0];
|
||||
const lastActivity = firstGroupItems[firstGroupItems.length - 1];
|
||||
const startBound = firstActivity.start;
|
||||
// Set the endBound to the end time of the current activity
|
||||
let endBound = lastActivity.end;
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (endBound === startBound) {
|
||||
// Prevent oddities with setting start and end bound equal
|
||||
// via URL params
|
||||
endBound += 1;
|
||||
}
|
||||
|
||||
// Switch to fixed time mode with all plan events within the bounds
|
||||
await page.goto(
|
||||
`${plan.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=plan.view`
|
||||
);
|
||||
|
||||
// select the first activity in the list
|
||||
await page.getByText('Past event 1').click();
|
||||
|
||||
// Find the activity state section in the inspector
|
||||
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||
|
||||
// Check that activity state dropdown selection shows the `set status` option by default
|
||||
await expect(page.getByLabel('Activity Status').locator("[aria-selected='true']")).toHaveText(
|
||||
'Not started'
|
||||
);
|
||||
|
||||
// Change the selection of the activity status
|
||||
await page.getByRole('combobox').selectOption({ label: 'Aborted' });
|
||||
// select a different activity and back to the previous one
|
||||
await page.getByText('Past event 2').click();
|
||||
await page.getByText('Past event 1').click();
|
||||
// Check that activity state dropdown selection shows the previously selected option by default
|
||||
await expect(page.getByLabel('Activity Status').locator("[aria-selected='true']")).toHaveText(
|
||||
'Aborted'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -30,6 +30,11 @@ const examplePlanSmall3 = JSON.parse(
|
||||
new URL('../../../test-data/examplePlans/ExamplePlan_Small3.json', import.meta.url)
|
||||
)
|
||||
);
|
||||
const examplePlanSmall1 = JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url)
|
||||
)
|
||||
);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const START_TIME_COLUMN = 0;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@ -38,55 +43,10 @@ const TIME_TO_FROM_COLUMN = 2;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const ACTIVITY_COLUMN = 3;
|
||||
const HEADER_ROW = 0;
|
||||
const NUM_COLUMNS = 4;
|
||||
|
||||
const testPlan = {
|
||||
TEST_GROUP: [
|
||||
{
|
||||
name: 'Past event 1',
|
||||
start: 1660320408000,
|
||||
end: 1660343797000,
|
||||
type: 'TEST-GROUP',
|
||||
color: 'orange',
|
||||
textColor: 'white'
|
||||
},
|
||||
{
|
||||
name: 'Past event 2',
|
||||
start: 1660406808000,
|
||||
end: 1660429160000,
|
||||
type: 'TEST-GROUP',
|
||||
color: 'orange',
|
||||
textColor: 'white'
|
||||
},
|
||||
{
|
||||
name: 'Past event 3',
|
||||
start: 1660493208000,
|
||||
end: 1660503981000,
|
||||
type: 'TEST-GROUP',
|
||||
color: 'orange',
|
||||
textColor: 'white'
|
||||
},
|
||||
{
|
||||
name: 'Past event 4',
|
||||
start: 1660579608000,
|
||||
end: 1660624108000,
|
||||
type: 'TEST-GROUP',
|
||||
color: 'orange',
|
||||
textColor: 'white'
|
||||
},
|
||||
{
|
||||
name: 'Past event 5',
|
||||
start: 1660666008000,
|
||||
end: 1660681529000,
|
||||
type: 'TEST-GROUP',
|
||||
color: 'orange',
|
||||
textColor: 'white'
|
||||
}
|
||||
]
|
||||
};
|
||||
const NUM_COLUMNS = 5;
|
||||
|
||||
test.describe('Time List', () => {
|
||||
test('Create a Time List, add a single Plan to it and verify all the activities are displayed with no milliseconds', async ({
|
||||
test("Create a Time List, add a single Plan to it, verify all the activities are displayed with no milliseconds and selecting an activity shows it's properties", async ({
|
||||
page
|
||||
}) => {
|
||||
// Goto baseURL
|
||||
@ -103,12 +63,16 @@ test.describe('Time List', () => {
|
||||
await test.step('Create a Plan and add it to the timelist', async () => {
|
||||
await createPlanFromJSON(page, {
|
||||
name: 'Test Plan',
|
||||
json: testPlan,
|
||||
json: examplePlanSmall1,
|
||||
parent: timelist.uuid
|
||||
});
|
||||
|
||||
const startBound = testPlan.TEST_GROUP[0].start;
|
||||
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end;
|
||||
const groups = Object.keys(examplePlanSmall1);
|
||||
const firstGroupKey = groups[0];
|
||||
const firstGroupItems = examplePlanSmall1[firstGroupKey];
|
||||
const firstActivity = firstGroupItems[0];
|
||||
const lastActivity = firstGroupItems[firstGroupItems.length - 1];
|
||||
const startBound = firstActivity.start;
|
||||
const endBound = lastActivity.end;
|
||||
|
||||
// Switch to fixed time mode with all plan events within the bounds
|
||||
await page.goto(
|
||||
@ -118,7 +82,7 @@ test.describe('Time List', () => {
|
||||
// Verify all events are displayed
|
||||
const eventCount = await page.getByRole('row').count();
|
||||
// subtracting one for the header
|
||||
await expect(eventCount - 1).toEqual(testPlan.TEST_GROUP.length);
|
||||
await expect(eventCount - 1).toEqual(firstGroupItems.length);
|
||||
});
|
||||
|
||||
await test.step('Does not show milliseconds in times', async () => {
|
||||
@ -131,6 +95,81 @@ test.describe('Time List', () => {
|
||||
await expect(row.locator('.--end')).not.toContainText('.');
|
||||
await expect(row.locator('.--duration')).not.toContainText('.');
|
||||
});
|
||||
|
||||
await test.step('Shows activity properties when a row is selected', async () => {
|
||||
await page.getByRole('row').nth(2).click();
|
||||
|
||||
// Find the activity state section in the inspector
|
||||
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||
// Check that activity state label is displayed in the inspector.
|
||||
await expect(page.getByLabel('Activity Status').locator("[aria-selected='true']")).toHaveText(
|
||||
'Not started'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("View a timelist in expanded view, verify all the activities are displayed and selecting an activity shows it's properties", async ({
|
||||
page
|
||||
}) => {
|
||||
// Goto baseURL
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const timelist = await test.step('Create a Time List', async () => {
|
||||
const createdTimeList = await createDomainObjectWithDefaults(page, { type: 'Time List' });
|
||||
const objectName = await page.locator('.l-browse-bar__object-name').innerText();
|
||||
expect(objectName).toBe(createdTimeList.name);
|
||||
|
||||
return createdTimeList;
|
||||
});
|
||||
|
||||
await test.step('Create a Plan and add it to the timelist', async () => {
|
||||
await createPlanFromJSON(page, {
|
||||
name: 'Test Plan',
|
||||
json: examplePlanSmall1,
|
||||
parent: timelist.uuid
|
||||
});
|
||||
|
||||
// Ensure that all activities are shown in the expanded view
|
||||
const groups = Object.keys(examplePlanSmall1);
|
||||
const firstGroupKey = groups[0];
|
||||
const firstGroupItems = examplePlanSmall1[firstGroupKey];
|
||||
const firstActivity = firstGroupItems[0];
|
||||
const lastActivity = firstGroupItems[firstGroupItems.length - 1];
|
||||
const startBound = firstActivity.start;
|
||||
const endBound = lastActivity.end;
|
||||
|
||||
// Switch to fixed time mode with all plan events within the bounds
|
||||
await page.goto(
|
||||
`${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view`
|
||||
);
|
||||
|
||||
// Change the object to edit mode
|
||||
await page.getByRole('button', { name: 'Edit Object' }).click();
|
||||
|
||||
// Find the display properties section in the inspector
|
||||
await page.getByRole('tab', { name: 'View Properties' }).click();
|
||||
// Switch to expanded view and save the setting
|
||||
await page.getByLabel('Display Style').selectOption({ label: 'Expanded' });
|
||||
|
||||
// Click on the "Save" button
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
|
||||
// Verify all events are displayed
|
||||
const eventCount = await page.getByRole('row').count();
|
||||
await expect(eventCount).toEqual(firstGroupItems.length);
|
||||
});
|
||||
|
||||
await test.step('Shows activity properties when a row is selected', async () => {
|
||||
await page.getByRole('row').nth(2).click();
|
||||
|
||||
// Find the activity state section in the inspector
|
||||
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||
// Check that activity state label is displayed in the inspector.
|
||||
await expect(page.getByLabel('Activity Status').locator("[aria-selected='true']")).toHaveText(
|
||||
'Not started'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -147,8 +186,8 @@ test.describe('Time List', () => {
|
||||
const COUNTDOWN_REGEXP = /(-)?(\d+D\s)?(\d{2}):(\d{2}):(\d{2})/;
|
||||
|
||||
/**
|
||||
* @typedef {Object} CountdownObject
|
||||
* @property {string} sign - The sign of the countdown ('-' if the countdown is negative, otherwise undefined).
|
||||
* @typedef {Object} CountdownOrUpObject
|
||||
* @property {string} sign - The sign of the countdown ('-' if the countdown is negative, '+' otherwise).
|
||||
* @property {string} days - The number of days in the countdown (undefined if there are no days).
|
||||
* @property {string} hours - The number of hours in the countdown.
|
||||
* @property {string} minutes - The number of minutes in the countdown.
|
||||
@ -220,11 +259,13 @@ test.describe('Time List with controlled clock', () => {
|
||||
await test.step(`Countdown cell ${i + 1} counts down`, async () => {
|
||||
const countdownCell = countdownCells[i];
|
||||
// Get the initial countdown timestamp object
|
||||
const beforeCountdown = await getAndAssertCountdownObject(page, i + 3);
|
||||
const beforeCountdown = await getAndAssertCountdownOrUpObject(page, i + 3);
|
||||
// should not have a '-' sign
|
||||
await expect(countdownCell).not.toHaveText('-');
|
||||
// Wait until it changes
|
||||
await expect(countdownCell).not.toHaveText(beforeCountdown.toString());
|
||||
// Get the new countdown timestamp object
|
||||
const afterCountdown = await getAndAssertCountdownObject(page, i + 3);
|
||||
const afterCountdown = await getAndAssertCountdownOrUpObject(page, i + 3);
|
||||
// Verify that the new countdown timestamp object is less than the old one
|
||||
expect(Number(afterCountdown.seconds)).toBeLessThan(Number(beforeCountdown.seconds));
|
||||
});
|
||||
@ -233,15 +274,17 @@ test.describe('Time List with controlled clock', () => {
|
||||
// Verify that the count-up cells are counting up
|
||||
for (let i = 0; i < countUpCells.length; i++) {
|
||||
await test.step(`Count-up cell ${i + 1} counts up`, async () => {
|
||||
const countdownCell = countUpCells[i];
|
||||
const countUpCell = countUpCells[i];
|
||||
// Get the initial count-up timestamp object
|
||||
const beforeCountdown = await getAndAssertCountdownObject(page, i + 1);
|
||||
const beforeCountUp = await getAndAssertCountdownOrUpObject(page, i + 1);
|
||||
// should not have a '+' sign
|
||||
await expect(countUpCell).not.toHaveText('+');
|
||||
// Wait until it changes
|
||||
await expect(countdownCell).not.toHaveText(beforeCountdown.toString());
|
||||
await expect(countUpCell).not.toHaveText(beforeCountUp.toString());
|
||||
// Get the new count-up timestamp object
|
||||
const afterCountdown = await getAndAssertCountdownObject(page, i + 1);
|
||||
const afterCountUp = await getAndAssertCountdownOrUpObject(page, i + 1);
|
||||
// Verify that the new count-up timestamp object is greater than the old one
|
||||
expect(Number(afterCountdown.seconds)).toBeGreaterThan(Number(beforeCountdown.seconds));
|
||||
expect(Number(afterCountUp.seconds)).toBeGreaterThan(Number(beforeCountUp.seconds));
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -271,13 +314,13 @@ async function getCellTextByIndex(page, rowIndex, columnIndex) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text from the countdown cell in the given row, assert that it matches the countdown
|
||||
* Get the text from the countdown (or countup) cell in the given row, assert that it matches the countdown/countup
|
||||
* regex, and return an object representing the countdown.
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {number} rowIndex the row index
|
||||
* @returns {Promise<CountdownObject>} countdownObject
|
||||
* @returns {Promise<CountdownOrUpObject>} The countdown (or countup) object
|
||||
*/
|
||||
async function getAndAssertCountdownObject(page, rowIndex) {
|
||||
async function getAndAssertCountdownOrUpObject(page, rowIndex) {
|
||||
const timeToFrom = await getCellTextByIndex(page, HEADER_ROW + rowIndex, TIME_TO_FROM_COLUMN);
|
||||
|
||||
expect(timeToFrom).toMatch(COUNTDOWN_REGEXP);
|
||||
|
@ -35,7 +35,7 @@ import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
let conditionSetUrl;
|
||||
|
||||
test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
|
||||
test.describe.serial('Condition Set CRUD Operations on @localStorage @2p', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
//TODO: This needs to be refactored
|
||||
const context = await browser.newContext();
|
||||
@ -68,30 +68,35 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
|
||||
});
|
||||
|
||||
//Begin suite of tests again localStorage
|
||||
test('Condition set object properties persist in main view and inspector @localStorage', async ({
|
||||
page
|
||||
}) => {
|
||||
//Navigate to baseURL with injected localStorage
|
||||
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' });
|
||||
test.fixme(
|
||||
'Condition set object properties persist in main view and inspector @localStorage',
|
||||
async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7421'
|
||||
});
|
||||
//Navigate to baseURL with injected localStorage
|
||||
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' });
|
||||
|
||||
//Assertions on loaded Condition Set in main view. This is a stateful transition step after page.goto()
|
||||
await expect
|
||||
.soft(page.locator('.l-browse-bar__object-name'))
|
||||
.toContainText('Unnamed Condition Set');
|
||||
//Assertions on loaded Condition Set in main view. This is a stateful transition step after page.goto()
|
||||
await expect
|
||||
.soft(page.locator('.l-browse-bar__object-name'))
|
||||
.toContainText('Unnamed Condition Set');
|
||||
|
||||
//Assertions on loaded Condition Set in Inspector
|
||||
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy();
|
||||
//Assertions on loaded Condition Set in Inspector
|
||||
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy();
|
||||
|
||||
//Reload Page
|
||||
await Promise.all([page.reload(), page.waitForLoadState('networkidle')]);
|
||||
//Reload Page
|
||||
await Promise.all([page.reload(), page.waitForLoadState('networkidle')]);
|
||||
|
||||
//Re-verify after reload
|
||||
await expect
|
||||
.soft(page.locator('.l-browse-bar__object-name'))
|
||||
.toContainText('Unnamed Condition Set');
|
||||
//Assertions on loaded Condition Set in Inspector
|
||||
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy();
|
||||
});
|
||||
//Re-verify after reload
|
||||
await expect
|
||||
.soft(page.locator('.l-browse-bar__object-name'))
|
||||
.toContainText('Unnamed Condition Set');
|
||||
//Assertions on loaded Condition Set in Inspector
|
||||
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy();
|
||||
}
|
||||
);
|
||||
test('condition set object can be modified on @localStorage', async ({ page, openmctConfig }) => {
|
||||
const { myItemsFolderName } = openmctConfig;
|
||||
|
||||
|
@ -161,6 +161,13 @@ test.describe('Display Layout', () => {
|
||||
const trimmedDisplayValue = displayLayoutValue.trim();
|
||||
|
||||
expect(trimmedDisplayValue).toBe(formattedTelemetryValue);
|
||||
|
||||
// ensure we can right click on the alpha-numeric widget and view historical data
|
||||
await page.getByLabel('Sine', { exact: true }).click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.getByLabel('View Historical Data').click();
|
||||
await expect(page.getByLabel('Plot Container Style Target')).toBeVisible();
|
||||
});
|
||||
test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in fixed time', async ({
|
||||
page
|
||||
|
@ -136,7 +136,11 @@ test.describe('Gauge', () => {
|
||||
// TODO: Verify changes in the UI
|
||||
});
|
||||
|
||||
test('Gauge does not display NaN when data not available', async ({ page }) => {
|
||||
test.fixme('Gauge does not display NaN when data not available', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7421'
|
||||
});
|
||||
// Create a Gauge
|
||||
const gauge = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Gauge'
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 30 KiB |
Binary file not shown.
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 39 KiB |
@ -63,6 +63,66 @@ test.describe('Overlay Plot', () => {
|
||||
await expect(seriesColorSwatch).toHaveCSS('background-color', 'rgb(255, 166, 61)');
|
||||
});
|
||||
|
||||
test('Plot legend expands by default', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7403'
|
||||
});
|
||||
const overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Overlay Plot'
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await page.goto(overlayPlot.url);
|
||||
|
||||
await page.getByRole('tab', { name: 'Config' }).click();
|
||||
|
||||
// Assert that the legend is collapsed by default
|
||||
await expect(page.getByLabel('Plot Legend Collapsed')).toBeVisible();
|
||||
await expect(page.getByLabel('Plot Legend Expanded')).toBeHidden();
|
||||
await expect(page.getByLabel('Expand by Default')).toHaveText('No');
|
||||
|
||||
expect(await page.getByLabel('Plot Legend Item').count()).toBe(3);
|
||||
|
||||
// Change the legend to expand by default
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByLabel('Expand By Default').check();
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
// Assert that the legend is now open
|
||||
await expect(page.getByLabel('Plot Legend Collapsed')).toBeHidden();
|
||||
await expect(page.getByLabel('Plot Legend Expanded')).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Name' })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Timestamp' })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Value' })).toBeVisible();
|
||||
await expect(page.getByLabel('Expand by Default')).toHaveText('Yes');
|
||||
await expect(page.getByLabel('Plot Legend Item')).toHaveCount(3);
|
||||
|
||||
// Assert that the legend is expanded on page load
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Plot Legend Collapsed')).toBeHidden();
|
||||
await expect(page.getByLabel('Plot Legend Expanded')).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Name' })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Timestamp' })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Value' })).toBeVisible();
|
||||
await expect(page.getByLabel('Expand by Default')).toHaveText('Yes');
|
||||
await expect(page.getByLabel('Plot Legend Item')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('Limit lines persist when series is moved to another Y Axis and on refresh', async ({
|
||||
page
|
||||
}) => {
|
||||
@ -224,31 +284,37 @@ test.describe('Overlay Plot', () => {
|
||||
expect(yAxis3Group.getByRole('listitem').nth(0).getByText(swgB.name)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Clicking on an item in the elements pool brings up the plot preview with data points', async ({
|
||||
page
|
||||
}) => {
|
||||
const overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Overlay Plot'
|
||||
});
|
||||
test.fixme(
|
||||
'Clicking on an item in the elements pool brings up the plot preview with data points',
|
||||
async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7421'
|
||||
});
|
||||
|
||||
const swgA = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
const overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Overlay Plot'
|
||||
});
|
||||
|
||||
await page.goto(overlayPlot.url);
|
||||
// Wait for plot series data to load and be drawn
|
||||
await waitForPlotsToRender(page);
|
||||
await page.getByLabel('Edit Object').click();
|
||||
const swgA = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await page.getByRole('tab', { name: 'Elements' }).click();
|
||||
await page.goto(overlayPlot.url);
|
||||
// Wait for plot series data to load and be drawn
|
||||
await waitForPlotsToRender(page);
|
||||
await page.getByLabel('Edit Object').click();
|
||||
|
||||
await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).click();
|
||||
await page.getByRole('tab', { name: 'Elements' }).click();
|
||||
|
||||
const plotPixels = await getCanvasPixels(page, '.js-overlay canvas');
|
||||
const plotPixelSize = plotPixels.length;
|
||||
expect(plotPixelSize).toBeGreaterThan(0);
|
||||
});
|
||||
await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).click();
|
||||
|
||||
const plotPixels = await getCanvasPixels(page, '.js-overlay canvas');
|
||||
const plotPixelSize = plotPixels.length;
|
||||
expect(plotPixelSize).toBeGreaterThan(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -52,7 +52,11 @@ test.describe('Plot Rendering', () => {
|
||||
expect(createMineFolderRequests.length).toEqual(0);
|
||||
});
|
||||
|
||||
test('Plot is rendered when infinity values exist', async ({ page }) => {
|
||||
test.fixme('Plot is rendered when infinity values exist', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7421'
|
||||
});
|
||||
// Edit Plot
|
||||
await editSineWaveToUseInfinityOption(page, sineWaveGeneratorObject);
|
||||
|
||||
|
88
e2e/tests/functional/plugins/plot/previews.e2e.spec.js
Normal file
88
e2e/tests/functional/plugins/plot/previews.e2e.spec.js
Normal file
@ -0,0 +1,88 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2024, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
|
||||
import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Plots work in Previews', () => {
|
||||
test('We can preview plot in display layouts', async ({ page, openmctConfig }) => {
|
||||
const { myItemsFolderName } = openmctConfig;
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
// Create a Sinewave Generator
|
||||
const sineWaveObject = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator'
|
||||
});
|
||||
// Create a Display Layout
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Display Layout',
|
||||
name: 'Test Display Layout'
|
||||
});
|
||||
// Edit Display Layout
|
||||
await page.getByLabel('Edit Object').click();
|
||||
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.getByLabel(`Expand ${myItemsFolderName} folder`).click();
|
||||
// Add the Sine Wave Generator to the Display Layout and save changes
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
const layoutGridHolder = page.getByLabel('Test Display Layout Layout Grid');
|
||||
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
|
||||
// right click on the plot and select view large
|
||||
await page.getByLabel('Sine', { exact: true }).click({ button: 'right' });
|
||||
await page.getByLabel('View Historical Data').click();
|
||||
await expect(page.getByLabel('Preview Container').getByLabel('Plot Canvas')).toBeVisible();
|
||||
await page.getByLabel('Close').click();
|
||||
await page.getByLabel('Expand Test Display Layout layout').click();
|
||||
|
||||
// change to a plot and ensure embiggen works
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByLabel('Move Sub-object Frame').click();
|
||||
await page.getByText('View type').click();
|
||||
await page.getByText('Overlay Plot').click();
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
await expect(
|
||||
page.getByLabel('Test Display Layout Layout', { exact: true }).getByLabel('Plot Canvas')
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Preview Container')).toBeHidden();
|
||||
await page.getByLabel('Large View').click();
|
||||
await expect(page.getByLabel('Preview Container').getByLabel('Plot Canvas')).toBeVisible();
|
||||
await page.getByLabel('Close').click();
|
||||
|
||||
// get last sinewave tree item (in the display layout)
|
||||
await page
|
||||
.getByRole('treeitem', { name: /Sine Wave Generator/ })
|
||||
.locator('a')
|
||||
.last()
|
||||
.click({ button: 'right' });
|
||||
await page.getByLabel('View', { exact: true }).click();
|
||||
await expect(page.getByLabel('Preview Container').getByLabel('Plot Canvas')).toBeVisible();
|
||||
await page.getByLabel('Close').click();
|
||||
});
|
||||
});
|
@ -257,6 +257,56 @@ test.describe('Stacked Plot', () => {
|
||||
|
||||
await assertAggregateLegendIsVisible(page);
|
||||
});
|
||||
|
||||
test('can toggle between aggregate and per child legends', async ({ page }) => {
|
||||
// make some an overlay plot
|
||||
const overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Overlay Plot',
|
||||
parent: stackedPlot.uuid
|
||||
});
|
||||
|
||||
// make some SWGs for the overlay plot
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await page.goto(stackedPlot.url);
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByRole('tab', { name: 'Config' }).click();
|
||||
await page.getByLabel('Inspector Views').getByRole('checkbox').uncheck();
|
||||
await page.getByLabel('Expand By Default').check();
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
await expect(page.getByLabel('Plot Legend Expanded')).toHaveCount(1);
|
||||
await expect(page.getByLabel('Plot Legend Item')).toHaveCount(5);
|
||||
|
||||
// reload and ensure the legend is still expanded
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Plot Legend Expanded')).toHaveCount(1);
|
||||
await expect(page.getByLabel('Plot Legend Item')).toHaveCount(5);
|
||||
|
||||
// change to collapsed by default
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByLabel('Expand By Default').uncheck();
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
await expect(page.getByLabel('Plot Legend Collapsed')).toHaveCount(1);
|
||||
await expect(page.getByLabel('Plot Legend Item')).toHaveCount(5);
|
||||
|
||||
// change it to individual legends
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByRole('tab', { name: 'Config' }).click();
|
||||
await page.getByLabel('Show Legends For Children').check();
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
await expect(page.getByLabel('Plot Legend Collapsed')).toHaveCount(4);
|
||||
await expect(page.getByLabel('Plot Legend Item')).toHaveCount(5);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,125 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2023, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
import { createDomainObjectWithDefaults, expandEntireTree } from '../../../../appActions.js';
|
||||
import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Reload action', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const displayLayout = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Display Layout'
|
||||
});
|
||||
|
||||
const alphaTable = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Telemetry Table',
|
||||
name: 'Alpha Table'
|
||||
});
|
||||
|
||||
const betaTable = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Telemetry Table',
|
||||
name: 'Beta Table'
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: alphaTable.uuid,
|
||||
customParameters: {
|
||||
'[aria-label="Data Rate (hz)"]': '0.001'
|
||||
}
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: betaTable.uuid,
|
||||
customParameters: {
|
||||
'[aria-label="Data Rate (hz)"]': '0.001'
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(displayLayout.url);
|
||||
|
||||
// Expand all folders
|
||||
await expandEntireTree(page);
|
||||
|
||||
await page.getByLabel('Edit Object', { exact: true }).click();
|
||||
|
||||
await page.dragAndDrop(`text='Alpha Table'`, '.l-layout__grid-holder', {
|
||||
targetPosition: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
await page.dragAndDrop(`text='Beta Table'`, '.l-layout__grid-holder', {
|
||||
targetPosition: { x: 0, y: 250 }
|
||||
});
|
||||
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
});
|
||||
|
||||
test('can reload display layout and its children', async ({ page }) => {
|
||||
const beforeReloadAlphaTelemetryValue = await page
|
||||
.getByLabel('Alpha Table table content')
|
||||
.getByLabel('wavelengths table cell')
|
||||
.first()
|
||||
.getAttribute('title');
|
||||
const beforeReloadBetaTelemetryValue = await page
|
||||
.getByLabel('Beta Table table content')
|
||||
.getByLabel('wavelengths table cell')
|
||||
.first()
|
||||
.getAttribute('title');
|
||||
// reload alpha
|
||||
await page.getByTitle('View menu items').first().click();
|
||||
await page.getByRole('menuitem', { name: /Reload/ }).click();
|
||||
|
||||
const afterReloadAlphaTelemetryValue = await page
|
||||
.getByLabel('Alpha Table table content')
|
||||
.getByLabel('wavelengths table cell')
|
||||
.first()
|
||||
.getAttribute('title');
|
||||
const afterReloadBetaTelemetryValue = await page
|
||||
.getByLabel('Beta Table table content')
|
||||
.getByLabel('wavelengths table cell')
|
||||
.first()
|
||||
.getAttribute('title');
|
||||
|
||||
expect(beforeReloadAlphaTelemetryValue).not.toEqual(afterReloadAlphaTelemetryValue);
|
||||
expect(beforeReloadBetaTelemetryValue).toEqual(afterReloadBetaTelemetryValue);
|
||||
|
||||
// now reload parent
|
||||
await page.getByTitle('More actions').click();
|
||||
await page.getByRole('menuitem', { name: /Reload/ }).click();
|
||||
|
||||
const fullReloadAlphaTelemetryValue = await page
|
||||
.getByLabel('Alpha Table table content')
|
||||
.getByLabel('wavelengths table cell')
|
||||
.first()
|
||||
.getAttribute('title');
|
||||
const fullReloadBetaTelemetryValue = await page
|
||||
.getByLabel('Beta Table table content')
|
||||
.getByLabel('wavelengths table cell')
|
||||
.first()
|
||||
.getAttribute('title');
|
||||
|
||||
expect(fullReloadAlphaTelemetryValue).not.toEqual(afterReloadAlphaTelemetryValue);
|
||||
expect(fullReloadBetaTelemetryValue).not.toEqual(afterReloadBetaTelemetryValue);
|
||||
});
|
||||
});
|
@ -32,10 +32,10 @@ const setBorderColor = '#ff00ff';
|
||||
const setBackgroundColor = '#5b0f00';
|
||||
const setTextColor = '#e6b8af';
|
||||
const defaultFrameBorderColor = '#e6b8af'; //default border color
|
||||
const defaultBorderTargetColor = '#aaaaaa';
|
||||
const defaultTextColor = '#aaaaaa'; // default text color
|
||||
const inheritedColor = '#aaaaaa'; // inherited from the body style
|
||||
const pukeGreen = '#6aa84f'; //Ugliest green known to man
|
||||
const defaultBorderTargetColor = '#acacac';
|
||||
const defaultTextColor = '#acacac'; // default text color
|
||||
const inheritedColor = '#acacac'; // inherited from the body style
|
||||
const pukeGreen = '#6aa84f'; //Ugliest green known to man 🤮
|
||||
const NO_STYLE_RGBA = 'rgba(0, 0, 0, 0)'; //default background color value
|
||||
|
||||
test.describe('Flexible Layout styling', () => {
|
||||
@ -397,8 +397,8 @@ test.describe('Flexible Layout styling', () => {
|
||||
page.getByLabel('StackedPlot1 Frame').getByLabel('Stacked Plot Style Target')
|
||||
);
|
||||
// Save Flexible Layout
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
|
||||
// Reload page and verify that styles persist
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
@ -411,4 +411,39 @@ test.describe('Flexible Layout styling', () => {
|
||||
page.getByLabel('StackedPlot1 Frame').getByLabel('Stacked Plot Style Target')
|
||||
);
|
||||
});
|
||||
|
||||
test('Styling, and then canceling reverts to previous style', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7233'
|
||||
});
|
||||
|
||||
await page.goto(flexibleLayout.url);
|
||||
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByRole('tab', { name: 'Styles' }).click();
|
||||
await setStyles(
|
||||
page,
|
||||
setBorderColor,
|
||||
setBackgroundColor,
|
||||
setTextColor,
|
||||
page.getByLabel('Flexible Layout Column')
|
||||
);
|
||||
await page.getByLabel('Cancel Editing').click();
|
||||
await page.getByRole('button', { name: 'OK', exact: true }).click();
|
||||
await checkStyles(
|
||||
hexToRGB(defaultBorderTargetColor),
|
||||
NO_STYLE_RGBA,
|
||||
hexToRGB(inheritedColor),
|
||||
page.getByLabel('Flexible Layout Column')
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await checkStyles(
|
||||
hexToRGB(defaultBorderTargetColor),
|
||||
NO_STYLE_RGBA,
|
||||
hexToRGB(inheritedColor),
|
||||
page.getByLabel('Flexible Layout Column')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -36,9 +36,9 @@ import { test } from '../../../../pluginFixtures.js';
|
||||
const setBorderColor = '#ff00ff';
|
||||
const setBackgroundColor = '#5b0f00';
|
||||
const setTextColor = '#e6b8af';
|
||||
const defaultTextColor = '#aaaaaa'; // default text color
|
||||
const defaultTextColor = '#acacac'; // default text color
|
||||
const NO_STYLE_RGBA = 'rgba(0, 0, 0, 0)'; //default background color value
|
||||
const DEFAULT_PLOT_VIEW_BORDER_COLOR = '#AAAAAA';
|
||||
const DEFAULT_PLOT_VIEW_BORDER_COLOR = '#acacac';
|
||||
const setFontSize = '72px';
|
||||
const setFontWeight = '700'; //bold for monospace bold
|
||||
const setFontFamily = '"Andale Mono", sans-serif';
|
||||
|
@ -24,13 +24,18 @@ import { createDomainObjectWithDefaults } from '../../../../appActions.js';
|
||||
import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Tabs View', () => {
|
||||
test('Renders tabbed elements', async ({ page }) => {
|
||||
let tabsView;
|
||||
let table;
|
||||
let notebook;
|
||||
let sineWaveGenerator;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const tabsView = await createDomainObjectWithDefaults(page, {
|
||||
tabsView = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Tabs View'
|
||||
});
|
||||
const table = await createDomainObjectWithDefaults(page, {
|
||||
table = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Telemetry Table',
|
||||
parent: tabsView.uuid
|
||||
});
|
||||
@ -38,19 +43,21 @@ test.describe('Tabs View', () => {
|
||||
type: 'Event Message Generator',
|
||||
parent: table.uuid
|
||||
});
|
||||
const notebook = await createDomainObjectWithDefaults(page, {
|
||||
notebook = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
parent: tabsView.uuid
|
||||
});
|
||||
const sineWaveGenerator = await createDomainObjectWithDefaults(page, {
|
||||
sineWaveGenerator = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: tabsView.uuid
|
||||
});
|
||||
});
|
||||
|
||||
page.goto(tabsView.url);
|
||||
test('Renders tabbed elements', async ({ page }) => {
|
||||
await page.goto(tabsView.url);
|
||||
|
||||
// select first tab
|
||||
await page.getByLabel(`${table.name} tab`).click();
|
||||
await page.getByLabel(`${table.name} tab`, { exact: true }).click();
|
||||
// ensure table header visible
|
||||
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
|
||||
|
||||
@ -58,7 +65,7 @@ test.describe('Tabs View', () => {
|
||||
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
|
||||
|
||||
// select second tab
|
||||
await page.getByLabel(`${notebook.name} tab`).click();
|
||||
await page.getByLabel(`${notebook.name} tab`, { exact: true }).click();
|
||||
|
||||
// ensure notebook visible
|
||||
await expect(page.locator('.c-notebook__drag-area')).toBeVisible();
|
||||
@ -67,7 +74,7 @@ test.describe('Tabs View', () => {
|
||||
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
|
||||
|
||||
// select third tab
|
||||
await page.getByLabel(`${sineWaveGenerator.name} tab`).click();
|
||||
await page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true }).click();
|
||||
|
||||
// expect sine wave generator visible
|
||||
await expect(page.locator('.c-plot')).toBeVisible();
|
||||
@ -78,7 +85,7 @@ test.describe('Tabs View', () => {
|
||||
await expect(page.locator('canvas').nth(1)).toBeVisible();
|
||||
|
||||
// now try to select the first tab again
|
||||
await page.getByLabel(`${table.name} tab`).click();
|
||||
await page.getByLabel(`${table.name} tab`, { exact: true }).click();
|
||||
// ensure table header visible
|
||||
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
|
||||
|
||||
@ -86,3 +93,29 @@ test.describe('Tabs View', () => {
|
||||
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tabs View CRUD', () => {
|
||||
let tabsView;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
tabsView = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Tabs View'
|
||||
});
|
||||
});
|
||||
|
||||
test('Eager Load Tabs is the default and then can be toggled off', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7198'
|
||||
});
|
||||
await page.goto(tabsView.url);
|
||||
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByLabel('More actions').click();
|
||||
await page.getByLabel('Edit Properties...').click();
|
||||
await expect(await page.getByLabel('Eager Load Tabs')).not.toBeChecked();
|
||||
await page.getByLabel('Eager Load Tabs').setChecked(true);
|
||||
await expect(await page.getByLabel('Eager Load Tabs')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,73 @@
|
||||
/*****************************************************************************
|
||||
* 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 the preview plugin.
|
||||
*/
|
||||
|
||||
import { createDomainObjectWithDefaults, expandEntireTree } from '../../../../appActions.js';
|
||||
import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Preview mode', () => {
|
||||
test('all context menu items are available for a telemetry table', async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
// Create a Display Layout
|
||||
const displayLayout = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Display Layout'
|
||||
});
|
||||
// Create a Telemetry Table
|
||||
const telemetryTable = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Telemetry Table',
|
||||
parent: displayLayout.uuid
|
||||
});
|
||||
// Create a Sinewave Generator
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: telemetryTable.uuid
|
||||
});
|
||||
|
||||
await page.goto(displayLayout.url);
|
||||
await page.getByLabel('View menu items').click();
|
||||
await expect(page.getByLabel('Export Marked Rows')).toBeVisible();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Large View' }).click();
|
||||
await page.getByLabel('Overlay').getByLabel('More actions').click();
|
||||
await expect(page.getByLabel('Export Table Data')).toBeVisible();
|
||||
await expect(page.getByLabel('Export Marked Rows')).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Pause' }).click();
|
||||
await page.getByLabel('Close').click();
|
||||
|
||||
await expandEntireTree(page);
|
||||
|
||||
await page.getByLabel('Edit Object').click();
|
||||
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const telemetryTableTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(telemetryTable.name)
|
||||
});
|
||||
await telemetryTableTreeItem.locator('a').click();
|
||||
await page.getByLabel('Overlay').getByLabel('More actions').click();
|
||||
await expect(page.getByLabel('Export Table Data')).toBeVisible();
|
||||
await expect(page.getByLabel('Export Marked Rows')).toBeVisible();
|
||||
});
|
||||
});
|
@ -64,10 +64,9 @@ test.describe('Telemetry Table', () => {
|
||||
|
||||
// Get the most recent telemetry date
|
||||
const latestTelemetryDate = await page
|
||||
.locator('table.c-telemetry-table__body > tbody > tr')
|
||||
.getByLabel('table content')
|
||||
.getByLabel('utc table cell')
|
||||
.last()
|
||||
.locator('td')
|
||||
.nth(1)
|
||||
.getAttribute('title');
|
||||
|
||||
// Verify that it is <= our new end bound
|
||||
@ -91,7 +90,7 @@ test.describe('Telemetry Table', () => {
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).click();
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).fill('Roger');
|
||||
|
||||
let cells = await page.getByRole('cell', { name: /Roger/ }).all();
|
||||
let cells = await page.getByRole('cell').getByText(/Roger/).all();
|
||||
// ensure we've got more than one cell
|
||||
expect(cells.length).toBeGreaterThan(1);
|
||||
// ensure the text content of each cell contains the search term
|
||||
@ -103,7 +102,10 @@ test.describe('Telemetry Table', () => {
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).click();
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).fill('Dodger');
|
||||
|
||||
cells = await page.getByRole('cell', { name: /Dodger/ }).all();
|
||||
cells = await page
|
||||
.getByRole('cell')
|
||||
.getByText(/Dodger/)
|
||||
.all();
|
||||
// ensure we've got more than one cell
|
||||
expect(cells.length).toBe(0);
|
||||
// ensure the text content of each cell contains the search term
|
||||
@ -135,7 +137,7 @@ test.describe('Telemetry Table', () => {
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).click();
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).fill('/[Rr]oger/');
|
||||
|
||||
let cells = await page.getByRole('cell', { name: /Roger/ }).all();
|
||||
let cells = await page.getByRole('cell').getByText(/Roger/).all();
|
||||
// ensure we've got more than one cell
|
||||
expect(cells.length).toBeGreaterThan(1);
|
||||
// ensure the text content of each cell contains the search term
|
||||
@ -147,7 +149,10 @@ test.describe('Telemetry Table', () => {
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).click();
|
||||
await page.getByRole('searchbox', { name: 'message filter input' }).fill('/[Dd]oger/');
|
||||
|
||||
cells = await page.getByRole('cell', { name: /Dodger/ }).all();
|
||||
cells = await page
|
||||
.getByRole('cell')
|
||||
.getByText(/Dodger/)
|
||||
.all();
|
||||
// ensure we've got more than one cell
|
||||
expect(cells.length).toBe(0);
|
||||
// ensure the text content of each cell contains the search term
|
||||
|
@ -359,7 +359,11 @@ test.describe('Verify tooltips', () => {
|
||||
expect(tooltipText).toBe(sineWaveObject3.path);
|
||||
});
|
||||
|
||||
test('display tooltip path for telemetry table names', async ({ page }) => {
|
||||
test.fixme('display tooltip path for telemetry table names', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7421'
|
||||
});
|
||||
// set endBound to 10 seconds after start bound
|
||||
const url = await page.url();
|
||||
const parsedUrl = new URL(url.replace('#', '!'));
|
||||
|
@ -24,7 +24,7 @@ import { createDomainObjectWithDefaults, waitForPlotsToRender } from '../../appA
|
||||
import { expect, test } from '../../pluginFixtures.js';
|
||||
|
||||
test.describe('Tabs View', () => {
|
||||
test('Renders tabbed elements nicely', async ({ page }) => {
|
||||
test('Renders tabbed elements only when visible', async ({ page }) => {
|
||||
// Code to hook into the requestAnimationFrame function and log each call
|
||||
let animationCalls = [];
|
||||
await page.exposeFunction('logCall', (callCount) => {
|
||||
@ -64,24 +64,24 @@ test.describe('Tabs View', () => {
|
||||
page.goto(tabsView.url);
|
||||
|
||||
// select first tab
|
||||
await page.getByLabel(`${table.name} tab`).click();
|
||||
await page.getByLabel(`${table.name} tab`, { exact: true }).click();
|
||||
// ensure table header visible
|
||||
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
|
||||
|
||||
// select second tab
|
||||
await page.getByLabel(`${notebook.name} tab`).click();
|
||||
await page.getByLabel(`${notebook.name} tab`, { exact: true }).click();
|
||||
|
||||
// expect notebook visible
|
||||
await expect(page.locator('.c-notebook__drag-area')).toBeVisible();
|
||||
|
||||
// select third tab
|
||||
await page.getByLabel(`${sineWaveGenerator.name} tab`).click();
|
||||
await page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true }).click();
|
||||
|
||||
// ensure sine wave generator visible
|
||||
expect(await page.locator('.c-plot').isVisible()).toBe(true);
|
||||
|
||||
// now select notebook and clear animation calls
|
||||
await page.getByLabel(`${notebook.name} tab`).click();
|
||||
await page.getByLabel(`${notebook.name} tab`, { exact: true }).click();
|
||||
animationCalls = [];
|
||||
// expect notebook visible
|
||||
await expect(page.locator('.c-notebook__drag-area')).toBeVisible();
|
||||
@ -89,7 +89,7 @@ test.describe('Tabs View', () => {
|
||||
|
||||
// select sine wave generator and clear animation calls
|
||||
animationCalls = [];
|
||||
await page.getByLabel(`${sineWaveGenerator.name} tab`).click();
|
||||
await page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true }).click();
|
||||
|
||||
// ensure sine wave generator visible
|
||||
await waitForPlotsToRender(page);
|
@ -20,14 +20,16 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import { scanForA11yViolations, test } from '../../avpFixtures.js';
|
||||
import { test } from '../../avpFixtures.js';
|
||||
import { VISUAL_URL } from '../../constants.js';
|
||||
|
||||
test.describe('a11y - Default @a11y', () => {
|
||||
test.describe('a11y - Default', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
test('main view @a11y', async ({ page }, testInfo) => {
|
||||
await scanForA11yViolations(page, testInfo.title);
|
||||
test('main view', async ({ page }, testInfo) => {
|
||||
await page.goto('./');
|
||||
//Skipping for https://github.com/nasa/openmct/issues/7421
|
||||
//await scanForA11yViolations(page, testInfo.title);
|
||||
});
|
||||
});
|
||||
|
@ -26,7 +26,7 @@ Tests the branding associated with the default deployment. At least the about mo
|
||||
|
||||
import percySnapshot from '@percy/playwright';
|
||||
|
||||
import { expect, scanForA11yViolations, test } from '../../../avpFixtures.js';
|
||||
import { expect, test } from '../../../avpFixtures.js';
|
||||
import { VISUAL_URL } from '../../../constants.js';
|
||||
|
||||
//Declare the scope of the visual test
|
||||
@ -78,6 +78,7 @@ test.describe('Visual - Header @a11y', () => {
|
||||
await expect(await page.getByLabel('Show Snapshots')).toBeVisible();
|
||||
});
|
||||
});
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await scanForA11yViolations(page, testInfo.title);
|
||||
});
|
||||
// Skipping for https://github.com/nasa/openmct/issues/7421
|
||||
// test.afterEach(async ({ page }, testInfo) => {
|
||||
// await scanForA11yViolations(page, testInfo.title);
|
||||
// });
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
import percySnapshot from '@percy/playwright';
|
||||
|
||||
import { scanForA11yViolations, test } from '../../../avpFixtures.js';
|
||||
import { test } from '../../../avpFixtures.js';
|
||||
import { MISSION_TIME, VISUAL_URL } from '../../../constants.js';
|
||||
|
||||
//Declare the scope of the visual test
|
||||
@ -55,6 +55,7 @@ test.describe('Visual - Inspector @ally', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await scanForA11yViolations(page, testInfo.title);
|
||||
});
|
||||
// Skipping for https://github.com/nasa/openmct/issues/7421
|
||||
// test.afterEach(async ({ page }, testInfo) => {
|
||||
// await scanForA11yViolations(page, testInfo.title);
|
||||
// });
|
||||
|
93
e2e/tests/visual-a11y/imagery.visual.spec.js
Normal file
93
e2e/tests/visual-a11y/imagery.visual.spec.js
Normal 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.
|
||||
*****************************************************************************/
|
||||
|
||||
import percySnapshot from '@percy/playwright';
|
||||
|
||||
import { createDomainObjectWithDefaults, setRealTimeMode } from '../../appActions.js';
|
||||
import { VISUAL_URL } from '../../constants.js';
|
||||
import { expect, test } from '../../pluginFixtures.js';
|
||||
|
||||
test.describe('Visual - Example Imagery', () => {
|
||||
let exampleImagery;
|
||||
let parentLayout;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
parentLayout = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Display Layout',
|
||||
name: 'Parent Layout'
|
||||
});
|
||||
|
||||
exampleImagery = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Example Imagery',
|
||||
name: 'Example Imagery Test',
|
||||
parent: parentLayout.uuid
|
||||
});
|
||||
|
||||
// Modify Example Imagery to create a really stable Example Imagery
|
||||
await page.goto(exampleImagery.url, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: 'More actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
|
||||
await page
|
||||
.locator('#imageLocation-textarea')
|
||||
.fill(
|
||||
'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-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-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-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-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-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-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-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-18731.jpg'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.getByTitle('Collapse Browse Pane').click();
|
||||
await page.getByTitle('Collapse Inspect Pane').click();
|
||||
});
|
||||
|
||||
test('Example Imagery in Fixed Time', async ({ page, theme }) => {
|
||||
await page.goto(exampleImagery.url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
|
||||
|
||||
await percySnapshot(page, `Example Imagery in Fixed Time (theme: ${theme})`);
|
||||
|
||||
await page.getByLabel('Image Wrapper').hover();
|
||||
|
||||
await percySnapshot(page, `Example Imagery Hover in Fixed Time (theme: ${theme})`);
|
||||
});
|
||||
|
||||
test('Example Imagery in Real Time', async ({ page, theme }) => {
|
||||
await page.goto(exampleImagery.url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await setRealTimeMode(page, true);
|
||||
//Temporary to close the dialog
|
||||
await page.getByLabel('Submit time offsets').click();
|
||||
|
||||
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
|
||||
|
||||
await percySnapshot(page, `Example Imagery in Real Time (theme: ${theme})`);
|
||||
});
|
||||
|
||||
test('Example Imagery in Display Layout', async ({ page, theme }) => {
|
||||
await page.goto(parentLayout.url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
|
||||
|
||||
await percySnapshot(page, `Example Imagery in Display Layout (theme: ${theme})`);
|
||||
});
|
||||
});
|
57
e2e/tests/visual-a11y/missionStatus.visual.spec.js
Normal file
57
e2e/tests/visual-a11y/missionStatus.visual.spec.js
Normal file
@ -0,0 +1,57 @@
|
||||
/*****************************************************************************
|
||||
* 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 { fileURLToPath } from 'url';
|
||||
|
||||
import { expect, scanForA11yViolations, test } from '../../avpFixtures.js';
|
||||
|
||||
test.describe('Mission Status Visual Tests @a11y', () => {
|
||||
const GO = '1';
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript({
|
||||
path: fileURLToPath(new URL('../../helper/addInitExampleUser.js', import.meta.url))
|
||||
});
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByText('Select Role')).toBeVisible();
|
||||
// Description should be empty https://github.com/nasa/openmct/issues/6978
|
||||
await expect(page.locator('c-message__action-text')).toBeHidden();
|
||||
// set role
|
||||
await page.getByRole('button', { name: 'Select', exact: true }).click();
|
||||
// dismiss role confirmation popup
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
});
|
||||
test('Mission status panel', async ({ page, theme }) => {
|
||||
await page.getByLabel('Toggle Mission Status Panel').click();
|
||||
await expect(page.getByRole('dialog', { name: 'User Control Panel' })).toBeVisible();
|
||||
await percySnapshot(page, `Mission status panel w/ default statuses (theme: '${theme}')`);
|
||||
await page.getByRole('combobox', { name: 'Commanding' }).selectOption(GO);
|
||||
await expect(
|
||||
page.getByRole('alert').filter({ hasText: 'Successfully set mission status' })
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Dismiss').click();
|
||||
await percySnapshot(page, `Mission status panel w/ non-default status (theme: '${theme}')`);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await scanForA11yViolations(page, testInfo.title);
|
||||
});
|
||||
});
|
@ -23,11 +23,11 @@
|
||||
import percySnapshot from '@percy/playwright';
|
||||
|
||||
import { createDomainObjectWithDefaults, expandTreePaneItemByName } from '../../appActions.js';
|
||||
import { scanForA11yViolations, test } from '../../avpFixtures.js';
|
||||
import { test } from '../../avpFixtures.js';
|
||||
import { VISUAL_URL } from '../../constants.js';
|
||||
import { enterTextEntry, startAndAddRestrictedNotebookObject } from '../../helper/notebookUtils.js';
|
||||
|
||||
test.describe('Visual - Restricted Notebook', () => {
|
||||
test.describe('Visual - Restricted Notebook @a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const restrictedNotebook = await startAndAddRestrictedNotebookObject(page);
|
||||
await page.goto(restrictedNotebook.url + '?hideTree=true&hideInspector=true');
|
||||
@ -39,7 +39,7 @@ test.describe('Visual - Restricted Notebook', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Visual - Notebook', () => {
|
||||
test.describe('Visual - Notebook @a11y', () => {
|
||||
let notebook;
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
|
||||
@ -125,7 +125,8 @@ test.describe('Visual - Notebook', () => {
|
||||
// Take a snapshot
|
||||
await percySnapshot(page, `Notebook Selected Entry Text Area Active (theme: '${theme}')`);
|
||||
});
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await scanForA11yViolations(page, testInfo.title);
|
||||
});
|
||||
// Skipping for https://github.com/nasa/openmct/issues/7421
|
||||
// test.afterEach(async ({ page }, testInfo) => {
|
||||
// await scanForA11yViolations(page, testInfo.title);
|
||||
// });
|
||||
});
|
||||
|
@ -24,7 +24,7 @@ import percySnapshot from '@percy/playwright';
|
||||
import fs from 'fs';
|
||||
|
||||
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js';
|
||||
import { scanForA11yViolations, test } from '../../avpFixtures.js';
|
||||
import { test } from '../../avpFixtures.js';
|
||||
import { VISUAL_URL } from '../../constants.js';
|
||||
import { setBoundsToSpanAllActivities, setDraftStatusForPlan } from '../../helper/planningUtils.js';
|
||||
|
||||
@ -34,7 +34,7 @@ const examplePlanSmall = JSON.parse(
|
||||
|
||||
const snapshotScope = '.l-shell__pane-main .l-pane__contents';
|
||||
|
||||
test.describe('Visual - Planning @a11y', () => {
|
||||
test.describe('Visual - Planning', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
@ -75,7 +75,25 @@ test.describe('Visual - Planning @a11y', () => {
|
||||
parent: ganttChart.uuid
|
||||
});
|
||||
await setBoundsToSpanAllActivities(page, examplePlanSmall, ganttChart.url);
|
||||
await percySnapshot(page, `Gantt Chart View (theme: ${theme})`, {
|
||||
await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Clipped Activity Names`, {
|
||||
scope: snapshotScope
|
||||
});
|
||||
|
||||
// Expand the inspect pane and uncheck the 'Clip Activity Names' option
|
||||
await page.getByRole('button', { name: 'Expand Inspect Pane' }).click();
|
||||
await page.getByRole('tab', { name: 'Config' }).click();
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByLabel('Clip Activity Names').click();
|
||||
|
||||
// Close the inspect pane and save the changes
|
||||
await page.getByRole('button', { name: 'Collapse Inspect Pane' }).click();
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
|
||||
// Dismiss the notification
|
||||
await page.getByLabel('Dismiss').click();
|
||||
|
||||
await percySnapshot(page, `Gantt Chart View (theme: ${theme}) - Unclipped Activity Names`, {
|
||||
scope: snapshotScope
|
||||
});
|
||||
});
|
||||
@ -98,8 +116,31 @@ test.describe('Visual - Planning @a11y', () => {
|
||||
await percySnapshot(page, `Gantt Chart View w/ draft status (theme: ${theme})`, {
|
||||
scope: snapshotScope
|
||||
});
|
||||
|
||||
// Expand the inspect pane and uncheck the 'Clip Activity Names' option
|
||||
await page.getByRole('button', { name: 'Expand Inspect Pane' }).click();
|
||||
await page.getByRole('tab', { name: 'Config' }).click();
|
||||
await page.getByLabel('Edit Object').click();
|
||||
await page.getByLabel('Clip Activity Names').click();
|
||||
|
||||
// Close the inspect pane and save the changes
|
||||
await page.getByRole('button', { name: 'Collapse Inspect Pane' }).click();
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
|
||||
// Dismiss the notification
|
||||
await page.getByLabel('Dismiss').click();
|
||||
|
||||
await percySnapshot(
|
||||
page,
|
||||
`Gantt Chart View w/ draft status (theme: ${theme}) - Unclipped Activity Names`,
|
||||
{
|
||||
scope: snapshotScope
|
||||
}
|
||||
);
|
||||
});
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await scanForA11yViolations(page, testInfo.title);
|
||||
});
|
||||
// Skipping for https://github.com/nasa/openmct/issues/7421
|
||||
// test.afterEach(async ({ page }, testInfo) => {
|
||||
// await scanForA11yViolations(page, testInfo.title);
|
||||
// });
|
||||
});
|
||||
|
@ -60,10 +60,22 @@ const STATUSES = [
|
||||
statusFgColor: '#fff'
|
||||
}
|
||||
];
|
||||
|
||||
const MISSION_STATUSES = [
|
||||
{
|
||||
key: 0,
|
||||
label: 'NO GO'
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
label: 'GO'
|
||||
}
|
||||
];
|
||||
/**
|
||||
* @implements {StatusUserProvider}
|
||||
*/
|
||||
export default class ExampleUserProvider extends EventEmitter {
|
||||
#actionToStatusMap;
|
||||
constructor(
|
||||
openmct,
|
||||
{ statusRoles } = {
|
||||
@ -73,6 +85,11 @@ export default class ExampleUserProvider extends EventEmitter {
|
||||
super();
|
||||
|
||||
this.openmct = openmct;
|
||||
this.#actionToStatusMap = {
|
||||
Imagery: MISSION_STATUSES[0],
|
||||
Commanding: MISSION_STATUSES[0],
|
||||
Driving: MISSION_STATUSES[0]
|
||||
};
|
||||
this.user = undefined;
|
||||
this.loggedIn = false;
|
||||
this.autoLoginUser = undefined;
|
||||
@ -110,6 +127,11 @@ export default class ExampleUserProvider extends EventEmitter {
|
||||
canSetPollQuestion() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
canSetMissionStatus() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
hasRole(roleId) {
|
||||
if (!this.loggedIn) {
|
||||
Promise.resolve(undefined);
|
||||
@ -122,6 +144,28 @@ export default class ExampleUserProvider extends EventEmitter {
|
||||
return this.user.getRoles();
|
||||
}
|
||||
|
||||
getPossibleMissionActions() {
|
||||
return Promise.resolve(Object.keys(this.#actionToStatusMap));
|
||||
}
|
||||
|
||||
getPossibleMissionActionStatuses() {
|
||||
return Promise.resolve(MISSION_STATUSES);
|
||||
}
|
||||
|
||||
getStatusForMissionAction(action) {
|
||||
return Promise.resolve(this.#actionToStatusMap[action]);
|
||||
}
|
||||
|
||||
setStatusForMissionAction(action, status) {
|
||||
this.#actionToStatusMap[action] = status;
|
||||
this.emit('missionStatusChange', {
|
||||
action,
|
||||
status
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getAllStatusRoles() {
|
||||
return Promise.resolve(this.statusRoles);
|
||||
}
|
||||
|
@ -92,6 +92,8 @@ GeneratorProvider.prototype.request = function (domainObject, request) {
|
||||
var workerRequest = this.makeWorkerRequest(domainObject, request);
|
||||
workerRequest.start = request.start;
|
||||
workerRequest.end = request.end;
|
||||
workerRequest.size = request.size;
|
||||
workerRequest.strategy = request.strategy;
|
||||
|
||||
return this.workerInterface.request(workerRequest);
|
||||
};
|
||||
|
@ -130,48 +130,37 @@
|
||||
var now = Date.now();
|
||||
var start = request.start;
|
||||
var end = request.end > now ? now : request.end;
|
||||
var amplitude = request.amplitude;
|
||||
var period = request.period;
|
||||
var offset = request.offset;
|
||||
var dataRateInHz = request.dataRateInHz;
|
||||
var phase = request.phase;
|
||||
var randomness = request.randomness;
|
||||
var loadDelay = Math.max(request.loadDelay, 0);
|
||||
var infinityValues = request.infinityValues;
|
||||
var exceedFloat32 = request.exceedFloat32;
|
||||
|
||||
var size = request.size;
|
||||
var duration = end - start;
|
||||
var step = 1000 / dataRateInHz;
|
||||
var maxPoints = Math.floor(duration / step);
|
||||
var nextStep = start - (start % step) + step;
|
||||
|
||||
var data = [];
|
||||
|
||||
for (; nextStep < end && data.length < 5000; nextStep += step) {
|
||||
data.push({
|
||||
utc: nextStep,
|
||||
yesterday: nextStep - 60 * 60 * 24 * 1000,
|
||||
sin: sin(
|
||||
nextStep,
|
||||
period,
|
||||
amplitude,
|
||||
offset,
|
||||
phase,
|
||||
randomness,
|
||||
infinityValues,
|
||||
exceedFloat32
|
||||
),
|
||||
wavelengths: wavelengths(),
|
||||
intensities: intensities(),
|
||||
cos: cos(
|
||||
nextStep,
|
||||
period,
|
||||
amplitude,
|
||||
offset,
|
||||
phase,
|
||||
randomness,
|
||||
infinityValues,
|
||||
exceedFloat32
|
||||
)
|
||||
});
|
||||
if (request.strategy === 'minmax' && size) {
|
||||
// Calculate the number of cycles to include based on size (2 points per cycle)
|
||||
var totalCycles = Math.min(Math.floor(size / 2), Math.floor(duration / period));
|
||||
|
||||
for (let cycle = 0; cycle < totalCycles; cycle++) {
|
||||
// Distribute cycles evenly across the time range
|
||||
let cycleStart = start + (duration / totalCycles) * cycle;
|
||||
let minPointTime = cycleStart; // Assuming min at the start of the cycle
|
||||
let maxPointTime = cycleStart + period / 2; // Assuming max at the halfway of the cycle
|
||||
|
||||
data.push(createDataPoint(minPointTime, request), createDataPoint(maxPointTime, request));
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < maxPoints && nextStep < end; i++, nextStep += step) {
|
||||
data.push(createDataPoint(nextStep, request));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.strategy !== 'minmax' && size) {
|
||||
data = data.slice(-size);
|
||||
}
|
||||
|
||||
if (loadDelay === 0) {
|
||||
@ -181,6 +170,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
function createDataPoint(time, request) {
|
||||
return {
|
||||
utc: time,
|
||||
yesterday: time - 60 * 60 * 24 * 1000,
|
||||
sin: sin(
|
||||
time,
|
||||
request.period,
|
||||
request.amplitude,
|
||||
request.offset,
|
||||
request.phase,
|
||||
request.randomness,
|
||||
request.infinityValues,
|
||||
request.exceedFloat32
|
||||
),
|
||||
wavelengths: wavelengths(),
|
||||
intensities: intensities(),
|
||||
cos: cos(
|
||||
time,
|
||||
request.period,
|
||||
request.amplitude,
|
||||
request.offset,
|
||||
request.phase,
|
||||
request.randomness,
|
||||
request.infinityValues,
|
||||
request.exceedFloat32
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function postOnRequest(message, request, data) {
|
||||
self.postMessage({
|
||||
id: message.id,
|
||||
|
18
package.json
18
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmct",
|
||||
"version": "3.3.0-next",
|
||||
"version": "4.0.0-next",
|
||||
"description": "The Open MCT core platform",
|
||||
"type": "module",
|
||||
"main": "dist/openmct.js",
|
||||
@ -28,12 +28,12 @@
|
||||
"d3-axis": "3.0.0",
|
||||
"d3-scale": "4.0.2",
|
||||
"d3-selection": "3.0.0",
|
||||
"eslint": "8.54.0",
|
||||
"eslint-config-prettier": "9.0.0",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-compat": "4.2.0",
|
||||
"eslint-plugin-no-unsanitized": "4.0.2",
|
||||
"eslint-plugin-playwright": "0.12.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-simple-import-sort": "10.0.0",
|
||||
"eslint-plugin-unicorn": "49.0.0",
|
||||
"eslint-plugin-vue": "9.18.1",
|
||||
@ -57,9 +57,9 @@
|
||||
"karma-webpack": "5.0.0",
|
||||
"location-bar": "3.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "11.1.0",
|
||||
"marked": "11.2.0",
|
||||
"mini-css-extract-plugin": "2.7.6",
|
||||
"moment": "2.29.4",
|
||||
"moment": "2.30.1",
|
||||
"moment-duration-format": "2.3.2",
|
||||
"moment-timezone": "0.5.41",
|
||||
"npm-run-all2": "6.1.1",
|
||||
@ -67,12 +67,13 @@
|
||||
"painterro": "1.2.87",
|
||||
"plotly.js-basic-dist-min": "2.20.0",
|
||||
"plotly.js-gl2d-dist-min": "2.20.0",
|
||||
"prettier": "2.8.7",
|
||||
"prettier": "3.2.5",
|
||||
"prettier-eslint": "16.3.0",
|
||||
"printj": "1.3.1",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sass": "1.68.0",
|
||||
"sass-loader": "13.3.2",
|
||||
"sass-loader": "14.0.0",
|
||||
"sinon": "17.0.0",
|
||||
"style-loader": "3.3.3",
|
||||
"terser-webpack-plugin": "5.3.9",
|
||||
@ -111,6 +112,7 @@
|
||||
"test:e2e:unstable": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @unstable",
|
||||
"test:e2e:local": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome",
|
||||
"test:e2e:generatedata": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @generatedata",
|
||||
"test:e2e:checksnapshots": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot --retries=0",
|
||||
"test:e2e:updatesnapshots": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot --update-snapshots",
|
||||
"test:e2e:visual:ci": "percy exec --config ./e2e/.percy.ci.yml --partial -- npx playwright test --config=e2e/playwright-visual-a11y.config.js --project=chrome --grep-invert @unstable",
|
||||
"test:e2e:visual:full": "percy exec --config ./e2e/.percy.nightly.yml -- npx playwright test --config=e2e/playwright-visual-a11y.config.js --grep-invert @unstable",
|
||||
|
@ -251,6 +251,7 @@ export class MCT extends EventEmitter {
|
||||
this.install(this.plugins.FlexibleLayout());
|
||||
this.install(this.plugins.GoToOriginalAction());
|
||||
this.install(this.plugins.OpenInNewTabAction());
|
||||
this.install(this.plugins.ReloadAction());
|
||||
this.install(this.plugins.WebPage());
|
||||
this.install(this.plugins.Condition());
|
||||
this.install(this.plugins.ConditionWidget());
|
||||
|
@ -28,7 +28,8 @@
|
||||
v-for="action in actionGroups"
|
||||
:key="action.name"
|
||||
role="menuitem"
|
||||
:class="[action.cssClass, action.isDisabled ? 'disabled' : '']"
|
||||
:aria-disabled="action.isDisabled"
|
||||
:class="action.cssClass"
|
||||
:aria-label="action.name"
|
||||
:title="action.description"
|
||||
@click="action.onItemClicked"
|
||||
@ -51,7 +52,8 @@
|
||||
v-for="action in options.actions"
|
||||
:key="action.name"
|
||||
role="menuitem"
|
||||
:class="[action.cssClass, action.isDisabled ? 'disabled' : '']"
|
||||
:aria-disabled="action.isDisabled"
|
||||
:class="action.cssClass"
|
||||
:aria-label="action.name"
|
||||
:title="action.description"
|
||||
@click="action.onItemClicked"
|
||||
|
@ -37,7 +37,8 @@
|
||||
v-for="action in actionGroups"
|
||||
:key="action.name"
|
||||
role="menuitem"
|
||||
:class="[action.cssClass, action.isDisabled ? 'disabled' : '']"
|
||||
:aria-disabled="action.isDisabled"
|
||||
:class="action.cssClass"
|
||||
:title="action.description"
|
||||
@click="action.onItemClicked"
|
||||
@mouseover="toggleItemDescription(action)"
|
||||
|
@ -99,7 +99,13 @@ export default class ObjectAPI {
|
||||
this.cache = {};
|
||||
this.interceptorRegistry = new InterceptorRegistry();
|
||||
|
||||
this.SYNCHRONIZED_OBJECT_TYPES = ['notebook', 'restricted-notebook', 'plan', 'annotation'];
|
||||
this.SYNCHRONIZED_OBJECT_TYPES = [
|
||||
'notebook',
|
||||
'restricted-notebook',
|
||||
'plan',
|
||||
'annotation',
|
||||
'activity-states'
|
||||
];
|
||||
|
||||
this.errors = {
|
||||
Conflict: ConflictError
|
||||
@ -348,6 +354,9 @@ export default class ObjectAPI {
|
||||
isPersistable(idOrKeyString) {
|
||||
let identifier = utils.parseKeyString(idOrKeyString);
|
||||
let provider = this.getProvider(identifier);
|
||||
if (provider?.isReadOnly) {
|
||||
return !provider.isReadOnly();
|
||||
}
|
||||
|
||||
return provider !== undefined && provider.create !== undefined && provider.update !== undefined;
|
||||
}
|
||||
@ -687,10 +696,12 @@ export default class ObjectAPI {
|
||||
/**
|
||||
* Updates a domain object based on its latest persisted state. Note that this will mutate the provided object.
|
||||
* @param {module:openmct.DomainObject} domainObject an object to refresh from its persistence store
|
||||
* @param {boolean} [forceRemote=false] defaults to false. If true, will skip cached and
|
||||
* dirty/in-transaction objects use and the provider.get method
|
||||
* @returns {Promise} the provided object, updated to reflect the latest persisted state of the object.
|
||||
*/
|
||||
async refresh(domainObject) {
|
||||
const refreshedObject = await this.get(domainObject.identifier);
|
||||
async refresh(domainObject, forceRemote = false) {
|
||||
const refreshedObject = await this.get(domainObject.identifier, null, forceRemote);
|
||||
|
||||
if (domainObject.isMutable) {
|
||||
domainObject.$refresh(refreshedObject);
|
||||
|
@ -362,7 +362,7 @@ describe('The Object API', () => {
|
||||
expect(objectAPI.get).not.toHaveBeenCalled();
|
||||
|
||||
return objectAPI.refresh(testObject).then(() => {
|
||||
expect(objectAPI.get).toHaveBeenCalledWith(testObject.identifier);
|
||||
expect(objectAPI.get).toHaveBeenCalledWith(testObject.identifier, null, false);
|
||||
|
||||
expect(testObject.otherAttribute).toEqual(OTHER_ATTRIBUTE_VALUE);
|
||||
expect(testObject.newAttribute).toEqual(NEW_ATTRIBUTE_VALUE);
|
||||
|
@ -47,9 +47,9 @@ export default class Transaction {
|
||||
return Promise.all(promiseArray);
|
||||
}
|
||||
|
||||
createDirtyObjectPromise(object, action) {
|
||||
createDirtyObjectPromise(object, action, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
action(object)
|
||||
action(object, ...args)
|
||||
.then((success) => {
|
||||
const key = this.objectAPI.makeKeyString(object.identifier);
|
||||
|
||||
@ -75,10 +75,10 @@ export default class Transaction {
|
||||
|
||||
_clear() {
|
||||
const promiseArray = [];
|
||||
const refresh = this.objectAPI.refresh.bind(this.objectAPI);
|
||||
const action = (obj) => this.objectAPI.refresh(obj, true);
|
||||
|
||||
Object.values(this.dirtyObjects).forEach((object) => {
|
||||
promiseArray.push(this.createDirtyObjectPromise(object, refresh));
|
||||
promiseArray.push(this.createDirtyObjectPromise(object, action));
|
||||
});
|
||||
|
||||
return Promise.all(promiseArray);
|
||||
|
@ -61,6 +61,7 @@ class Overlay extends EventEmitter {
|
||||
dismiss() {
|
||||
this.emit('destroy');
|
||||
this.destroy();
|
||||
this.container.remove();
|
||||
}
|
||||
|
||||
//Ensures that any callers are notified that the overlay is dismissed
|
||||
|
194
src/api/telemetry/BatchingWebSocket.js
Normal file
194
src/api/telemetry/BatchingWebSocket.js
Normal file
@ -0,0 +1,194 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT Web, Copyright (c) 2014-2024, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT Web 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 Web 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 installWorker from './WebSocketWorker.js';
|
||||
const DEFAULT_RATE_MS = 1000;
|
||||
/**
|
||||
* Describes the strategy to be used when batching WebSocket messages
|
||||
*
|
||||
* @typedef BatchingStrategy
|
||||
* @property {Function} shouldBatchMessage a function that accepts a single
|
||||
* argument - the raw message received from the websocket. Every message
|
||||
* received will be evaluated against this function so it should be performant.
|
||||
* Note also that this function is executed in a worker, so it must be
|
||||
* completely self-contained with no external dependencies. The function
|
||||
* should return `true` if the message should be batched, and `false` if not.
|
||||
* @property {Function} getBatchIdFromMessage a function that accepts a
|
||||
* single argument - the raw message received from the websocket. Only messages
|
||||
* where `shouldBatchMessage` has evaluated to true will be passed into this
|
||||
* function. The function should return a unique value on which to batch the
|
||||
* messages. For example a telemetry, channel, or parameter identifier.
|
||||
*/
|
||||
/**
|
||||
* Provides a reliable and convenient WebSocket abstraction layer that handles
|
||||
* a lot of boilerplate common to managing WebSocket connections such as:
|
||||
* - Establishing a WebSocket connection to a server
|
||||
* - Reconnecting on error, with a fallback strategy
|
||||
* - Queuing messages so that clients can send messages without concern for the current
|
||||
* connection state of the WebSocket.
|
||||
*
|
||||
* The WebSocket that it manages is based in a dedicated worker so that network
|
||||
* concerns are not handled on the main event loop. This allows for performant receipt
|
||||
* and batching of messages without blocking either the UI or server.
|
||||
*
|
||||
* @memberof module:openmct.telemetry
|
||||
*/
|
||||
class BatchingWebSocket extends EventTarget {
|
||||
#worker;
|
||||
#openmct;
|
||||
#showingRateLimitNotification;
|
||||
#rate;
|
||||
|
||||
constructor(openmct) {
|
||||
super();
|
||||
// Install worker, register listeners etc.
|
||||
const workerFunction = `(${installWorker.toString()})()`;
|
||||
const workerBlob = new Blob([workerFunction]);
|
||||
const workerUrl = URL.createObjectURL(workerBlob, { type: 'application/javascript' });
|
||||
this.#worker = new Worker(workerUrl);
|
||||
this.#openmct = openmct;
|
||||
this.#showingRateLimitNotification = false;
|
||||
this.#rate = DEFAULT_RATE_MS;
|
||||
|
||||
const routeMessageToHandler = this.#routeMessageToHandler.bind(this);
|
||||
this.#worker.addEventListener('message', routeMessageToHandler);
|
||||
openmct.on(
|
||||
'destroy',
|
||||
() => {
|
||||
this.disconnect();
|
||||
URL.revokeObjectURL(workerUrl);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Will establish a WebSocket connection to the provided url
|
||||
* @param {string} url The URL to connect to
|
||||
*/
|
||||
connect(url) {
|
||||
this.#worker.postMessage({
|
||||
type: 'connect',
|
||||
url
|
||||
});
|
||||
|
||||
this.#readyForNextBatch();
|
||||
}
|
||||
|
||||
#readyForNextBatch() {
|
||||
this.#worker.postMessage({
|
||||
type: 'readyForNextBatch'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the WebSocket.
|
||||
* @param {any} message The message to send. Can be any type supported by WebSockets.
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send#data
|
||||
*/
|
||||
sendMessage(message) {
|
||||
this.#worker.postMessage({
|
||||
type: 'message',
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the strategy used to both decide which raw messages to batch, and how to group
|
||||
* them.
|
||||
* @param {BatchingStrategy} strategy The batching strategy to use when evaluating
|
||||
* raw messages from the WebSocket.
|
||||
*/
|
||||
setBatchingStrategy(strategy) {
|
||||
const serializedStrategy = {
|
||||
shouldBatchMessage: strategy.shouldBatchMessage.toString(),
|
||||
getBatchIdFromMessage: strategy.getBatchIdFromMessage.toString()
|
||||
};
|
||||
|
||||
this.#worker.postMessage({
|
||||
type: 'setBatchingStrategy',
|
||||
serializedStrategy
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When using batching, sets the rate at which batches of messages are released.
|
||||
* @param {Number} rate the amount of time to wait, in ms, between batches.
|
||||
*/
|
||||
setRate(rate) {
|
||||
this.#rate = rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
* point where Open MCT cannot keep up with the amount of telemetry it is receiving.
|
||||
* In this event it will sacrifice the oldest telemetry in the batch in favor of the
|
||||
* most recent telemetry. The user will be informed that telemetry has been dropped.
|
||||
*
|
||||
* This should be set appropriately for the expected data rate. eg. If telemetry
|
||||
* is received at 10Hz for each telemetry point, then a minimal combination of batch
|
||||
* size and rate is 10 and 1000 respectively. Ideally you would add some margin, so
|
||||
* 15 would probably be a better batch size.
|
||||
*/
|
||||
setMaxBatchSize(maxBatchSize) {
|
||||
this.#worker.postMessage({
|
||||
type: 'setMaxBatchSize',
|
||||
maxBatchSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the associated WebSocket. Generally speaking there is no need to call
|
||||
* this manually.
|
||||
*/
|
||||
disconnect() {
|
||||
this.#worker.postMessage({
|
||||
type: 'disconnect'
|
||||
});
|
||||
}
|
||||
|
||||
#routeMessageToHandler(message) {
|
||||
if (message.data.type === 'batch') {
|
||||
if (message.data.batch.dropped === true && !this.#showingRateLimitNotification) {
|
||||
const notification = this.#openmct.notifications.alert(
|
||||
'Telemetry dropped due to client rate limiting.',
|
||||
{ hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' }
|
||||
);
|
||||
this.#showingRateLimitNotification = true;
|
||||
notification.once('minimized', () => {
|
||||
this.#showingRateLimitNotification = false;
|
||||
});
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('batch', { detail: message.data.batch }));
|
||||
setTimeout(() => {
|
||||
this.#readyForNextBatch();
|
||||
}, this.#rate);
|
||||
} else if (message.data.type === 'message') {
|
||||
this.dispatchEvent(new CustomEvent('message', { detail: message.data.message }));
|
||||
} else {
|
||||
throw new Error(`Unknown message type: ${message.data.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BatchingWebSocket;
|
@ -23,6 +23,7 @@
|
||||
import objectUtils from 'objectUtils';
|
||||
|
||||
import CustomStringFormatter from '../../plugins/displayLayout/CustomStringFormatter.js';
|
||||
import BatchingWebSocket from './BatchingWebSocket.js';
|
||||
import DefaultMetadataProvider from './DefaultMetadataProvider.js';
|
||||
import TelemetryCollection from './TelemetryCollection.js';
|
||||
import TelemetryMetadataManager from './TelemetryMetadataManager.js';
|
||||
@ -54,6 +55,28 @@ import TelemetryValueFormatter from './TelemetryValueFormatter.js';
|
||||
* @memberof module:openmct.TelemetryAPI~
|
||||
*/
|
||||
|
||||
/**
|
||||
* Describes and bounds requests for telemetry data.
|
||||
*
|
||||
* @typedef TelemetrySubscriptionOptions
|
||||
* @property {String} [strategy] symbolic identifier directing providers on how
|
||||
* to handle telemetry subscriptions. The default behavior is 'latest' which will
|
||||
* always return a single telemetry value with each callback, and in the event
|
||||
* of throttling will always prioritize the latest data, meaning intermediate
|
||||
* data will be skipped. Alternatively, the `batch` strategy can be used, which
|
||||
* will return all telemetry values since the last callback. This strategy is
|
||||
* useful for cases where intermediate data is important, such as when
|
||||
* rendering a telemetry plot or table. If `batch` is specified, the subscription
|
||||
* callback will be invoked with an Array.
|
||||
*
|
||||
* @memberof module:openmct.TelemetryAPI~
|
||||
*/
|
||||
|
||||
const SUBSCRIBE_STRATEGY = {
|
||||
LATEST: 'latest',
|
||||
BATCH: 'batch'
|
||||
};
|
||||
|
||||
/**
|
||||
* Utilities for telemetry
|
||||
* @interface TelemetryAPI
|
||||
@ -61,6 +84,11 @@ import TelemetryValueFormatter from './TelemetryValueFormatter.js';
|
||||
*/
|
||||
export default class TelemetryAPI {
|
||||
#isGreedyLAD;
|
||||
#subscribeCache;
|
||||
|
||||
get SUBSCRIBE_STRATEGY() {
|
||||
return SUBSCRIBE_STRATEGY;
|
||||
}
|
||||
|
||||
constructor(openmct) {
|
||||
this.openmct = openmct;
|
||||
@ -78,6 +106,17 @@ export default class TelemetryAPI {
|
||||
this.valueFormatterCache = new WeakMap();
|
||||
this.requestInterceptorRegistry = new TelemetryRequestInterceptorRegistry();
|
||||
this.#isGreedyLAD = true;
|
||||
this.BatchingWebSocket = BatchingWebSocket;
|
||||
this.#subscribeCache = {};
|
||||
this.itemsGarbageCollected = 0;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
this.registry = new FinalizationRegistry((heldValue) => {
|
||||
this.itemsGarbageCollected++;
|
||||
console.debug(
|
||||
`🗑️ 🛜 TELEMETRY API garbage collected: ${this.itemsGarbageCollected} - ${JSON.stringify(heldValue)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
abortAllRequests() {
|
||||
@ -353,6 +392,10 @@ export default class TelemetryAPI {
|
||||
arguments[1] = await this.applyRequestInterceptors(domainObject, arguments[1]);
|
||||
try {
|
||||
const telemetry = await provider.request(...arguments);
|
||||
// add each piece of data individually to be registry
|
||||
telemetry.forEach((datum) => {
|
||||
this.registry.register(datum, `${new Date()} Data with: ${JSON.stringify(datum)}`);
|
||||
});
|
||||
|
||||
return telemetry;
|
||||
} catch (error) {
|
||||
@ -378,54 +421,116 @@ export default class TelemetryAPI {
|
||||
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
|
||||
* @param {module:openmct.DomainObject} domainObject the object
|
||||
* which has associated telemetry
|
||||
* @param {TelemetryRequestOptions} options configuration items for subscription
|
||||
* @param {TelemetrySubscriptionOptions} options configuration items for subscription
|
||||
* @param {Function} callback the callback to invoke with new data, as
|
||||
* it becomes available
|
||||
* @returns {Function} a function which may be called to terminate
|
||||
* the subscription
|
||||
*/
|
||||
subscribe(domainObject, callback, options) {
|
||||
subscribe(domainObject, callback, options = { strategy: SUBSCRIBE_STRATEGY.LATEST }) {
|
||||
const requestedStrategy = options.strategy || SUBSCRIBE_STRATEGY.LATEST;
|
||||
|
||||
if (domainObject.type === 'unknown') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const provider = this.findSubscriptionProvider(domainObject);
|
||||
const provider = this.findSubscriptionProvider(domainObject, options);
|
||||
const supportsBatching =
|
||||
Boolean(provider?.supportsBatching) && provider?.supportsBatching(domainObject, options);
|
||||
|
||||
if (!this.subscribeCache) {
|
||||
this.subscribeCache = {};
|
||||
if (!this.#subscribeCache) {
|
||||
this.#subscribeCache = {};
|
||||
}
|
||||
|
||||
const keyString = objectUtils.makeKeyString(domainObject.identifier);
|
||||
let subscriber = this.subscribeCache[keyString];
|
||||
const supportedStrategy = supportsBatching ? requestedStrategy : SUBSCRIBE_STRATEGY.LATEST;
|
||||
// Override the requested strategy with the strategy supported by the provider
|
||||
const optionsWithSupportedStrategy = {
|
||||
...options,
|
||||
strategy: supportedStrategy
|
||||
};
|
||||
// If batching is supported, we need to cache a subscription for each strategy -
|
||||
// latest and batched.
|
||||
const cacheKey = `${keyString}:${supportedStrategy}`;
|
||||
let subscriber = this.#subscribeCache[cacheKey];
|
||||
|
||||
if (!subscriber) {
|
||||
subscriber = this.subscribeCache[keyString] = {
|
||||
callbacks: [callback]
|
||||
subscriber = this.#subscribeCache[cacheKey] = {
|
||||
latestCallbacks: [],
|
||||
batchCallbacks: []
|
||||
};
|
||||
if (provider) {
|
||||
subscriber.unsubscribe = provider.subscribe(
|
||||
domainObject,
|
||||
function (value) {
|
||||
subscriber.callbacks.forEach(function (cb) {
|
||||
cb(value);
|
||||
});
|
||||
},
|
||||
options
|
||||
invokeCallbackWithRequestedStrategy,
|
||||
optionsWithSupportedStrategy
|
||||
);
|
||||
} else {
|
||||
subscriber.unsubscribe = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedStrategy === SUBSCRIBE_STRATEGY.BATCH) {
|
||||
subscriber.batchCallbacks.push(callback);
|
||||
} else {
|
||||
subscriber.callbacks.push(callback);
|
||||
subscriber.latestCallbacks.push(callback);
|
||||
}
|
||||
const telemetryAPI = this;
|
||||
// Guarantees that view receive telemetry in the expected form
|
||||
function invokeCallbackWithRequestedStrategy(data) {
|
||||
// add each piece of data individually to be registry
|
||||
data.forEach((datum) => {
|
||||
const heldValue = `${new Date()} Data with: ${JSON.stringify(datum)}`;
|
||||
telemetryAPI.registry.register(datum, heldValue);
|
||||
});
|
||||
invokeCallbacksWithArray(data, subscriber.batchCallbacks);
|
||||
invokeCallbacksWithSingleValue(data, subscriber.latestCallbacks);
|
||||
}
|
||||
|
||||
function invokeCallbacksWithArray(data, batchCallbacks) {
|
||||
//
|
||||
if (data === undefined || data === null || data.length === 0) {
|
||||
throw new Error(
|
||||
'Attempt to invoke telemetry subscription callback with no telemetry datum'
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
data = [data];
|
||||
}
|
||||
|
||||
batchCallbacks.forEach((cb) => {
|
||||
cb(data);
|
||||
});
|
||||
}
|
||||
|
||||
function invokeCallbacksWithSingleValue(data, latestCallbacks) {
|
||||
if (Array.isArray(data)) {
|
||||
data = data[data.length - 1];
|
||||
}
|
||||
|
||||
if (data === undefined || data === null) {
|
||||
throw new Error(
|
||||
'Attempt to invoke telemetry subscription callback with no telemetry datum'
|
||||
);
|
||||
}
|
||||
|
||||
latestCallbacks.forEach((cb) => {
|
||||
cb(data);
|
||||
});
|
||||
}
|
||||
|
||||
return function unsubscribe() {
|
||||
subscriber.callbacks = subscriber.callbacks.filter(function (cb) {
|
||||
subscriber.latestCallbacks = subscriber.latestCallbacks.filter(function (cb) {
|
||||
return cb !== callback;
|
||||
});
|
||||
if (subscriber.callbacks.length === 0) {
|
||||
subscriber.batchCallbacks = subscriber.batchCallbacks.filter(function (cb) {
|
||||
return cb !== callback;
|
||||
});
|
||||
|
||||
if (subscriber.latestCallbacks.length === 0 && subscriber.batchCallbacks.length === 0) {
|
||||
subscriber.unsubscribe();
|
||||
delete this.subscribeCache[keyString];
|
||||
delete this.#subscribeCache[cacheKey];
|
||||
}
|
||||
}.bind(this);
|
||||
}
|
||||
|
@ -90,7 +90,9 @@ describe('Telemetry API', () => {
|
||||
|
||||
const callback = jasmine.createSpy('callback');
|
||||
const unsubscribe = telemetryAPI.subscribe(domainObject, callback);
|
||||
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject);
|
||||
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject, {
|
||||
strategy: 'latest'
|
||||
});
|
||||
expect(telemetryProvider.subscribe).not.toHaveBeenCalled();
|
||||
expect(unsubscribe).toEqual(jasmine.any(Function));
|
||||
|
||||
@ -111,12 +113,16 @@ describe('Telemetry API', () => {
|
||||
const callback = jasmine.createSpy('callback');
|
||||
const unsubscribe = telemetryAPI.subscribe(domainObject, callback);
|
||||
expect(telemetryProvider.supportsSubscribe.calls.count()).toBe(1);
|
||||
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject);
|
||||
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject, {
|
||||
strategy: 'latest'
|
||||
});
|
||||
expect(telemetryProvider.subscribe.calls.count()).toBe(1);
|
||||
expect(telemetryProvider.subscribe).toHaveBeenCalledWith(
|
||||
domainObject,
|
||||
jasmine.any(Function),
|
||||
undefined
|
||||
{
|
||||
strategy: 'latest'
|
||||
}
|
||||
);
|
||||
|
||||
const notify = telemetryProvider.subscribe.calls.mostRecent().args[1];
|
||||
@ -321,6 +327,126 @@ describe('Telemetry API', () => {
|
||||
signal
|
||||
});
|
||||
});
|
||||
describe('telemetry batching support', () => {
|
||||
let callbacks;
|
||||
let unsubFunc;
|
||||
|
||||
beforeEach(() => {
|
||||
callbacks = [];
|
||||
unsubFunc = jasmine.createSpy('unsubscribe');
|
||||
telemetryProvider.supportsBatching = jasmine.createSpy('supportsBatching');
|
||||
telemetryProvider.supportsBatching.and.returnValue(true);
|
||||
telemetryProvider.supportsSubscribe.and.returnValue(true);
|
||||
|
||||
telemetryProvider.subscribe.and.callFake(function (obj, cb, options) {
|
||||
callbacks.push(cb);
|
||||
|
||||
return unsubFunc;
|
||||
});
|
||||
|
||||
telemetryAPI.addProvider(telemetryProvider);
|
||||
});
|
||||
|
||||
it('caches subscriptions for batched and latest telemetry subscriptions', () => {
|
||||
const latestCallback1 = jasmine.createSpy('latestCallback1');
|
||||
const unsubscribeFromLatest1 = telemetryAPI.subscribe(domainObject, latestCallback1, {
|
||||
strategy: 'latest'
|
||||
});
|
||||
const latestCallback2 = jasmine.createSpy('latestCallback2');
|
||||
const unsubscribeFromLatest2 = telemetryAPI.subscribe(domainObject, latestCallback2, {
|
||||
strategy: 'latest'
|
||||
});
|
||||
|
||||
//Expect a single cached subscription for latest telemetry
|
||||
expect(telemetryProvider.subscribe.calls.count()).toBe(1);
|
||||
|
||||
const batchedCallback1 = jasmine.createSpy('batchedCallback1');
|
||||
const unsubscribeFromBatched1 = telemetryAPI.subscribe(domainObject, batchedCallback1, {
|
||||
strategy: 'batch'
|
||||
});
|
||||
|
||||
const batchedCallback2 = jasmine.createSpy('batchedCallback2');
|
||||
const unsubscribeFromBatched2 = telemetryAPI.subscribe(domainObject, batchedCallback2, {
|
||||
strategy: 'batch'
|
||||
});
|
||||
|
||||
//Expect a single cached subscription for each strategy telemetry
|
||||
expect(telemetryProvider.subscribe.calls.count()).toBe(2);
|
||||
|
||||
unsubscribeFromLatest1();
|
||||
unsubscribeFromLatest2();
|
||||
unsubscribeFromBatched1();
|
||||
unsubscribeFromBatched2();
|
||||
|
||||
expect(unsubFunc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('subscriptions with the latest strategy are always invoked with a single value', () => {
|
||||
const latestCallback = jasmine.createSpy('latestCallback1');
|
||||
telemetryAPI.subscribe(domainObject, latestCallback, {
|
||||
strategy: 'latest'
|
||||
});
|
||||
|
||||
const batchedValues = [1, 2, 3];
|
||||
callbacks.forEach((cb) => {
|
||||
cb(batchedValues);
|
||||
});
|
||||
|
||||
expect(latestCallback).toHaveBeenCalledWith(3);
|
||||
|
||||
const singleValue = 1;
|
||||
callbacks.forEach((cb) => {
|
||||
cb(singleValue);
|
||||
});
|
||||
|
||||
expect(latestCallback).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('subscriptions with the batch strategy are always invoked with an array', () => {
|
||||
const batchedCallback = jasmine.createSpy('batchedCallback1');
|
||||
const latestCallback = jasmine.createSpy('latestCallback1');
|
||||
telemetryAPI.subscribe(domainObject, batchedCallback, {
|
||||
strategy: 'batch'
|
||||
});
|
||||
telemetryAPI.subscribe(domainObject, latestCallback, {
|
||||
strategy: 'latest'
|
||||
});
|
||||
|
||||
const batchedValues = [1, 2, 3];
|
||||
callbacks.forEach((cb) => {
|
||||
cb(batchedValues);
|
||||
});
|
||||
|
||||
// Callbacks for the 'batch' strategy are always called with an array of values
|
||||
expect(batchedCallback).toHaveBeenCalledWith(batchedValues);
|
||||
// Callbacks for the 'latest' strategy are always called with a single value
|
||||
expect(latestCallback).toHaveBeenCalledWith(3);
|
||||
|
||||
callbacks.forEach((cb) => {
|
||||
cb(1);
|
||||
});
|
||||
// Callbacks for the 'batch' strategy are always called with an array of values, even if there is only one value
|
||||
expect(batchedCallback).toHaveBeenCalledWith([1]);
|
||||
// Callbacks for the 'latest' strategy are always called with a single value
|
||||
expect(latestCallback).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('legacy providers are left unchanged, with a single subscription', () => {
|
||||
delete telemetryProvider.supportsBatching;
|
||||
|
||||
const batchCallback = jasmine.createSpy('batchCallback');
|
||||
telemetryAPI.subscribe(domainObject, batchCallback, {
|
||||
strategy: 'batch'
|
||||
});
|
||||
expect(telemetryProvider.subscribe.calls.mostRecent().args[2].strategy).toBe('latest');
|
||||
|
||||
const latestCallback = jasmine.createSpy('latestCallback');
|
||||
telemetryAPI.subscribe(domainObject, latestCallback, {
|
||||
strategy: 'latest'
|
||||
});
|
||||
|
||||
expect(telemetryProvider.subscribe.calls.mostRecent().args[2].strategy).toBe('latest');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata', () => {
|
||||
|
@ -180,11 +180,14 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
const options = { ...this.options };
|
||||
//We always want to receive all available values in telemetry tables.
|
||||
options.strategy = this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH;
|
||||
|
||||
this.unsubscribe = this.openmct.telemetry.subscribe(
|
||||
this.domainObject,
|
||||
(datum) => this._processNewTelemetry(datum),
|
||||
this.options
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@ -209,6 +212,8 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
let added = [];
|
||||
let addedIndices = [];
|
||||
let hasDataBeforeStartBound = false;
|
||||
let size = this.options.size;
|
||||
let enforceSize = size !== undefined && this.options.enforceSize;
|
||||
|
||||
// loop through, sort and dedupe
|
||||
for (let datum of data) {
|
||||
@ -271,6 +276,13 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
}
|
||||
} else {
|
||||
this.emit('add', added, addedIndices);
|
||||
|
||||
if (enforceSize && this.boundedTelemetry.length > size) {
|
||||
const removeCount = this.boundedTelemetry.length - size;
|
||||
const removed = this.boundedTelemetry.splice(0, removeCount);
|
||||
|
||||
this.emit('remove', removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
366
src/api/telemetry/WebSocketWorker.js
Normal file
366
src/api/telemetry/WebSocketWorker.js
Normal file
@ -0,0 +1,366 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT Web, Copyright (c) 2014-2024, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT Web 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 Web 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.
|
||||
*****************************************************************************/
|
||||
/* eslint-disable max-classes-per-file */
|
||||
export default function installWorker() {
|
||||
const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000];
|
||||
|
||||
/**
|
||||
* @typedef {import('./BatchingWebSocket').BatchingStrategy} BatchingStrategy
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides a WebSocket connection that is resilient to errors and dropouts.
|
||||
* On an error or dropout, will automatically reconnect.
|
||||
*
|
||||
* Additionally, messages will be queued and sent only when WebSocket is
|
||||
* connected meaning that client code does not need to check the state of
|
||||
* the socket before sending.
|
||||
*/
|
||||
class ResilientWebSocket extends EventTarget {
|
||||
#webSocket;
|
||||
#isConnected = false;
|
||||
#isConnecting = false;
|
||||
#messageQueue = [];
|
||||
#reconnectTimeoutHandle;
|
||||
#currentWaitIndex = 0;
|
||||
#messageCallbacks = [];
|
||||
#wsUrl;
|
||||
|
||||
/**
|
||||
* Establish a new WebSocket connection to the given URL
|
||||
* @param {String} url
|
||||
*/
|
||||
connect(url) {
|
||||
this.#wsUrl = url;
|
||||
if (this.#isConnected) {
|
||||
throw new Error('WebSocket already connected');
|
||||
}
|
||||
|
||||
if (this.#isConnecting) {
|
||||
throw new Error('WebSocket connection in progress');
|
||||
}
|
||||
|
||||
this.#isConnecting = true;
|
||||
|
||||
this.#webSocket = new WebSocket(url);
|
||||
|
||||
const boundConnected = this.#connected.bind(this);
|
||||
this.#webSocket.addEventListener('open', boundConnected);
|
||||
|
||||
const boundCleanUpAndReconnect = this.#cleanUpAndReconnect.bind(this);
|
||||
this.#webSocket.addEventListener('error', boundCleanUpAndReconnect);
|
||||
this.#webSocket.addEventListener('close', boundCleanUpAndReconnect);
|
||||
|
||||
const boundMessage = this.#message.bind(this);
|
||||
this.#webSocket.addEventListener('message', boundMessage);
|
||||
|
||||
this.addEventListener(
|
||||
'disconnected',
|
||||
() => {
|
||||
this.#webSocket.removeEventListener('open', boundConnected);
|
||||
this.#webSocket.removeEventListener('error', boundCleanUpAndReconnect);
|
||||
this.#webSocket.removeEventListener('close', boundCleanUpAndReconnect);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be invoked when a message is received on the WebSocket.
|
||||
* This paradigm is used instead of the standard EventTarget or EventEmitter approach
|
||||
* for performance reasons.
|
||||
* @param {Function} callback The function to be invoked when a message is received
|
||||
* @returns an unregister function
|
||||
*/
|
||||
registerMessageCallback(callback) {
|
||||
this.#messageCallbacks.push(callback);
|
||||
|
||||
return () => {
|
||||
this.#messageCallbacks = this.#messageCallbacks.filter((cb) => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
#connected() {
|
||||
console.debug('Websocket connected.');
|
||||
this.#isConnected = true;
|
||||
this.#isConnecting = false;
|
||||
this.#currentWaitIndex = 0;
|
||||
|
||||
this.dispatchEvent(new Event('connected'));
|
||||
|
||||
this.#flushQueue();
|
||||
}
|
||||
|
||||
#cleanUpAndReconnect() {
|
||||
console.warn('Websocket closed. Attempting to reconnect...');
|
||||
this.disconnect();
|
||||
this.#reconnect();
|
||||
}
|
||||
|
||||
#message(event) {
|
||||
this.#messageCallbacks.forEach((callback) => callback(event.data));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#isConnected = false;
|
||||
this.#isConnecting = false;
|
||||
|
||||
// On WebSocket error, both error callback and close callback are invoked, resulting in
|
||||
// this function being called twice, and websocket being destroyed and deallocated.
|
||||
if (this.#webSocket !== undefined && this.#webSocket !== null) {
|
||||
this.#webSocket.close();
|
||||
}
|
||||
|
||||
this.dispatchEvent(new Event('disconnected'));
|
||||
this.#webSocket = undefined;
|
||||
}
|
||||
|
||||
#reconnect() {
|
||||
if (this.#reconnectTimeoutHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#reconnectTimeoutHandle = setTimeout(() => {
|
||||
this.connect(this.#wsUrl);
|
||||
|
||||
this.#reconnectTimeoutHandle = undefined;
|
||||
}, FALLBACK_AND_WAIT_MS[this.#currentWaitIndex]);
|
||||
|
||||
if (this.#currentWaitIndex < FALLBACK_AND_WAIT_MS.length - 1) {
|
||||
this.#currentWaitIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
enqueueMessage(message) {
|
||||
this.#messageQueue.push(message);
|
||||
this.#flushQueueIfReady();
|
||||
}
|
||||
|
||||
#flushQueueIfReady() {
|
||||
if (this.#isConnected) {
|
||||
this.#flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
#flushQueue() {
|
||||
while (this.#messageQueue.length > 0) {
|
||||
if (!this.#isConnected) {
|
||||
break;
|
||||
}
|
||||
|
||||
const message = this.#messageQueue.shift();
|
||||
this.#webSocket.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles messages over the worker interface, and
|
||||
* sends corresponding WebSocket messages.
|
||||
*/
|
||||
class WorkerToWebSocketMessageBroker {
|
||||
#websocket;
|
||||
#messageBatcher;
|
||||
|
||||
constructor(websocket, messageBatcher) {
|
||||
this.#websocket = websocket;
|
||||
this.#messageBatcher = messageBatcher;
|
||||
}
|
||||
|
||||
routeMessageToHandler(message) {
|
||||
const { type } = message.data;
|
||||
switch (type) {
|
||||
case 'connect':
|
||||
this.connect(message);
|
||||
break;
|
||||
case 'disconnect':
|
||||
this.disconnect(message);
|
||||
break;
|
||||
case 'message':
|
||||
this.#websocket.enqueueMessage(message.data.message);
|
||||
break;
|
||||
case 'setBatchingStrategy':
|
||||
this.setBatchingStrategy(message);
|
||||
break;
|
||||
case 'readyForNextBatch':
|
||||
this.#messageBatcher.readyForNextBatch();
|
||||
break;
|
||||
case 'setMaxBatchSize':
|
||||
this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${type}`);
|
||||
}
|
||||
}
|
||||
connect(message) {
|
||||
const { url } = message.data;
|
||||
this.#websocket.connect(url);
|
||||
}
|
||||
disconnect() {
|
||||
this.#websocket.disconnect();
|
||||
}
|
||||
setBatchingStrategy(message) {
|
||||
const { serializedStrategy } = message.data;
|
||||
const batchingStrategy = {
|
||||
// eslint-disable-next-line no-new-func
|
||||
shouldBatchMessage: new Function(`return ${serializedStrategy.shouldBatchMessage}`)(),
|
||||
// eslint-disable-next-line no-new-func
|
||||
getBatchIdFromMessage: new Function(`return ${serializedStrategy.getBatchIdFromMessage}`)()
|
||||
// Will also include maximum batch length here
|
||||
};
|
||||
this.#messageBatcher.setBatchingStrategy(batchingStrategy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Received messages from the WebSocket, and passes them along to the
|
||||
* Worker interface and back to the main thread.
|
||||
*/
|
||||
class WebSocketToWorkerMessageBroker {
|
||||
#worker;
|
||||
#messageBatcher;
|
||||
|
||||
constructor(messageBatcher, worker) {
|
||||
this.#messageBatcher = messageBatcher;
|
||||
this.#worker = worker;
|
||||
}
|
||||
|
||||
routeMessageToHandler(data) {
|
||||
//Implement batching here
|
||||
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;
|
||||
#worker;
|
||||
|
||||
constructor(worker) {
|
||||
this.#maxBatchSize = 10;
|
||||
this.#readyForNextBatch = false;
|
||||
this.#worker = worker;
|
||||
this.#resetBatch();
|
||||
}
|
||||
#resetBatch() {
|
||||
this.#batch = {};
|
||||
this.#hasBatch = false;
|
||||
}
|
||||
/**
|
||||
* @param {BatchingStrategy} strategy
|
||||
*/
|
||||
setBatchingStrategy(strategy) {
|
||||
this.#batchingStrategy = strategy;
|
||||
}
|
||||
/**
|
||||
* Applies the `shouldBatchMessage` function from the supplied batching strategy
|
||||
* to each message to determine if it should be added to a batch. If not batched,
|
||||
* the message is immediately sent over the worker to the main thread.
|
||||
* @param {any} message the message received from the WebSocket. See the WebSocket
|
||||
* documentation for more details -
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
|
||||
* @returns
|
||||
*/
|
||||
shouldBatchMessage(message) {
|
||||
return (
|
||||
this.#batchingStrategy.shouldBatchMessage &&
|
||||
this.#batchingStrategy.shouldBatchMessage(message)
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Adds the given message to a batch. The batch group that the message is added
|
||||
* to will be determined by the value returned by `getBatchIdFromMessage`.
|
||||
* @param {any} message the message received from the WebSocket. See the WebSocket
|
||||
* documentation for more details -
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
|
||||
*/
|
||||
addMessageToBatch(message) {
|
||||
const batchId = this.#batchingStrategy.getBatchIdFromMessage(message);
|
||||
let batch = this.#batch[batchId];
|
||||
if (batch === undefined) {
|
||||
batch = this.#batch[batchId] = [message];
|
||||
} else {
|
||||
batch.push(message);
|
||||
}
|
||||
if (batch.length > this.#maxBatchSize) {
|
||||
batch.shift();
|
||||
this.#batch.dropped = this.#batch.dropped || true;
|
||||
}
|
||||
if (this.#readyForNextBatch) {
|
||||
this.#sendNextBatch();
|
||||
} else {
|
||||
this.#hasBatch = true;
|
||||
}
|
||||
}
|
||||
setMaxBatchSize(maxBatchSize) {
|
||||
this.#maxBatchSize = maxBatchSize;
|
||||
}
|
||||
/**
|
||||
* Indicates that client code is ready to receive the next batch of
|
||||
* messages. If a batch is available, it will be immediately sent.
|
||||
* Otherwise a flag will be set to send the next batch as soon as
|
||||
* any new data is available.
|
||||
*/
|
||||
readyForNextBatch() {
|
||||
if (this.#hasBatch) {
|
||||
this.#sendNextBatch();
|
||||
} else {
|
||||
this.#readyForNextBatch = true;
|
||||
}
|
||||
}
|
||||
#sendNextBatch() {
|
||||
const batch = this.#batch;
|
||||
this.#resetBatch();
|
||||
this.#worker.postMessage({
|
||||
type: 'batch',
|
||||
batch
|
||||
});
|
||||
this.#readyForNextBatch = false;
|
||||
this.#hasBatch = false;
|
||||
}
|
||||
}
|
||||
|
||||
const websocket = new ResilientWebSocket();
|
||||
const messageBatcher = new MessageBatcher(self);
|
||||
const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher);
|
||||
const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self);
|
||||
|
||||
self.addEventListener('message', (message) => {
|
||||
workerBroker.routeMessageToHandler(message);
|
||||
});
|
||||
websocket.registerMessageCallback((data) => {
|
||||
websocketBroker.routeMessageToHandler(data);
|
||||
});
|
||||
}
|
@ -32,6 +32,7 @@ export default class StatusAPI extends EventEmitter {
|
||||
|
||||
this.onProviderStatusChange = this.onProviderStatusChange.bind(this);
|
||||
this.onProviderPollQuestionChange = this.onProviderPollQuestionChange.bind(this);
|
||||
this.onMissionActionStatusChange = this.onMissionActionStatusChange.bind(this);
|
||||
this.listenToStatusEvents = this.listenToStatusEvents.bind(this);
|
||||
|
||||
this.#openmct.once('destroy', () => {
|
||||
@ -40,6 +41,7 @@ export default class StatusAPI extends EventEmitter {
|
||||
if (typeof provider?.off === 'function') {
|
||||
provider.off('statusChange', this.onProviderStatusChange);
|
||||
provider.off('pollQuestionChange', this.onProviderPollQuestionChange);
|
||||
provider.off('missionActionStatusChange', this.onMissionActionStatusChange);
|
||||
}
|
||||
});
|
||||
|
||||
@ -100,6 +102,67 @@ export default class StatusAPI extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the currently logged in user set the mission status.
|
||||
* @returns {Promise<Boolean>} true if the currently logged in user can set the mission status, false otherwise.
|
||||
*/
|
||||
canSetMissionStatus() {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.canSetMissionStatus) {
|
||||
return provider.canSetMissionStatus();
|
||||
} else {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current status for the given mission action
|
||||
* @param {MissionAction} action
|
||||
* @returns {string}
|
||||
*/
|
||||
getStatusForMissionAction(action) {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.getStatusForMissionAction) {
|
||||
return provider.getStatusForMissionAction(action);
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support getting mission action status');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of possible mission status options (GO, NO-GO, etc.)
|
||||
* @returns {Promise<MissionStatusOption[]>} the complete list of possible mission statuses
|
||||
*/
|
||||
async getPossibleMissionActionStatuses() {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.getPossibleMissionActionStatuses) {
|
||||
const possibleOptions = await provider.getPossibleMissionActionStatuses();
|
||||
|
||||
return possibleOptions;
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support mission status options');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of possible mission actions
|
||||
* @returns {Promise<string[]>} the list of possible mission actions
|
||||
*/
|
||||
async getPossibleMissionActions() {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.getPossibleMissionActions) {
|
||||
const possibleActions = await provider.getPossibleMissionActions();
|
||||
|
||||
return possibleActions;
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support mission statuses');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Array<Status>>} the complete list of possible states that an operator can reply to a poll question with.
|
||||
*/
|
||||
@ -166,6 +229,21 @@ export default class StatusAPI extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MissionAction} action
|
||||
* @param {MissionStatusOption} status
|
||||
* @returns {Promise<Boolean>} true if operation was successful, otherwise false.
|
||||
*/
|
||||
setStatusForMissionAction(action, status) {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.setStatusForMissionAction) {
|
||||
return provider.setStatusForMissionAction(action, status);
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support setting mission role status');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the status of the provided role back to its default status.
|
||||
* @param {import("./UserAPI").Role} role The role to set the status for.
|
||||
@ -245,6 +323,7 @@ export default class StatusAPI extends EventEmitter {
|
||||
if (typeof provider.on === 'function') {
|
||||
provider.on('statusChange', this.onProviderStatusChange);
|
||||
provider.on('pollQuestionChange', this.onProviderPollQuestionChange);
|
||||
provider.on('missionActionStatusChange', this.onMissionActionStatusChange);
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,14 +340,23 @@ export default class StatusAPI extends EventEmitter {
|
||||
onProviderPollQuestionChange(pollQuestion) {
|
||||
this.emit('pollQuestionChange', pollQuestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
onMissionActionStatusChange({ action, status }) {
|
||||
this.emit('missionActionStatusChange', { action, status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import('./UserProvider')} UserProvider
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./StatusUserProvider')} StatusUserProvider
|
||||
*/
|
||||
|
||||
/**
|
||||
* The PollQuestion type
|
||||
* @typedef {Object} PollQuestion
|
||||
@ -276,6 +364,19 @@ export default class StatusAPI extends EventEmitter {
|
||||
* @property {Number} timestamp - The time that the poll question was set.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The MissionStatus type
|
||||
* @typedef {Object} MissionStatusOption
|
||||
* @extends {Status}
|
||||
* @property {String} color A color to be used when displaying the mission status
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MissionAction
|
||||
* @property {String} key A unique identifier for this action
|
||||
* @property {String} label A human readable label for this action
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Status type
|
||||
* @typedef {Object} Status
|
||||
|
@ -23,12 +23,12 @@ import UserProvider from './UserProvider.js';
|
||||
|
||||
export default class StatusUserProvider extends UserProvider {
|
||||
/**
|
||||
* @param {('statusChange'|'pollQuestionChange')} event the name of the event to listen to
|
||||
* @param {('statusChange'|'pollQuestionChange'|'missionActionStatusChange')} event the name of the event to listen to
|
||||
* @param {Function} callback a function to invoke when this event occurs
|
||||
*/
|
||||
on(event, callback) {}
|
||||
/**
|
||||
* @param {('statusChange'|'pollQuestionChange')} event the name of the event to stop listen to
|
||||
* @param {('statusChange'|'pollQuestionChange'|'missionActionStatusChange')} event the name of the event to stop listen to
|
||||
* @param {Function} callback the callback function used to register the listener
|
||||
*/
|
||||
off(event, callback) {}
|
||||
|
@ -24,9 +24,6 @@ import ExampleUserProvider from '../../../example/exampleUser/ExampleUserProvide
|
||||
import { createOpenMct, resetApplicationState } from '../../utils/testing.js';
|
||||
import { MULTIPLE_PROVIDER_ERROR } from './constants.js';
|
||||
|
||||
const USERNAME = 'Test User';
|
||||
const EXAMPLE_ROLE = 'flight';
|
||||
|
||||
describe('The User API', () => {
|
||||
let openmct;
|
||||
|
||||
@ -65,48 +62,4 @@ describe('The User API', () => {
|
||||
expect(openmct.user.hasProvider()).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provides the ability', () => {
|
||||
let provider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ExampleUserProvider(openmct);
|
||||
provider.autoLogin(USERNAME);
|
||||
});
|
||||
|
||||
it('to check if a user (not specific) is logged in', (done) => {
|
||||
expect(openmct.user.isLoggedIn()).toBeFalse();
|
||||
|
||||
openmct.user.on('providerAdded', () => {
|
||||
expect(openmct.user.isLoggedIn()).toBeTrue();
|
||||
done();
|
||||
});
|
||||
|
||||
// this will trigger the user indicator plugin,
|
||||
// which will in turn login the user
|
||||
openmct.user.setProvider(provider);
|
||||
});
|
||||
|
||||
it('to get the current user', (done) => {
|
||||
openmct.user.setProvider(provider);
|
||||
openmct.user
|
||||
.getCurrentUser()
|
||||
.then((apiUser) => {
|
||||
expect(apiUser.name).toEqual(USERNAME);
|
||||
})
|
||||
.finally(done);
|
||||
});
|
||||
|
||||
it('to check if a user has a specific role (by id)', (done) => {
|
||||
openmct.user.setProvider(provider);
|
||||
let junkIdCheckPromise = openmct.user.hasRole('junk-id').then((hasRole) => {
|
||||
expect(hasRole).toBeFalse();
|
||||
});
|
||||
let realIdCheckPromise = openmct.user.hasRole(EXAMPLE_ROLE).then((hasRole) => {
|
||||
expect(hasRole).toBeTrue();
|
||||
});
|
||||
|
||||
Promise.all([junkIdCheckPromise, realIdCheckPromise]).finally(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -38,7 +38,6 @@ describe('the plugin', function () {
|
||||
|
||||
let couchPlugin = openmct.plugins.CouchDB(testPath);
|
||||
openmct.install(couchPlugin);
|
||||
|
||||
openmct.install(
|
||||
new CouchDBSearchFolderPlugin('CouchDB Documents', couchPlugin, {
|
||||
selector: {
|
||||
|
@ -46,14 +46,14 @@ describe('DeviceMatchers', function () {
|
||||
return 'is' + deviceType[0].toUpperCase() + deviceType.slice(1);
|
||||
}
|
||||
|
||||
['mobile', 'phone', 'tablet', 'landscape', 'portrait', 'landscape', 'touch'].forEach(function (
|
||||
deviceType
|
||||
) {
|
||||
it('detects when a device is a ' + deviceType + ' device', function () {
|
||||
mockAgent[method(deviceType)].and.returnValue(true);
|
||||
expect(DeviceMatchers[deviceType](mockAgent)).toBe(true);
|
||||
mockAgent[method(deviceType)].and.returnValue(false);
|
||||
expect(DeviceMatchers[deviceType](mockAgent)).toBe(false);
|
||||
});
|
||||
});
|
||||
['mobile', 'phone', 'tablet', 'landscape', 'portrait', 'landscape', 'touch'].forEach(
|
||||
function (deviceType) {
|
||||
it('detects when a device is a ' + deviceType + ' device', function () {
|
||||
mockAgent[method(deviceType)].and.returnValue(true);
|
||||
expect(DeviceMatchers[deviceType](mockAgent)).toBe(true);
|
||||
mockAgent[method(deviceType)].and.returnValue(false);
|
||||
expect(DeviceMatchers[deviceType](mockAgent)).toBe(false);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -79,5 +79,6 @@ export default class LADTableView {
|
||||
if (this._destroy) {
|
||||
this._destroy();
|
||||
}
|
||||
this.component = null;
|
||||
}
|
||||
}
|
||||
|
68
src/plugins/activityStates/activityStatesInterceptor.js
Normal file
68
src/plugins/activityStates/activityStatesInterceptor.js
Normal file
@ -0,0 +1,68 @@
|
||||
/*****************************************************************************
|
||||
* 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 { ACTIVITY_STATES_KEY } from './createActivityStatesIdentifier.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} ActivityStatesInterceptorOptions
|
||||
* @property {import('../../api/objects/ObjectAPI').Identifier} identifier the {namespace, key} to use for the activity states object.
|
||||
* @property {string} name The name of the activity states model.
|
||||
* @property {number} priority the priority of the interceptor. By default, it is low.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates an activity states object in the persistence store. This is used to save plan activity states.
|
||||
* This will only get invoked when an attempt is made to save the state for an activity and no activity states object exists in the store.
|
||||
* @param {import('../../../openmct').OpenMCT} openmct
|
||||
* @param {ActivityStatesInterceptorOptions} options
|
||||
* @returns {object}
|
||||
*/
|
||||
const ACTIVITY_STATES_TYPE = 'activity-states';
|
||||
|
||||
function activityStatesInterceptor(openmct, options) {
|
||||
const { identifier, name, priority = openmct.priority.LOW } = options;
|
||||
const activityStatesModel = {
|
||||
identifier,
|
||||
name,
|
||||
type: ACTIVITY_STATES_TYPE,
|
||||
activities: {},
|
||||
location: null
|
||||
};
|
||||
|
||||
return {
|
||||
appliesTo: (identifierObject) => {
|
||||
return identifierObject.key === ACTIVITY_STATES_KEY;
|
||||
},
|
||||
invoke: (identifierObject, object) => {
|
||||
if (!object || openmct.objects.isMissing(object)) {
|
||||
openmct.objects.save(activityStatesModel);
|
||||
|
||||
return activityStatesModel;
|
||||
}
|
||||
|
||||
return object;
|
||||
},
|
||||
priority
|
||||
};
|
||||
}
|
||||
|
||||
export default activityStatesInterceptor;
|
30
src/plugins/activityStates/createActivityStatesIdentifier.js
Normal file
30
src/plugins/activityStates/createActivityStatesIdentifier.js
Normal file
@ -0,0 +1,30 @@
|
||||
/*****************************************************************************
|
||||
* 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 const ACTIVITY_STATES_KEY = 'activity-states';
|
||||
|
||||
export function createActivityStatesIdentifier(namespace = '') {
|
||||
return {
|
||||
key: ACTIVITY_STATES_KEY,
|
||||
namespace
|
||||
};
|
||||
}
|
89
src/plugins/activityStates/pluginSpec.js
Normal file
89
src/plugins/activityStates/pluginSpec.js
Normal file
@ -0,0 +1,89 @@
|
||||
/*****************************************************************************
|
||||
* 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 { createOpenMct, resetApplicationState } from 'utils/testing';
|
||||
|
||||
import {
|
||||
ACTIVITY_STATES_KEY,
|
||||
createActivityStatesIdentifier
|
||||
} from './createActivityStatesIdentifier.js';
|
||||
|
||||
const MISSING_NAME = `Missing: ${ACTIVITY_STATES_KEY}`;
|
||||
const DEFAULT_NAME = 'Activity States';
|
||||
const activityStatesIdentifier = createActivityStatesIdentifier();
|
||||
|
||||
describe('the plugin', () => {
|
||||
let openmct;
|
||||
let missingObj = {
|
||||
identifier: activityStatesIdentifier,
|
||||
type: 'unknown',
|
||||
name: MISSING_NAME
|
||||
};
|
||||
|
||||
describe('with no arguments passed in', () => {
|
||||
beforeEach((done) => {
|
||||
openmct = createOpenMct();
|
||||
openmct.install(openmct.plugins.PlanLayout());
|
||||
|
||||
openmct.on('start', done);
|
||||
openmct.startHeadless();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
return resetApplicationState(openmct);
|
||||
});
|
||||
|
||||
it('when installed, adds "Activity States"', async () => {
|
||||
const activityStatesObject = await openmct.objects.get(activityStatesIdentifier);
|
||||
expect(activityStatesObject.name).toBe(DEFAULT_NAME);
|
||||
expect(activityStatesObject).toBeDefined();
|
||||
});
|
||||
|
||||
describe('adds an interceptor that returns a "Activity States" model for', () => {
|
||||
let activityStatesObject;
|
||||
let mockNotFoundProvider;
|
||||
let activeProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockNotFoundProvider = {
|
||||
get: () => Promise.reject(new Error('Not found')),
|
||||
create: () => Promise.resolve(missingObj),
|
||||
update: () => Promise.resolve(missingObj)
|
||||
};
|
||||
|
||||
activeProvider = mockNotFoundProvider;
|
||||
spyOn(openmct.objects, 'getProvider').and.returnValue(activeProvider);
|
||||
activityStatesObject = await openmct.objects.get(activityStatesIdentifier);
|
||||
});
|
||||
|
||||
it('missing objects', () => {
|
||||
let idsMatch = openmct.objects.areIdsEqual(
|
||||
activityStatesObject.identifier,
|
||||
activityStatesIdentifier
|
||||
);
|
||||
|
||||
expect(activityStatesObject).toBeDefined();
|
||||
expect(idsMatch).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -85,6 +85,7 @@ export default class ConditionSetViewProvider {
|
||||
if (_destroy) {
|
||||
_destroy();
|
||||
}
|
||||
component = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -84,12 +84,7 @@ import LayoutFrame from './LayoutFrame.vue';
|
||||
|
||||
const DEFAULT_TELEMETRY_DIMENSIONS = [10, 5];
|
||||
const DEFAULT_POSITION = [1, 1];
|
||||
const CONTEXT_MENU_ACTIONS = [
|
||||
'copyToClipboard',
|
||||
'copyToNotebook',
|
||||
'viewHistoricalData',
|
||||
'renderWhenVisible'
|
||||
];
|
||||
const CONTEXT_MENU_ACTIONS = ['copyToClipboard', 'copyToNotebook', 'viewHistoricalData'];
|
||||
|
||||
export default {
|
||||
makeDefinition(openmct, gridSize, domainObject, position) {
|
||||
|
@ -99,7 +99,7 @@ class DisplayLayoutView {
|
||||
destroy() {
|
||||
if (this._destroy) {
|
||||
this._destroy();
|
||||
this.component = undefined;
|
||||
this.component = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,7 @@
|
||||
at runtime from the About dialog for additional information.
|
||||
-->
|
||||
<template>
|
||||
<div class="l-grid-view" role="grid">
|
||||
<div class="l-grid-view" role="grid" :aria-label="`${domainObject.name} Grid View`">
|
||||
<grid-item
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
@ -38,6 +38,6 @@ import GridItem from './GridItem.vue';
|
||||
export default {
|
||||
components: { GridItem },
|
||||
mixins: [compositionLoader],
|
||||
inject: ['openmct']
|
||||
inject: ['openmct', 'domainObject']
|
||||
};
|
||||
</script>
|
||||
|
@ -118,7 +118,7 @@
|
||||
|
||||
&__metadata {
|
||||
color: $colorItemFgDetails;
|
||||
font-size: 0.9em;
|
||||
//font-size: 0.9em;
|
||||
|
||||
body.mobile & {
|
||||
[class*='__item-count'] {
|
||||
|
@ -1,5 +1,7 @@
|
||||
/******************************* LIST ITEM */
|
||||
.c-list-item {
|
||||
color: $colorItemFgDetails;
|
||||
|
||||
&__name__type-icon {
|
||||
color: $colorItemTreeIcon;
|
||||
}
|
||||
@ -8,12 +10,12 @@
|
||||
@include ellipsize();
|
||||
|
||||
a & {
|
||||
// .c-list-item_name a element
|
||||
color: $colorItemFg;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.c-list-item__name) {
|
||||
color: $colorItemFgDetails;
|
||||
}
|
||||
|
||||
&.is-alias {
|
||||
|
@ -145,7 +145,7 @@
|
||||
v-if="relatedTelemetry.hasRelatedTelemetry && isSpacecraftPositionFresh"
|
||||
class="c-imagery__age icon-check c-imagery--new no-animation"
|
||||
>
|
||||
POS
|
||||
ROV
|
||||
</div>
|
||||
|
||||
<!-- camera position fresh -->
|
||||
|
@ -112,7 +112,7 @@ export default {
|
||||
},
|
||||
renderPlot(plotObject) {
|
||||
const wrapper = document.createElement('div');
|
||||
const visibilityObserver = new VisibilityObserver(wrapper);
|
||||
const visibilityObserver = new VisibilityObserver(wrapper, this.openmct.element);
|
||||
|
||||
const { destroy } = mount(
|
||||
{
|
||||
|
@ -1,22 +1,23 @@
|
||||
.c-path,
|
||||
.c-location {
|
||||
// Path is two or more items, not clickable
|
||||
// Location used in Inspector, is clickable
|
||||
// Location used in Inspector and search results, is clickable
|
||||
display: flex;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
font-size: 11px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
&:after {
|
||||
// color: $colorInspectorPropName;
|
||||
// Right-pointing arrow
|
||||
color: $colorBodyFgSubtle;
|
||||
content: $glyph-icon-arrow-right;
|
||||
font-family: symbolsfont;
|
||||
font-size: 0.7em;
|
||||
margin-left: $interiorMarginSm;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -74,6 +74,10 @@ export default class LocalStorageObjectProvider {
|
||||
this.localStorage.setItem(this.spaceKey, JSON.stringify(space));
|
||||
}
|
||||
|
||||
isReadOnly() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
|
@ -1,8 +1,26 @@
|
||||
# My Items plugin
|
||||
Defines top-level folder named "My Items" to store user-created items. Enabled by default, this can be disabled in a
|
||||
read-only deployment with no user-editable objects.
|
||||
Defines top-level folder named "My Items" to store user-created items. Enabled by default, this can be disabled in a read-only deployment with no user-editable objects.
|
||||
|
||||
## Installation
|
||||
```js
|
||||
openmct.install(openmct.plugins.MyItems());
|
||||
```
|
||||
|
||||
## Options
|
||||
When installing, the plugin can take several options:
|
||||
|
||||
- `name`: The label of the root object. Defaults to "My Items"
|
||||
- Example: `'Apple Items'`
|
||||
|
||||
- `namespace`: The namespace to create the root object in. Defaults to the empty string `''`
|
||||
- Example: `'apple-namespace'`
|
||||
|
||||
- `priority`: The optional priority to install this plugin. Defaults to `openmct.priority.LOW`
|
||||
- Example: `'openmct.priority.LOW'`
|
||||
|
||||
E.g., to install with a custom name and namespace, you could use:
|
||||
|
||||
|
||||
```js
|
||||
openmct.install(openmct.plugins.MyItems('Apple Items', 'apple-namespace'));
|
||||
```
|
@ -20,9 +20,7 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import { MY_ITEMS_KEY } from './createMyItemsIdentifier.js';
|
||||
|
||||
function myItemsInterceptor(openmct, identifierObject, name) {
|
||||
function myItemsInterceptor({ openmct, identifierObject, name }) {
|
||||
const myItemsModel = {
|
||||
identifier: identifierObject,
|
||||
name,
|
||||
@ -33,7 +31,10 @@ function myItemsInterceptor(openmct, identifierObject, name) {
|
||||
|
||||
return {
|
||||
appliesTo: (identifier) => {
|
||||
return identifier.key === MY_ITEMS_KEY;
|
||||
return (
|
||||
identifier.key === myItemsModel.identifier.key &&
|
||||
identifier.namespace === myItemsModel.identifier.namespace
|
||||
);
|
||||
},
|
||||
invoke: (identifier, object) => {
|
||||
if (!object || openmct.objects.isMissing(object)) {
|
||||
|
@ -31,13 +31,13 @@ export default function MyItemsPlugin(
|
||||
priority = undefined
|
||||
) {
|
||||
return function install(openmct) {
|
||||
const identifier = createMyItemsIdentifier(namespace);
|
||||
const identifierObject = createMyItemsIdentifier(namespace);
|
||||
|
||||
if (priority === undefined) {
|
||||
priority = openmct.priority.LOW;
|
||||
}
|
||||
|
||||
openmct.objects.addGetInterceptor(myItemsInterceptor(openmct, identifier, name));
|
||||
openmct.objects.addRoot(identifier, priority);
|
||||
openmct.objects.addGetInterceptor(myItemsInterceptor({ openmct, identifierObject, name }));
|
||||
openmct.objects.addRoot(identifierObject, priority);
|
||||
};
|
||||
}
|
||||
|
@ -96,7 +96,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedPage && !selectedPage.isLocked"
|
||||
:class="{ disabled: activeTransaction }"
|
||||
:aria-disabled="activeTransaction"
|
||||
class="c-notebook__drag-area icon-plus"
|
||||
@click="newEntry(null, $event)"
|
||||
@dragover="dragOver"
|
||||
|
@ -20,11 +20,7 @@
|
||||
at runtime from the About dialog for additional information.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
:style="position"
|
||||
class="c-status-poll-panel c-status-poll-panel--operator"
|
||||
@click.stop="noop"
|
||||
>
|
||||
<div :style="position" class="c-status-poll-panel c-status-poll-panel--operator" @click.stop>
|
||||
<div class="c-status-poll-panel__section c-status-poll-panel__top">
|
||||
<div class="c-status-poll-panel__title">Status Poll</div>
|
||||
<div class="c-status-poll-panel__user-role icon-person">{{ role }}</div>
|
||||
@ -191,8 +187,7 @@ export default {
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
},
|
||||
noop() {}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
@ -80,7 +80,7 @@
|
||||
keepAliveTimer = setTimeout(self.listenForChanges, keepAliveTime);
|
||||
|
||||
if (!couchEventSource || couchEventSource.readyState === EventSource.CLOSED) {
|
||||
console.debug('⇿ Opening CouchDB change feed connection ⇿');
|
||||
console.debug(`⇿ Opening CouchDB change feed connection for ${changesFeedUrl} ⇿`);
|
||||
couchEventSource = new EventSource(changesFeedUrl);
|
||||
couchEventSource.onerror = self.onerror;
|
||||
couchEventSource.onopen = self.onopen;
|
||||
@ -88,7 +88,7 @@
|
||||
// start listening for events
|
||||
couchEventSource.addEventListener('message', self.onCouchMessage);
|
||||
connected = true;
|
||||
console.debug('⇿ Opened connection ⇿');
|
||||
console.debug(`⇿ Opened connection to ${changesFeedUrl} ⇿`);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -33,13 +33,13 @@ const HEARTBEAT = 50000;
|
||||
const ALL_DOCS = '_all_docs?include_docs=true';
|
||||
|
||||
class CouchObjectProvider {
|
||||
constructor(openmct, options, namespace, indicator) {
|
||||
options = this.#normalize(options);
|
||||
constructor({ openmct, databaseConfiguration, couchStatusIndicator }) {
|
||||
this.openmct = openmct;
|
||||
this.indicator = indicator;
|
||||
this.url = options.url;
|
||||
this.useDesignDocuments = options.useDesignDocuments;
|
||||
this.namespace = namespace;
|
||||
this.indicator = couchStatusIndicator;
|
||||
this.url = databaseConfiguration.url;
|
||||
this.readOnly = databaseConfiguration.readOnly;
|
||||
this.useDesignDocuments = databaseConfiguration.useDesignDocuments;
|
||||
this.namespace = databaseConfiguration.namespace;
|
||||
this.objectQueue = {};
|
||||
this.observers = {};
|
||||
this.batchIds = [];
|
||||
@ -47,6 +47,7 @@ class CouchObjectProvider {
|
||||
this.onEventError = this.onEventError.bind(this);
|
||||
this.flushPersistenceQueue = _.debounce(this.flushPersistenceQueue.bind(this));
|
||||
this.persistenceQueue = [];
|
||||
this.rootObject = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -59,7 +60,10 @@ class CouchObjectProvider {
|
||||
// eslint-disable-next-line no-undef
|
||||
const sharedWorkerURL = `${this.openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}couchDBChangesFeed.js`;
|
||||
|
||||
sharedWorker = new SharedWorker(sharedWorkerURL, 'CouchDB SSE Shared Worker');
|
||||
sharedWorker = new SharedWorker(
|
||||
sharedWorkerURL,
|
||||
`CouchDB SSE Shared Worker for ${this.namespace}`
|
||||
);
|
||||
sharedWorker.port.onmessage = provider.onSharedWorkerMessage.bind(this);
|
||||
sharedWorker.port.onmessageerror = provider.onSharedWorkerMessageError.bind(this);
|
||||
sharedWorker.port.start();
|
||||
@ -93,7 +97,7 @@ class CouchObjectProvider {
|
||||
this.changesFeedSharedWorkerConnectionId = event.data.connectionId;
|
||||
} else if (event.data.type === 'state') {
|
||||
const state = this.#messageToIndicatorState(event.data.state);
|
||||
this.indicator.setIndicatorToState(state);
|
||||
this.indicator?.setIndicatorToState(state);
|
||||
} else {
|
||||
let objectChanges = event.data.objectChanges;
|
||||
const objectIdentifier = {
|
||||
@ -184,16 +188,8 @@ class CouchObjectProvider {
|
||||
return state;
|
||||
}
|
||||
|
||||
//backwards compatibility, options used to be a url. Now it's an object
|
||||
#normalize(options) {
|
||||
if (typeof options === 'string') {
|
||||
return {
|
||||
url: options,
|
||||
useDesignDocuments: false
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
isReadOnly() {
|
||||
return this.readOnly;
|
||||
}
|
||||
|
||||
async request(subPath, method, body, signal) {
|
||||
@ -233,7 +229,7 @@ class CouchObjectProvider {
|
||||
|
||||
// Network error, CouchDB unreachable.
|
||||
if (response === null) {
|
||||
this.indicator.setIndicatorToState(DISCONNECTED);
|
||||
this.indicator?.setIndicatorToState(DISCONNECTED);
|
||||
console.error(error.message);
|
||||
|
||||
throw new Error(`CouchDB Error - No response"`);
|
||||
@ -256,7 +252,7 @@ class CouchObjectProvider {
|
||||
* @private
|
||||
*/
|
||||
#handleResponseCode(status, json, fetchOptions) {
|
||||
this.indicator.setIndicatorToState(this.#statusCodeToIndicatorState(status));
|
||||
this.indicator?.setIndicatorToState(this.#statusCodeToIndicatorState(status));
|
||||
if (status === CouchObjectProvider.HTTP_CONFLICT) {
|
||||
const objectName = JSON.parse(fetchOptions.body)?.model?.name;
|
||||
throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`);
|
||||
@ -684,7 +680,7 @@ class CouchObjectProvider {
|
||||
}
|
||||
|
||||
const indicatorState = this.#messageToIndicatorState(message);
|
||||
this.indicator.setIndicatorToState(indicatorState);
|
||||
this.indicator?.setIndicatorToState(indicatorState);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,6 +51,10 @@ class CouchSearchProvider {
|
||||
return this.supportedSearchTypes.includes(searchType);
|
||||
}
|
||||
|
||||
isReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
search(query, abortSignal, searchType) {
|
||||
if (searchType === this.searchTypes.OBJECTS) {
|
||||
return this.searchForObjects(query, abortSignal);
|
||||
|
@ -153,13 +153,63 @@ sh ./src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.s
|
||||
Add a line to install the CouchDB plugin for Open MCT:
|
||||
|
||||
```js
|
||||
openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: false}));
|
||||
openmct.install(
|
||||
openmct.plugins.CouchDB({
|
||||
databases: [
|
||||
{
|
||||
url: 'http://localhost:5984/openmct',
|
||||
namespace: '',
|
||||
additionalNamespaces: [],
|
||||
readOnly: false,
|
||||
useDesignDocuments: false,
|
||||
indicator: true
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Configuration Options for OpenMCT
|
||||
|
||||
When installing the CouchDB plugin for OpenMCT, you can specify a list of databases with configuration options for each. Here's a breakdown of the available options for each database:
|
||||
|
||||
- `url`: The URL to the CouchDB instance, specifying the protocol, hostname, and port as needed.
|
||||
- Example: `'http://localhost:5984/openmct'`
|
||||
|
||||
- `namespace`: The namespace associated with this database.
|
||||
- Example: `'openmct-sandbox'`
|
||||
|
||||
- `additionalNamespaces`: Other namespaces that this plugin should respond to requests for.
|
||||
- Example: `['apple-namespace', 'pear-namespace']`
|
||||
|
||||
- `readOnly`: A boolean indicating whether the database should be treated as read-only. If set to `true`, OpenMCT will not attempt to write to this database.
|
||||
- Example: `false`
|
||||
|
||||
- `useDesignDocuments`: Indicates whether design documents should be used to speed up annotation search.
|
||||
- Example: `false`
|
||||
|
||||
- `indicator`: A boolean to specify whether an indicator should show the status of this CouchDB connection in the OpenMCT interface.
|
||||
- Example: `true`
|
||||
|
||||
Note: If using the `exampleTags` plugin with non-blank namespaces, you'll need to configure it point to a writable database. For example:
|
||||
|
||||
```js
|
||||
openmct.install(
|
||||
openmct.plugins.example.ExampleTags({ namespaceToSaveAnnotations: 'openmct-sandbox' })
|
||||
);
|
||||
```
|
||||
|
||||
Note: If using the `MyItems` plugin, be sure to configure a root for each writable namespace. E.g., if you have two namespaces called `apple-namespace` and `pear-namespace`:
|
||||
```js
|
||||
openmct.install(openmct.plugins.MyItems('Apple Items', 'apple-namespace'));
|
||||
openmct.install(openmct.plugins.MyItems('Pear Items', 'pear-namespace'));
|
||||
```
|
||||
This will create a root object with the id of `mine` in both namespaces upon load if not already created.
|
||||
|
||||
# Validating a successful Installation
|
||||
|
||||
1. Start Open MCT by running `npm start` in the `openmct` path.
|
||||
2. Navigate to <http://localhost:8080/> and create a random object in Open MCT (e.g., a 'Clock') and save. You may get an error saying that the object failed to persist - this is a known error that you can ignore, and will only happen the first time you save - just try again.
|
||||
2. Navigate to <http://localhost:8080/> and create a random object in Open MCT (e.g., a 'Clock') and save.
|
||||
3. Navigate to: <http://127.0.0.1:5984/_utils/#database/openmct/_all_docs>
|
||||
4. Look at the 'JSON' tab and ensure you can see the specific object you created above.
|
||||
5. All done! 🏆
|
||||
@ -242,4 +292,4 @@ To enable them in Open MCT, we need to configure the plugin `useDesignDocuments`
|
||||
|
||||
```js
|
||||
openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: true}));
|
||||
```
|
||||
```
|
||||
|
@ -24,29 +24,81 @@ import CouchObjectProvider from './CouchObjectProvider.js';
|
||||
import CouchSearchProvider from './CouchSearchProvider.js';
|
||||
import CouchStatusIndicator from './CouchStatusIndicator.js';
|
||||
|
||||
const NAMESPACE = '';
|
||||
const DEFAULT_NAMESPACE = '';
|
||||
const LEGACY_SPACE = 'mct';
|
||||
const COUCH_SEARCH_ONLY_NAMESPACE = `COUCH_SEARCH_${Date.now()}`;
|
||||
|
||||
export default function CouchPlugin(options) {
|
||||
return function install(openmct) {
|
||||
const simpleIndicator = openmct.indicators.simpleIndicator();
|
||||
openmct.indicators.add(simpleIndicator);
|
||||
const couchStatusIndicator = new CouchStatusIndicator(simpleIndicator);
|
||||
install.couchProvider = new CouchObjectProvider(
|
||||
openmct,
|
||||
options,
|
||||
NAMESPACE,
|
||||
couchStatusIndicator
|
||||
);
|
||||
function normalizeOptions(unnormalizedOptions) {
|
||||
const normalizedOptions = {};
|
||||
if (typeof unnormalizedOptions === 'string') {
|
||||
normalizedOptions.databases = [
|
||||
{
|
||||
url: options,
|
||||
namespace: DEFAULT_NAMESPACE,
|
||||
additionalNamespaces: [LEGACY_SPACE],
|
||||
readOnly: false,
|
||||
useDesignDocuments: false,
|
||||
indicator: true
|
||||
}
|
||||
];
|
||||
} else if (!unnormalizedOptions.databases) {
|
||||
normalizedOptions.databases = [
|
||||
{
|
||||
url: unnormalizedOptions.url,
|
||||
namespace: DEFAULT_NAMESPACE,
|
||||
additionalNamespaces: [LEGACY_SPACE],
|
||||
readOnly: false,
|
||||
useDesignDocuments: unnormalizedOptions.useDesignDocuments,
|
||||
indicator: true
|
||||
}
|
||||
];
|
||||
} else {
|
||||
normalizedOptions.databases = unnormalizedOptions.databases;
|
||||
}
|
||||
|
||||
// Unfortunately, for historical reasons, Couch DB produces objects with a mix of namespaces (alternately "mct", and "")
|
||||
// Installing the same provider under both namespaces means that it can respond to object gets for both namespaces.
|
||||
openmct.objects.addProvider(LEGACY_SPACE, install.couchProvider);
|
||||
openmct.objects.addProvider(NAMESPACE, install.couchProvider);
|
||||
openmct.objects.addProvider(
|
||||
COUCH_SEARCH_ONLY_NAMESPACE,
|
||||
new CouchSearchProvider(install.couchProvider)
|
||||
);
|
||||
// final sanity check, ensure we have all options
|
||||
normalizedOptions.databases.forEach((databaseConfiguration) => {
|
||||
if (!databaseConfiguration.url) {
|
||||
throw new Error(
|
||||
`🛑 CouchDB plugin requires a url option. Please check the configuration for namespace ${databaseConfiguration.namespace}`
|
||||
);
|
||||
} else if (databaseConfiguration.namespace === undefined) {
|
||||
// note we can't check for just !databaseConfiguration.namespace because it could be an empty string
|
||||
throw new Error(
|
||||
`🛑 CouchDB plugin requires a namespace option. Please check the configuration for url ${databaseConfiguration.url}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return normalizedOptions;
|
||||
}
|
||||
|
||||
return function install(openmct) {
|
||||
const normalizedOptions = normalizeOptions(options);
|
||||
normalizedOptions.databases.forEach((databaseConfiguration) => {
|
||||
let couchStatusIndicator;
|
||||
if (databaseConfiguration.indicator) {
|
||||
const simpleIndicator = openmct.indicators.simpleIndicator();
|
||||
openmct.indicators.add(simpleIndicator);
|
||||
couchStatusIndicator = new CouchStatusIndicator(simpleIndicator);
|
||||
}
|
||||
// the provider is added to the install function to expose couchProvider to unit tests
|
||||
install.couchProvider = new CouchObjectProvider({
|
||||
openmct,
|
||||
databaseConfiguration,
|
||||
couchStatusIndicator
|
||||
});
|
||||
openmct.objects.addProvider(databaseConfiguration.namespace, install.couchProvider);
|
||||
databaseConfiguration.additionalNamespaces?.forEach((additionalNamespace) => {
|
||||
openmct.objects.addProvider(additionalNamespace, install.couchProvider);
|
||||
});
|
||||
|
||||
// need one search provider for whole couch database
|
||||
const searchOnlyNamespace = `COUCH_SEARCH_${databaseConfiguration.namespace}${Date.now()}`;
|
||||
openmct.objects.addProvider(
|
||||
searchOnlyNamespace,
|
||||
new CouchSearchProvider(install.couchProvider)
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -135,6 +135,7 @@ export default {
|
||||
default: 22
|
||||
}
|
||||
},
|
||||
emits: ['activity-selected'],
|
||||
data() {
|
||||
return {
|
||||
lineHeight: 10
|
||||
@ -142,30 +143,11 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
setSelectionForActivity(activity, event) {
|
||||
const element = event.currentTarget;
|
||||
const multiSelect = event.metaKey;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
this.openmct.selection.select(
|
||||
[
|
||||
{
|
||||
element: element,
|
||||
context: {
|
||||
type: 'activity',
|
||||
activity: activity
|
||||
}
|
||||
},
|
||||
{
|
||||
element: this.openmct.layout.$refs.browseObject.$el,
|
||||
context: {
|
||||
item: this.domainObject,
|
||||
supportsMultiSelect: true
|
||||
}
|
||||
}
|
||||
],
|
||||
multiSelect
|
||||
);
|
||||
this.$emit('activity-selected', {
|
||||
event,
|
||||
selection: activity.selection
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -47,6 +47,7 @@
|
||||
:width="group.width"
|
||||
:is-nested="options.isChildObject"
|
||||
:status="status"
|
||||
@activity-selected="selectActivity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -133,8 +134,9 @@ export default {
|
||||
this.isNested = this.options.isChildObject;
|
||||
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
|
||||
this.clipActivityNames = this.configuration.clipActivityNames;
|
||||
// This view is used for both gantt-chart and plan domain objects
|
||||
if (this.domainObject.type === 'plan') {
|
||||
this.planData = getValidatedData(this.domainObject);
|
||||
this.setupPlan(this.domainObject);
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
@ -142,18 +144,8 @@ export default {
|
||||
this.setDimensions();
|
||||
this.setTimeContext();
|
||||
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
|
||||
this.setStatus(this.openmct.status.get(this.domainObject.identifier));
|
||||
this.removeStatusListener = this.openmct.status.observe(
|
||||
this.domainObject.identifier,
|
||||
this.setStatus
|
||||
);
|
||||
this.handleConfigurationChange(this.configuration);
|
||||
this.planViewConfiguration.on('change', this.handleConfigurationChange);
|
||||
this.stopObservingSelectFile = this.openmct.objects.observe(
|
||||
this.domainObject,
|
||||
'*',
|
||||
this.handleSelectFileChange
|
||||
);
|
||||
this.loadComposition();
|
||||
},
|
||||
beforeUnmount() {
|
||||
@ -173,10 +165,28 @@ export default {
|
||||
}
|
||||
|
||||
this.planViewConfiguration.off('change', this.handleConfigurationChange);
|
||||
this.stopObservingSelectFile();
|
||||
if (this.stopObservingPlanChanges) {
|
||||
this.stopObservingPlanChanges();
|
||||
}
|
||||
this.planViewConfiguration.destroy();
|
||||
},
|
||||
methods: {
|
||||
setupPlan(domainObject) {
|
||||
this.planObject = domainObject;
|
||||
this.applyChangesForPlanObject(domainObject);
|
||||
this.stopObservingPlanChanges = this.openmct.objects.observe(
|
||||
domainObject,
|
||||
'*',
|
||||
this.applyChangesForPlanObject
|
||||
);
|
||||
this.removeStatusListener = this.openmct.status.observe(
|
||||
domainObject.identifier,
|
||||
this.setPlanStatus
|
||||
);
|
||||
},
|
||||
setPlanData(domainObject) {
|
||||
this.planData = getValidatedData(domainObject);
|
||||
},
|
||||
activityNameFitsRect(activityName, rectWidth) {
|
||||
return this.getTextWidth(activityName) + TEXT_LEFT_PADDING < rectWidth;
|
||||
},
|
||||
@ -214,10 +224,7 @@ export default {
|
||||
emphasis: true,
|
||||
callback: () => {
|
||||
this.removeFromComposition(this.planObject);
|
||||
this.planObject = domainObject;
|
||||
this.planData = getValidatedData(domainObject);
|
||||
this.setStatus(this.openmct.status.get(domainObject.identifier));
|
||||
this.setScaleAndGenerateActivities();
|
||||
this.setupPlan(domainObject);
|
||||
dialog.dismiss();
|
||||
}
|
||||
},
|
||||
@ -235,11 +242,8 @@ export default {
|
||||
if (this.planObject) {
|
||||
this.showReplacePlanDialog(domainObject);
|
||||
} else {
|
||||
this.planObject = domainObject;
|
||||
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
|
||||
this.planData = getValidatedData(domainObject);
|
||||
this.setStatus(this.openmct.status.get(domainObject.identifier));
|
||||
this.setScaleAndGenerateActivities();
|
||||
this.setupPlan(domainObject);
|
||||
}
|
||||
},
|
||||
handleConfigurationChange(newConfiguration) {
|
||||
@ -259,8 +263,10 @@ export default {
|
||||
|
||||
this.setScaleAndGenerateActivities();
|
||||
},
|
||||
handleSelectFileChange() {
|
||||
this.planData = getValidatedData(this.domainObject);
|
||||
applyChangesForPlanObject(domainObject) {
|
||||
const planDomainObject = domainObject || this.domainObject;
|
||||
this.setPlanData(planDomainObject);
|
||||
this.setPlanStatus(this.openmct.status.get(planDomainObject.identifier));
|
||||
this.setScaleAndGenerateActivities();
|
||||
},
|
||||
removeFromComposition(domainObject) {
|
||||
@ -434,7 +440,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
rawActivities.forEach((rawActivity) => {
|
||||
rawActivities.forEach((rawActivity, index) => {
|
||||
if (!this.isActivityInBounds(rawActivity)) {
|
||||
return;
|
||||
}
|
||||
@ -481,13 +487,10 @@ export default {
|
||||
const activity = {
|
||||
color: color,
|
||||
textColor: textColor,
|
||||
name: rawActivity.name,
|
||||
exceeds: {
|
||||
start: this.xScale(this.viewBounds.start) > this.xScale(rawActivity.start),
|
||||
end: this.xScale(this.viewBounds.end) < this.xScale(rawActivity.end)
|
||||
},
|
||||
start: rawActivity.start,
|
||||
end: rawActivity.end,
|
||||
row: currentRow,
|
||||
textLines: textLines,
|
||||
textStart: textStart,
|
||||
@ -496,7 +499,11 @@ export default {
|
||||
rectStart: rectX1,
|
||||
rectEnd: showTextInsideRect ? rectX2 : textStart + textWidth,
|
||||
rectWidth: rectWidth,
|
||||
clipPathId: this.getClipPathId(groupName, rawActivity, currentRow)
|
||||
clipPathId: this.getClipPathId(groupName, rawActivity, currentRow),
|
||||
selection: {
|
||||
groupName,
|
||||
index
|
||||
}
|
||||
};
|
||||
activitiesByRow[currentRow].push(activity);
|
||||
});
|
||||
@ -565,7 +572,7 @@ export default {
|
||||
swimlaneWidth
|
||||
};
|
||||
},
|
||||
setStatus(status) {
|
||||
setPlanStatus(status) {
|
||||
this.status = status;
|
||||
},
|
||||
getClipPathId(groupName, activity, row) {
|
||||
@ -573,6 +580,31 @@ export default {
|
||||
const activityName = activity.name.toLowerCase().replace(/ /g, '-');
|
||||
|
||||
return `${groupName}-${activityName}-${activity.start}-${activity.end}-${row}`;
|
||||
},
|
||||
selectActivity({ event, selection }) {
|
||||
const element = event.currentTarget;
|
||||
const multiSelect = event.metaKey;
|
||||
const { groupName, index } = selection;
|
||||
const rawActivity = this.planData[groupName][index];
|
||||
this.openmct.selection.select(
|
||||
[
|
||||
{
|
||||
element: element,
|
||||
context: {
|
||||
type: 'activity',
|
||||
activity: rawActivity
|
||||
}
|
||||
},
|
||||
{
|
||||
element: this.openmct.layout.$refs.browseObject.$el,
|
||||
context: {
|
||||
item: this.domainObject,
|
||||
supportsMultiSelect: true
|
||||
}
|
||||
}
|
||||
],
|
||||
multiSelect
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -20,21 +20,35 @@
|
||||
at runtime from the About dialog for additional information.
|
||||
-->
|
||||
<template>
|
||||
<div class="c-inspector__properties c-inspect-properties">
|
||||
<plan-activity-view
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
:activity="activity"
|
||||
:heading="heading"
|
||||
/>
|
||||
</div>
|
||||
<plan-activity-time-view
|
||||
v-for="activity in activities"
|
||||
:key="activity.key"
|
||||
:activity="activity"
|
||||
:heading="heading"
|
||||
/>
|
||||
<plan-activity-properties-view
|
||||
v-for="activity in activities"
|
||||
:key="activity.key"
|
||||
heading="Properties"
|
||||
:activity="activity"
|
||||
/>
|
||||
<plan-activity-status-view
|
||||
v-if="canPersistState"
|
||||
:key="activities[0].key"
|
||||
:activity="activities[0]"
|
||||
:execution-state="activityExecutionState"
|
||||
heading="Activity Status"
|
||||
@update-activity-state="persistActivityState"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPreciseDuration } from 'utils/duration';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import PlanActivityView from './PlanActivityView.vue';
|
||||
import { getDisplayProperties } from '../../util.js';
|
||||
import PlanActivityPropertiesView from './PlanActivityPropertiesView.vue';
|
||||
import PlanActivityStatusView from './PlanActivityStatusView.vue';
|
||||
import PlanActivityTimeView from './PlanActivityTimeView.vue';
|
||||
|
||||
const propertyLabels = {
|
||||
start: 'Start DateTime',
|
||||
@ -44,23 +58,34 @@ const propertyLabels = {
|
||||
latestEnd: 'Latest End',
|
||||
gap: 'Gap',
|
||||
overlap: 'Overlap',
|
||||
totalTime: 'Total Time'
|
||||
totalTime: 'Total Time',
|
||||
description: 'Description'
|
||||
};
|
||||
export default {
|
||||
components: {
|
||||
PlanActivityView
|
||||
PlanActivityTimeView,
|
||||
PlanActivityPropertiesView,
|
||||
PlanActivityStatusView
|
||||
},
|
||||
inject: ['openmct', 'selection'],
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
activities: [],
|
||||
selectedActivities: [],
|
||||
activityExecutionState: undefined,
|
||||
heading: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canPersistState() {
|
||||
return this.selectedActivities.length === 1 && this.activities?.[0]?.id;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setFormatters();
|
||||
this.getPlanData(this.selection);
|
||||
this.getActivityStates();
|
||||
this.getActivities();
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
this.openmct.time.on('timeSystem', this.setFormatters);
|
||||
@ -68,8 +93,28 @@ export default {
|
||||
beforeUnmount() {
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
this.openmct.time.off('timeSystem', this.setFormatters);
|
||||
if (this.stopObservingActivityStatesObject) {
|
||||
this.stopObservingActivityStatesObject();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getActivityStates() {
|
||||
this.activityStatesObject = await this.openmct.objects.get('activity-states');
|
||||
this.setActivityStates(this.activityStatesObject);
|
||||
this.stopObservingActivityStatesObject = this.openmct.objects.observe(
|
||||
this.activityStatesObject,
|
||||
'*',
|
||||
this.setActivityStates
|
||||
);
|
||||
},
|
||||
setActivityStates(newActivitiesStateObject) {
|
||||
if (this.activities.length) {
|
||||
const id = this.activities[0].id;
|
||||
this.activityExecutionState = newActivitiesStateObject.activities[id];
|
||||
} else {
|
||||
this.activityExecutionState = undefined;
|
||||
}
|
||||
},
|
||||
setFormatters() {
|
||||
let timeSystem = this.openmct.time.timeSystem();
|
||||
this.timeFormatter = this.openmct.telemetry.getValueFormatter({
|
||||
@ -84,8 +129,9 @@ export default {
|
||||
this.selectedActivities = [];
|
||||
selection.forEach((selectionItem) => {
|
||||
if (selectionItem[0].context.type === 'activity') {
|
||||
const activity = selectionItem[0].context.activity;
|
||||
const activity = { ...selectionItem[0].context.activity };
|
||||
if (activity) {
|
||||
activity.key = activity.id ?? activity.name;
|
||||
this.selectedActivities.push(activity);
|
||||
}
|
||||
}
|
||||
@ -104,20 +150,37 @@ export default {
|
||||
this.activities.splice(0);
|
||||
this.selectedActivities.forEach((selectedActivity, index) => {
|
||||
const activity = {
|
||||
id: uuid(),
|
||||
start: {
|
||||
label: propertyLabels.start,
|
||||
value: this.formatTime(selectedActivity.start)
|
||||
},
|
||||
end: {
|
||||
label: propertyLabels.end,
|
||||
value: this.formatTime(selectedActivity.end)
|
||||
},
|
||||
duration: {
|
||||
label: propertyLabels.duration,
|
||||
value: this.formatDuration(selectedActivity.end - selectedActivity.start)
|
||||
id: selectedActivity.id,
|
||||
key: selectedActivity.key,
|
||||
timeProperties: {
|
||||
start: {
|
||||
label: propertyLabels.start,
|
||||
value: this.formatTime(selectedActivity.start)
|
||||
},
|
||||
end: {
|
||||
label: propertyLabels.end,
|
||||
value: this.formatTime(selectedActivity.end)
|
||||
},
|
||||
duration: {
|
||||
label: propertyLabels.duration,
|
||||
value: this.formatDuration(selectedActivity.end - selectedActivity.start)
|
||||
}
|
||||
}
|
||||
};
|
||||
activity.metadata = {};
|
||||
if (selectedActivity.description) {
|
||||
activity.metadata.description = {
|
||||
label: propertyLabels.description,
|
||||
value: selectedActivity.description
|
||||
};
|
||||
}
|
||||
|
||||
const displayProperties = getDisplayProperties(selectedActivity);
|
||||
activity.metadata = {
|
||||
...activity.metadata,
|
||||
...displayProperties
|
||||
};
|
||||
|
||||
this.activities[index] = activity;
|
||||
});
|
||||
},
|
||||
@ -141,6 +204,8 @@ export default {
|
||||
let latestEnd;
|
||||
let gap;
|
||||
let overlap;
|
||||
let id;
|
||||
let key;
|
||||
|
||||
//Sort by start time
|
||||
let selectedActivities = this.selectedActivities.sort(this.sortFn);
|
||||
@ -159,6 +224,8 @@ export default {
|
||||
earliestStart = Math.min(earliestStart, selectedActivity.start);
|
||||
latestEnd = Math.max(latestEnd, selectedActivity.end);
|
||||
} else {
|
||||
id = selectedActivity.id;
|
||||
key = selectedActivity.id ?? selectedActivity.name;
|
||||
earliestStart = selectedActivity.start;
|
||||
latestEnd = selectedActivity.end;
|
||||
}
|
||||
@ -166,30 +233,33 @@ export default {
|
||||
let totalTime = latestEnd - earliestStart;
|
||||
|
||||
const activity = {
|
||||
id: uuid(),
|
||||
earliestStart: {
|
||||
label: propertyLabels.earliestStart,
|
||||
value: this.formatTime(earliestStart)
|
||||
},
|
||||
latestEnd: {
|
||||
label: propertyLabels.latestEnd,
|
||||
value: this.formatTime(latestEnd)
|
||||
id,
|
||||
key,
|
||||
timeProperties: {
|
||||
earliestStart: {
|
||||
label: propertyLabels.earliestStart,
|
||||
value: this.formatTime(earliestStart)
|
||||
},
|
||||
latestEnd: {
|
||||
label: propertyLabels.latestEnd,
|
||||
value: this.formatTime(latestEnd)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (gap) {
|
||||
activity.gap = {
|
||||
activity.timeProperties.gap = {
|
||||
label: propertyLabels.gap,
|
||||
value: this.formatDuration(gap)
|
||||
};
|
||||
} else if (overlap) {
|
||||
activity.overlap = {
|
||||
activity.timeProperties.overlap = {
|
||||
label: propertyLabels.overlap,
|
||||
value: this.formatDuration(overlap)
|
||||
};
|
||||
}
|
||||
|
||||
activity.totalTime = {
|
||||
activity.timeProperties.totalTime = {
|
||||
label: propertyLabels.totalTime,
|
||||
value: this.formatDuration(totalTime)
|
||||
};
|
||||
@ -201,6 +271,11 @@ export default {
|
||||
},
|
||||
formatTime(time) {
|
||||
return this.timeFormatter.format(time);
|
||||
},
|
||||
persistActivityState(data) {
|
||||
const { key, executionState } = data;
|
||||
const activitiesPath = `activities.${key}`;
|
||||
this.openmct.objects.mutate(this.activityStatesObject, activitiesPath, executionState);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -0,0 +1,81 @@
|
||||
<!--
|
||||
Open MCT, Copyright (c) 2014-2023, 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.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="c-inspector__properties c-inspect-properties">
|
||||
<div v-if="properties.length" class="u-contents">
|
||||
<div class="c-inspect-properties__header">{{ heading }}</div>
|
||||
<ul v-for="property in properties" :key="property.id" class="c-inspect-properties__section">
|
||||
<activity-property :label="property.label" :value="property.value" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ActivityProperty from './ActivityProperty.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ActivityProperty
|
||||
},
|
||||
props: {
|
||||
activity: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
heading: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
properties: []
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.setProperties();
|
||||
},
|
||||
methods: {
|
||||
setProperties() {
|
||||
if (!this.activity.metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(this.activity.metadata).forEach((key) => {
|
||||
if (this.activity.metadata[key].label) {
|
||||
const label = this.activity.metadata[key].label;
|
||||
const value = String(this.activity.metadata[key].value);
|
||||
const id = this.activity.id;
|
||||
|
||||
this.properties[this.properties.length] = {
|
||||
id,
|
||||
label,
|
||||
value
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
127
src/plugins/plan/inspector/components/PlanActivityStatusView.vue
Normal file
127
src/plugins/plan/inspector/components/PlanActivityStatusView.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<!--
|
||||
Open MCT, Copyright (c) 2014-2023, 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.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="c-inspector__properties c-inspect-properties">
|
||||
<div class="u-contents">
|
||||
<div class="c-inspect-properties__header">{{ heading }}</div>
|
||||
<div class="c-inspect-properties__row">
|
||||
<div class="c-inspect-properties__label" title="Set Status">Set Status</div>
|
||||
<div class="c-inspect-properties__value" aria-label="Activity Status Label">
|
||||
<select
|
||||
v-model="currentStatusKey"
|
||||
name="setActivityStatus"
|
||||
aria-label="Activity Status"
|
||||
@change="changeActivityStatus"
|
||||
>
|
||||
<option
|
||||
v-for="status in activityStates"
|
||||
:key="status.key"
|
||||
:value="status.key"
|
||||
:aria-selected="currentStatusKey === status.key"
|
||||
>
|
||||
{{ status.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const activityStates = [
|
||||
{
|
||||
key: 'notStarted',
|
||||
label: 'Not started'
|
||||
},
|
||||
{
|
||||
key: 'in-progress',
|
||||
label: 'In progress'
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: 'Completed'
|
||||
},
|
||||
{
|
||||
key: 'aborted',
|
||||
label: 'Aborted'
|
||||
},
|
||||
{
|
||||
key: 'skipped',
|
||||
label: 'Skipped'
|
||||
}
|
||||
];
|
||||
|
||||
export default {
|
||||
props: {
|
||||
activity: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
executionState: {
|
||||
type: String,
|
||||
default() {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
heading: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['updateActivityState'],
|
||||
data() {
|
||||
return {
|
||||
activityStates: activityStates,
|
||||
currentStatusKey: activityStates[0].key
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
executionState() {
|
||||
this.setActivityStatus();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setActivityStatus();
|
||||
},
|
||||
methods: {
|
||||
setActivityStatus() {
|
||||
let statusKeyIndex = activityStates.findIndex((state) => state.key === this.executionState);
|
||||
if (statusKeyIndex < 0) {
|
||||
statusKeyIndex = 0;
|
||||
}
|
||||
this.currentStatusKey = this.activityStates[statusKeyIndex].key;
|
||||
},
|
||||
changeActivityStatus() {
|
||||
if (this.currentStatusKey === '') {
|
||||
return;
|
||||
}
|
||||
this.activity.executionState = this.currentStatusKey;
|
||||
this.$emit('updateActivityState', {
|
||||
key: this.activity.id,
|
||||
executionState: this.currentStatusKey
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
@ -21,23 +21,23 @@
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="timeProperties.length" class="u-contents">
|
||||
<div class="c-inspect-properties__header">
|
||||
{{ heading }}
|
||||
<div class="c-inspector__properties c-inspect-properties">
|
||||
<div v-if="timeProperties.length" class="u-contents">
|
||||
<div class="c-inspect-properties__header">
|
||||
{{ heading }}
|
||||
</div>
|
||||
<ul
|
||||
v-for="timeProperty in timeProperties"
|
||||
:key="timeProperty.id"
|
||||
class="c-inspect-properties__section"
|
||||
>
|
||||
<activity-property :label="timeProperty.label" :value="timeProperty.value" />
|
||||
</ul>
|
||||
</div>
|
||||
<ul
|
||||
v-for="timeProperty in timeProperties"
|
||||
:key="timeProperty.id"
|
||||
class="c-inspect-properties__section"
|
||||
>
|
||||
<activity-property :label="timeProperty.label" :value="timeProperty.value" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import ActivityProperty from './ActivityProperty.vue';
|
||||
|
||||
export default {
|
||||
@ -64,13 +64,14 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
setProperties() {
|
||||
Object.keys(this.activity).forEach((key) => {
|
||||
if (this.activity[key].label) {
|
||||
const label = this.activity[key].label;
|
||||
const value = String(this.activity[key].value);
|
||||
Object.keys(this.activity.timeProperties).forEach((key) => {
|
||||
if (this.activity.timeProperties[key].label) {
|
||||
const label = this.activity.timeProperties[key].label;
|
||||
const value = String(this.activity.timeProperties[key].value);
|
||||
const id = this.activity.id;
|
||||
|
||||
this.timeProperties[this.timeProperties.length] = {
|
||||
id: uuid(),
|
||||
id,
|
||||
label,
|
||||
value
|
||||
};
|
@ -20,12 +20,28 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import activityStatesInterceptor from '../activityStates/activityStatesInterceptor.js';
|
||||
import { createActivityStatesIdentifier } from '../activityStates/createActivityStatesIdentifier.js';
|
||||
import ganttChartCompositionPolicy from './GanttChartCompositionPolicy.js';
|
||||
import ActivityInspectorViewProvider from './inspector/ActivityInspectorViewProvider.js';
|
||||
import GanttChartInspectorViewProvider from './inspector/GanttChartInspectorViewProvider.js';
|
||||
import { DEFAULT_CONFIGURATION } from './PlanViewConfiguration.js';
|
||||
import PlanViewProvider from './PlanViewProvider.js';
|
||||
|
||||
const ACTIVITY_STATES_DEFAULT_NAME = 'Activity States';
|
||||
/**
|
||||
* @typedef {object} PlanOptions
|
||||
* @property {boolean} creatable true/false to allow creation of a plan via the Create menu.
|
||||
* @property {string} name The name of the activity states model.
|
||||
* @property {string} namespace the namespace to use for the activity states object.
|
||||
* @property {Number} priority the priority of the interceptor. By default, it is low.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PlanOptions} options
|
||||
* @returns {*} (any)
|
||||
*/
|
||||
export default function (options = {}) {
|
||||
return function install(openmct) {
|
||||
openmct.types.addType('plan', {
|
||||
@ -70,5 +86,13 @@ export default function (options = {}) {
|
||||
openmct.inspectorViews.addProvider(new ActivityInspectorViewProvider(openmct));
|
||||
openmct.inspectorViews.addProvider(new GanttChartInspectorViewProvider(openmct));
|
||||
openmct.composition.addPolicy(ganttChartCompositionPolicy(openmct));
|
||||
|
||||
//add activity states get interceptor
|
||||
const { name = ACTIVITY_STATES_DEFAULT_NAME, namespace = '', priority } = options;
|
||||
const identifier = createActivityStatesIdentifier(namespace);
|
||||
|
||||
openmct.objects.addGetInterceptor(
|
||||
activityStatesInterceptor(openmct, { identifier, name, priority })
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -20,6 +20,20 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
/**
|
||||
* The SourceMap allows mapping specific implementations of plan domain objects to those expected by Open MCT.
|
||||
* @typedef {object} SourceMapOption
|
||||
* @property {string} orderedGroups the property of the plan that lists groups/swim lanes specifying what order they will be displayed in Open MCT.
|
||||
* @property {string} activities the property of the plan that has the list of activities to be displayed.
|
||||
* @property {string} groupId the property of the activity that maps to the group/swim lane it should be displayed in.
|
||||
* @property {string} start The start time property of the activity
|
||||
* @property {string} end The end time property of the activity
|
||||
* @property {string} id The unique id of the activity. This is required to allow setting activity states
|
||||
* @property {object} displayProperties a list of key: value pairs that specifies which properties of the activity should be displayed when it is selected. Ex. {'location': 'Location', 'metadata.length_in_meters', 'Length (meters)'}
|
||||
* @property {object} filterMetadata a list of strings that specifies which properties of the activity be included for filtering. Ex. {'description','properties.length_in_meters'}
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
export function getValidatedData(domainObject) {
|
||||
const sourceMap = domainObject.sourceMap;
|
||||
const json = getObjectJson(domainObject);
|
||||
@ -45,6 +59,24 @@ export function getValidatedData(domainObject) {
|
||||
groupActivity.end = activity[sourceMap.end];
|
||||
}
|
||||
|
||||
if (Array.isArray(sourceMap.filterMetadata)) {
|
||||
groupActivity.filterMetadataValues = [];
|
||||
sourceMap.filterMetadata.forEach((property) => {
|
||||
const value = _.get(activity, property);
|
||||
if (value !== undefined && value !== null) {
|
||||
groupActivity.filterMetadataValues.push(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceMap.id) {
|
||||
groupActivity.id = activity[sourceMap.id];
|
||||
}
|
||||
|
||||
if (sourceMap.displayProperties) {
|
||||
groupActivity.displayProperties = sourceMap.displayProperties;
|
||||
}
|
||||
|
||||
if (!mappedJson[groupIdKey]) {
|
||||
mappedJson[groupIdKey] = [];
|
||||
}
|
||||
@ -92,7 +124,6 @@ export function getValidatedGroups(domainObject, planData) {
|
||||
orderedGroupNames = groups;
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedGroupNames === undefined) {
|
||||
orderedGroupNames = Object.keys(planData);
|
||||
}
|
||||
@ -100,6 +131,37 @@ export function getValidatedGroups(domainObject, planData) {
|
||||
return orderedGroupNames;
|
||||
}
|
||||
|
||||
export function getDisplayProperties(activity) {
|
||||
let displayProperties = {};
|
||||
function extractProperties(properties, useKeyAsLabel = false) {
|
||||
Object.keys(properties).forEach((key) => {
|
||||
const label = useKeyAsLabel ? key : properties[key];
|
||||
const value = _.get(activity, key);
|
||||
if (value) {
|
||||
displayProperties[key] = { label, value };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (activity?.displayProperties) {
|
||||
extractProperties(activity.displayProperties);
|
||||
} else if (activity?.properties) {
|
||||
extractProperties(activity.properties, true);
|
||||
}
|
||||
return displayProperties;
|
||||
}
|
||||
|
||||
export function getFilteredValues(activity) {
|
||||
let values = [];
|
||||
if (Array.isArray(activity.filterMetadataValues)) {
|
||||
values = activity.filterMetadataValues;
|
||||
} else if (activity?.properties) {
|
||||
values = Object.values(activity.properties);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getContrastingColor(hexColor) {
|
||||
function cutHex(h, start, end) {
|
||||
const hStr = h.charAt(0) === '#' ? h.substring(1, 7) : h;
|
||||
|
@ -53,13 +53,13 @@
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<mct-ticks
|
||||
<MctTicks
|
||||
v-show="gridLines && !options.compact"
|
||||
:axis-type="'xAxis'"
|
||||
:position="'right'"
|
||||
/>
|
||||
|
||||
<mct-ticks
|
||||
<MctTicks
|
||||
v-for="(yAxis, index) in yAxesIds"
|
||||
v-show="gridLines"
|
||||
:key="`yAxis-gridlines-${index}`"
|
||||
|
@ -273,12 +273,14 @@ export default {
|
||||
)
|
||||
);
|
||||
|
||||
this.tickWidth = tickWidth;
|
||||
this.$emit('plot-tick-width', {
|
||||
width: tickWidth,
|
||||
yAxisId: this.axisType === 'yAxis' ? this.axisId : ''
|
||||
});
|
||||
this.shouldCheckWidth = false;
|
||||
if (this.tickWidth !== tickWidth) {
|
||||
this.tickWidth = tickWidth;
|
||||
this.$emit('plot-tick-width', {
|
||||
width: tickWidth,
|
||||
yAxisId: this.axisType === 'yAxis' ? this.axisId : ''
|
||||
});
|
||||
this.shouldCheckWidth = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
<template>
|
||||
<div v-if="loaded" class="gl-plot-axis-area gl-plot-x has-local-controls">
|
||||
<mct-ticks :axis-type="'xAxis'" :position="'left'" @plot-tick-width="onTickWidthChange" />
|
||||
<MctTicks :axis-type="'xAxis'" :position="'left'" />
|
||||
|
||||
<div class="gl-plot-label gl-plot-x-label" :class="{ 'icon-gear': isEnabledXKeyToggle() }">
|
||||
{{ xAxisLabel }}
|
||||
@ -59,7 +59,6 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
emits: ['plot-x-tick-width'],
|
||||
data() {
|
||||
return {
|
||||
selectedXKeyOptionKey: '',
|
||||
@ -137,9 +136,6 @@ export default {
|
||||
this.xAxisLabel = this.xAxis.get('label');
|
||||
this.selectedXKeyOptionKey =
|
||||
this.xKeyOptions.length > 0 ? this.getXKeyOption(xAxisKey).key : xAxisKey;
|
||||
},
|
||||
onTickWidthChange(width) {
|
||||
this.$emit('plot-x-tick-width', width);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -60,7 +60,7 @@
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<mct-ticks
|
||||
<MctTicks
|
||||
:axis-id="id"
|
||||
:axis-type="'yAxis'"
|
||||
class="gl-plot-ticks"
|
||||
|
@ -22,8 +22,20 @@
|
||||
|
||||
<template>
|
||||
<div ref="chart" class="gl-plot-chart-area">
|
||||
<canvas id="2dContext" :style="canvasStyle" class="js-overlay-canvas" role="img"></canvas>
|
||||
<canvas id="webglContext" :style="canvasStyle" class="js-main-canvas" role="img"></canvas>
|
||||
<canvas
|
||||
id="2dContext"
|
||||
:style="canvasStyle"
|
||||
class="js-overlay-canvas"
|
||||
role="img"
|
||||
aria-label="Overlay Canvas"
|
||||
></canvas>
|
||||
<canvas
|
||||
id="webglContext"
|
||||
:style="canvasStyle"
|
||||
class="js-main-canvas"
|
||||
role="img"
|
||||
aria-label="Plot Canvas"
|
||||
></canvas>
|
||||
<div ref="limitArea" class="js-limit-area" aria-hidden="true">
|
||||
<limit-label
|
||||
v-for="(limitLabel, index) in visibleLimitLabels"
|
||||
@ -200,7 +212,11 @@ export default {
|
||||
this.chartVisible = true;
|
||||
this.chartContainer = this.$refs.chart;
|
||||
this.drawnOnce = false;
|
||||
this.visibilityObserver = new IntersectionObserver(this.visibilityChanged);
|
||||
const rootContainer = this.openmct.element;
|
||||
const options = {
|
||||
root: rootContainer
|
||||
};
|
||||
this.visibilityObserver = new IntersectionObserver(this.visibilityChanged, options);
|
||||
eventHelpers.extend(this);
|
||||
this.seriesModels = [];
|
||||
this.config = this.getConfig();
|
||||
@ -276,13 +292,20 @@ export default {
|
||||
return config;
|
||||
},
|
||||
visibilityChanged([entry]) {
|
||||
// Per https://github.com/nasa/openmct/issues/7405, we only want to draw when the chart is visible.
|
||||
// and we need to use the Open MCT root element as the root of the intersection observer.
|
||||
if (entry.target === this.chartContainer) {
|
||||
const wasVisible = this.chartVisible;
|
||||
this.chartVisible = entry.isIntersecting;
|
||||
if (!this.chartVisible) {
|
||||
// destroy the chart
|
||||
const isNowVisible = entry.isIntersecting;
|
||||
const chartInOverlayWindow = this.chartContainer?.closest('.js-overlay') !== null;
|
||||
|
||||
if (!isNowVisible && !chartInOverlayWindow) {
|
||||
this.chartVisible = false;
|
||||
this.destroyCanvas();
|
||||
} else if (!wasVisible && this.chartVisible) {
|
||||
} else if (!isNowVisible && chartInOverlayWindow) {
|
||||
this.chartVisible = true;
|
||||
} else if (!wasVisible && isNowVisible) {
|
||||
this.chartVisible = true;
|
||||
// rebuild the chart
|
||||
this.buildCanvasElements();
|
||||
const canvasInitialized = this.readyCanvasForDrawing();
|
||||
@ -290,8 +313,8 @@ export default {
|
||||
this.draw();
|
||||
}
|
||||
this.$emit('plot-reinitialize-canvas');
|
||||
} else if (wasVisible && this.chartVisible) {
|
||||
// ignore, moving on
|
||||
} else {
|
||||
this.chartVisible = isNowVisible;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -139,7 +139,9 @@ export default class Model extends EventEmitter {
|
||||
|
||||
/** @typedef {any} TODO */
|
||||
|
||||
/** @typedef {TODO} OpenMCT */
|
||||
/**
|
||||
* @typedef {import('../../../../openmct.js').OpenMCT} OpenMCT
|
||||
*/
|
||||
|
||||
/**
|
||||
@template {object} T
|
||||
|
@ -87,6 +87,15 @@ export default class PlotSeries extends Model {
|
||||
this.onYKeyChange(this.get('yKey'));
|
||||
|
||||
this.unPlottableValues = [undefined, Infinity, -Infinity];
|
||||
this.itemsGarbageCollected = 0;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
this.registry = new FinalizationRegistry((heldValue) => {
|
||||
this.itemsGarbageCollected++;
|
||||
console.debug(
|
||||
`🗑️ 📈 PLOT garbage collected: ${this.itemsGarbageCollected} - ${JSON.stringify(heldValue)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getLogMode(options) {
|
||||
@ -211,13 +220,21 @@ export default class PlotSeries extends Model {
|
||||
);
|
||||
|
||||
if (!this.unsubscribe) {
|
||||
this.unsubscribe = this.openmct.telemetry.subscribe(this.domainObject, this.add.bind(this), {
|
||||
filters: this.filters
|
||||
});
|
||||
this.unsubscribe = this.openmct.telemetry.subscribe(
|
||||
this.domainObject,
|
||||
(data) => {
|
||||
this.addAll(data, true);
|
||||
},
|
||||
{
|
||||
filters: this.filters,
|
||||
strategy: this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const points = await this.openmct.telemetry.request(this.domainObject, options);
|
||||
|
||||
const data = this.getSeriesData();
|
||||
// eslint-disable-next-line you-dont-need-lodash-underscore/concat
|
||||
const newPoints = _(data)
|
||||
@ -225,6 +242,7 @@ export default class PlotSeries extends Model {
|
||||
.sortBy(this.getXVal)
|
||||
.uniq(true, (point) => [this.getXVal(point), this.getYVal(point)].join())
|
||||
.value();
|
||||
|
||||
this.reset(newPoints);
|
||||
} catch (error) {
|
||||
console.warn('Error fetching data', error);
|
||||
@ -302,9 +320,7 @@ export default class PlotSeries extends Model {
|
||||
this.resetStats();
|
||||
this.emit('reset');
|
||||
if (newData) {
|
||||
newData.forEach(function (point) {
|
||||
this.add(point, true);
|
||||
}, this);
|
||||
this.addAll(newData, true);
|
||||
}
|
||||
}
|
||||
/**
|
||||
@ -416,14 +432,17 @@ export default class PlotSeries extends Model {
|
||||
* when adding an array of points that are already properly sorted.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} point a telemetry datum.
|
||||
* @param {Boolean} [appendOnly] default false, if true will append
|
||||
* @param {Object} newData a telemetry datum.
|
||||
* @param {Boolean} [sorted] default false, if true will append
|
||||
* a point to the end without dupe checking.
|
||||
*/
|
||||
add(point, appendOnly) {
|
||||
add(newData, sorted = false) {
|
||||
const heldValue = `${new Date()} Data with ${JSON.stringify(newData)}`;
|
||||
this.registry.register(newData, heldValue);
|
||||
|
||||
let data = this.getSeriesData();
|
||||
let insertIndex = data.length;
|
||||
const currentYVal = this.getYVal(point);
|
||||
const currentYVal = this.getYVal(newData);
|
||||
const lastYVal = this.getYVal(data[insertIndex - 1]);
|
||||
|
||||
if (this.isValueInvalid(currentYVal) && this.isValueInvalid(lastYVal)) {
|
||||
@ -432,22 +451,28 @@ export default class PlotSeries extends Model {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!appendOnly) {
|
||||
insertIndex = this.sortedIndex(point);
|
||||
if (this.getXVal(data[insertIndex]) === this.getXVal(point)) {
|
||||
if (!sorted) {
|
||||
insertIndex = this.sortedIndex(newData);
|
||||
if (this.getXVal(data[insertIndex]) === this.getXVal(newData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getXVal(data[insertIndex - 1]) === this.getXVal(point)) {
|
||||
if (this.getXVal(data[insertIndex - 1]) === this.getXVal(newData)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateStats(point);
|
||||
point.mctLimitState = this.evaluate(point);
|
||||
data.splice(insertIndex, 0, point);
|
||||
this.updateStats(newData);
|
||||
newData.mctLimitState = this.evaluate(newData);
|
||||
data.splice(insertIndex, 0, newData);
|
||||
this.updateSeriesData(data);
|
||||
this.emit('add', point, insertIndex, this);
|
||||
this.emit('add', newData, insertIndex, this);
|
||||
}
|
||||
|
||||
addAll(points, sorted = false) {
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
this.add(points[i], sorted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -94,7 +94,7 @@ export default class XAxisModel extends Model {
|
||||
*/
|
||||
defaultModel(options) {
|
||||
const bounds = options.openmct.time.bounds();
|
||||
const timeSystem = options.openmct.time.timeSystem();
|
||||
const timeSystem = options.openmct.time.getTimeSystem();
|
||||
const format = options.openmct.telemetry.getFormatter(timeSystem.timeFormat);
|
||||
|
||||
/** @type {XAxisModelType} */
|
||||
|
@ -228,8 +228,8 @@ DrawWebGL.prototype.setDimensions = function (dimensions, origin) {
|
||||
}
|
||||
|
||||
if (dimensions && dimensions.length > 0 && origin && origin.length > 0) {
|
||||
this.gl.uniform2fv(this.uDimensions, dimensions);
|
||||
this.gl.uniform2fv(this.uOrigin, origin);
|
||||
this.gl?.uniform2fv(this.uDimensions, dimensions);
|
||||
this.gl?.uniform2fv(this.uOrigin, origin);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -23,7 +23,7 @@
|
||||
<div v-if="loaded" class="js-plot-options-browse">
|
||||
<ul v-if="!isStackedPlotObject" class="c-tree" aria-label="Plot Series Properties">
|
||||
<h2 class="--first" title="Plot series display properties in this object">Plot Series</h2>
|
||||
<plot-options-item v-for="series in plotSeries" :key="series.key" :series="series" />
|
||||
<PlotOptionsItem v-for="series in plotSeries" :key="series.keyString" :series="series" />
|
||||
</ul>
|
||||
<div v-if="plotSeries.length && !isStackedPlotObject" class="grid-properties">
|
||||
<ul
|
||||
@ -79,7 +79,7 @@
|
||||
</div>
|
||||
<div class="grid-cell value">{{ showLegendsForChildren ? 'Yes' : 'No' }}</div>
|
||||
</li>
|
||||
<li class="grid-row">
|
||||
<li v-if="showLegendDetails" class="grid-row">
|
||||
<div
|
||||
class="grid-cell label"
|
||||
title="The position of the legend relative to the plot display area."
|
||||
@ -88,25 +88,27 @@
|
||||
</div>
|
||||
<div class="grid-cell value capitalize">{{ position }}</div>
|
||||
</li>
|
||||
<li class="grid-row">
|
||||
<li v-if="showLegendDetails" class="grid-row">
|
||||
<div class="grid-cell label" title="Hide the legend when the plot is small">
|
||||
Hide when plot small
|
||||
</div>
|
||||
<div class="grid-cell value">{{ hideLegendWhenSmall ? 'Yes' : 'No' }}</div>
|
||||
</li>
|
||||
<li class="grid-row">
|
||||
<li v-if="showLegendDetails" class="grid-row">
|
||||
<div class="grid-cell label" title="Show the legend expanded by default">
|
||||
Expand by Default
|
||||
</div>
|
||||
<div class="grid-cell value">{{ expandByDefault ? 'Yes' : 'No' }}</div>
|
||||
<div aria-label="Expand by Default" class="grid-cell value">
|
||||
{{ expandByDefault ? 'Yes' : 'No' }}
|
||||
</div>
|
||||
</li>
|
||||
<li class="grid-row">
|
||||
<li v-if="showLegendDetails" class="grid-row">
|
||||
<div class="grid-cell label" title="What to display in the legend when it's collapsed.">
|
||||
Show when collapsed:
|
||||
</div>
|
||||
<div class="grid-cell value">{{ valueToShowWhenCollapsed.replace('nearest', '') }}</div>
|
||||
</li>
|
||||
<li class="grid-row">
|
||||
<li v-if="showLegendDetails" class="grid-row">
|
||||
<div class="grid-cell label" title="What to display in the legend when it's expanded.">
|
||||
Show when expanded:
|
||||
</div>
|
||||
@ -164,6 +166,11 @@ export default {
|
||||
pathObjIndex === 0 && pathObject.type === 'telemetry.plot.stacked'
|
||||
);
|
||||
},
|
||||
showLegendDetails() {
|
||||
return (
|
||||
!this.isStackedPlotObject || (this.isStackedPlotObject && !this.showLegendsForChildren)
|
||||
);
|
||||
},
|
||||
yAxesWithSeries() {
|
||||
return this.yAxes.filter((yAxis) => yAxis.seriesCount > 0);
|
||||
}
|
||||
@ -174,9 +181,8 @@ export default {
|
||||
if (!this.isStackedPlotObject) {
|
||||
this.initYAxesConfiguration();
|
||||
this.registerListeners();
|
||||
} else {
|
||||
this.initLegendConfiguration();
|
||||
}
|
||||
this.initLegendConfiguration();
|
||||
|
||||
this.loaded = true;
|
||||
},
|
||||
|
@ -23,7 +23,7 @@
|
||||
<div v-if="loaded" class="js-plot-options-edit">
|
||||
<ul v-if="!isStackedPlotObject" class="c-tree" aria-label="Plot Series Properties">
|
||||
<h2 class="--first" title="Display properties for this object">Plot Series</h2>
|
||||
<li v-for="series in plotSeries" :key="series.key">
|
||||
<li v-for="series in plotSeries" :key="series.keyString">
|
||||
<series-form :series="series" @series-updated="updateSeriesConfigForObject" />
|
||||
</li>
|
||||
</ul>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user