mirror of
https://github.com/nasa/openmct.git
synced 2024-12-20 05:37:53 +00:00
Merge branch 'master' into mission-status-situational-awareness
This commit is contained in:
commit
90d08982e0
@ -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
|
||||
@ -129,7 +129,7 @@ jobs:
|
||||
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}
|
||||
@ -251,8 +251,6 @@ workflows:
|
||||
- e2e-test:
|
||||
name: e2e-stable
|
||||
suite: stable
|
||||
- mem-test
|
||||
- perf-test
|
||||
- visual-a11y-tests:
|
||||
name: visual-test-ci
|
||||
suite: ci
|
||||
@ -278,7 +276,7 @@ workflows:
|
||||
- e2e-couchdb
|
||||
triggers:
|
||||
- schedule:
|
||||
cron: '0 0 * * *'
|
||||
cron: "0 0 * * *"
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
|
@ -493,6 +493,8 @@
|
||||
"WCAG",
|
||||
"stackedplot",
|
||||
"Andale",
|
||||
"checksnapshots",
|
||||
"specced",
|
||||
"composables"
|
||||
],
|
||||
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US"],
|
||||
|
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}`);
|
||||
}
|
@ -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:
|
||||
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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
|
||||
@ -40,53 +45,8 @@ 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'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
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,17 @@ 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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 |
@ -224,31 +224,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);
|
||||
|
||||
|
33
e2e/tests/functional/plugins/preview/preview.e2e.spec.js
Normal file
33
e2e/tests/functional/plugins/preview/preview.e2e.spec.js
Normal file
@ -0,0 +1,33 @@
|
||||
/*****************************************************************************
|
||||
* 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 { test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Preview mode', () => {
|
||||
test.fixme('all context menu items are available for a telemetry table', async ({ page }) => {
|
||||
// compare the context menu options when viewing a telemetry table directly
|
||||
// vs when it is presented in preview mode (e.g. edit mode is enabled and the table is clicked on from the tree)
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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);
|
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})`);
|
||||
});
|
||||
});
|
@ -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,
|
||||
|
@ -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",
|
||||
@ -72,7 +72,7 @@
|
||||
"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 +111,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
|
||||
|
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,8 @@ export default class TelemetryAPI {
|
||||
this.valueFormatterCache = new WeakMap();
|
||||
this.requestInterceptorRegistry = new TelemetryRequestInterceptorRegistry();
|
||||
this.#isGreedyLAD = true;
|
||||
this.BatchingWebSocket = BatchingWebSocket;
|
||||
this.#subscribeCache = {};
|
||||
}
|
||||
|
||||
abortAllRequests() {
|
||||
@ -378,54 +408,111 @@ 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);
|
||||
}
|
||||
|
||||
// Guarantees that view receive telemetry in the expected form
|
||||
function invokeCallbackWithRequestedStrategy(data) {
|
||||
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);
|
||||
});
|
||||
}
|
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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) {
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
@ -134,7 +135,7 @@ export default {
|
||||
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
|
||||
this.clipActivityNames = this.configuration.clipActivityNames;
|
||||
if (this.domainObject.type === 'plan') {
|
||||
this.planData = getValidatedData(this.domainObject);
|
||||
this.setPlanData(this.domainObject);
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
@ -177,6 +178,9 @@ export default {
|
||||
this.planViewConfiguration.destroy();
|
||||
},
|
||||
methods: {
|
||||
setPlanData(domainObject) {
|
||||
this.planData = getValidatedData(domainObject);
|
||||
},
|
||||
activityNameFitsRect(activityName, rectWidth) {
|
||||
return this.getTextWidth(activityName) + TEXT_LEFT_PADDING < rectWidth;
|
||||
},
|
||||
@ -215,9 +219,7 @@ export default {
|
||||
callback: () => {
|
||||
this.removeFromComposition(this.planObject);
|
||||
this.planObject = domainObject;
|
||||
this.planData = getValidatedData(domainObject);
|
||||
this.setStatus(this.openmct.status.get(domainObject.identifier));
|
||||
this.setScaleAndGenerateActivities();
|
||||
this.handleSelectFileChange();
|
||||
dialog.dismiss();
|
||||
}
|
||||
},
|
||||
@ -237,9 +239,7 @@ export default {
|
||||
} else {
|
||||
this.planObject = domainObject;
|
||||
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
|
||||
this.planData = getValidatedData(domainObject);
|
||||
this.setStatus(this.openmct.status.get(domainObject.identifier));
|
||||
this.setScaleAndGenerateActivities();
|
||||
this.handleSelectFileChange(domainObject);
|
||||
}
|
||||
},
|
||||
handleConfigurationChange(newConfiguration) {
|
||||
@ -259,8 +259,10 @@ export default {
|
||||
|
||||
this.setScaleAndGenerateActivities();
|
||||
},
|
||||
handleSelectFileChange() {
|
||||
this.planData = getValidatedData(this.domainObject);
|
||||
handleSelectFileChange(domainObject) {
|
||||
const planDomainObject = domainObject || this.domainObject;
|
||||
this.setPlanData(planDomainObject);
|
||||
this.setStatus(this.openmct.status.get(planDomainObject.identifier));
|
||||
this.setScaleAndGenerateActivities();
|
||||
},
|
||||
removeFromComposition(domainObject) {
|
||||
@ -434,7 +436,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
rawActivities.forEach((rawActivity) => {
|
||||
rawActivities.forEach((rawActivity, index) => {
|
||||
if (!this.isActivityInBounds(rawActivity)) {
|
||||
return;
|
||||
}
|
||||
@ -481,13 +483,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 +495,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);
|
||||
});
|
||||
@ -573,6 +576,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({
|
||||
@ -86,6 +131,7 @@ export default {
|
||||
if (selectionItem[0].context.type === '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: 'cancelled',
|
||||
label: 'Cancelled'
|
||||
}
|
||||
];
|
||||
|
||||
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,19 @@
|
||||
* 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)'}
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
export function getValidatedData(domainObject) {
|
||||
const sourceMap = domainObject.sourceMap;
|
||||
const json = getObjectJson(domainObject);
|
||||
@ -45,6 +58,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);
|
||||
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 +123,6 @@ export function getValidatedGroups(domainObject, planData) {
|
||||
orderedGroupNames = groups;
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedGroupNames === undefined) {
|
||||
orderedGroupNames = Object.keys(planData);
|
||||
}
|
||||
@ -100,6 +130,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;
|
||||
|
@ -200,7 +200,13 @@ 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,
|
||||
rootMargin: '0px',
|
||||
threshold: 1.0
|
||||
};
|
||||
this.visibilityObserver = new IntersectionObserver(this.visibilityChanged, options);
|
||||
eventHelpers.extend(this);
|
||||
this.seriesModels = [];
|
||||
this.config = this.getConfig();
|
||||
@ -276,6 +282,8 @@ 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;
|
||||
|
@ -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
|
||||
|
@ -211,9 +211,16 @@ 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 {
|
||||
@ -302,9 +309,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 +421,14 @@ 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) {
|
||||
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 +437,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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -65,6 +65,7 @@ import PerformanceIndicator from './performanceIndicator/plugin.js';
|
||||
import CouchDBPlugin from './persistence/couch/plugin.js';
|
||||
import PlanLayout from './plan/plugin.js';
|
||||
import PlotPlugin from './plot/plugin.js';
|
||||
import ReloadAction from './reloadAction/plugin.js';
|
||||
import RemoteClock from './remoteClock/plugin.js';
|
||||
import StaticRootPlugin from './staticRootPlugin/plugin.js';
|
||||
import SummaryWidget from './summaryWidget/plugin.js';
|
||||
@ -141,6 +142,7 @@ plugins.Filters = Filters;
|
||||
plugins.ObjectMigration = ObjectMigration;
|
||||
plugins.GoToOriginalAction = GoToOriginalAction;
|
||||
plugins.OpenInNewTabAction = OpenInNewTabAction;
|
||||
plugins.ReloadAction = ReloadAction;
|
||||
plugins.ClearData = ClearData;
|
||||
plugins.WebPage = WebPagePlugin;
|
||||
plugins.Espresso = Espresso;
|
||||
|
37
src/plugins/reloadAction/ReloadAction.js
Normal file
37
src/plugins/reloadAction/ReloadAction.js
Normal file
@ -0,0 +1,37 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
export default class ReloadAction {
|
||||
constructor(openmct) {
|
||||
this.name = 'Reload';
|
||||
this.key = 'reload';
|
||||
this.description = 'Reload this object and its children';
|
||||
this.group = 'action';
|
||||
this.priority = 10;
|
||||
this.cssClass = 'icon-refresh';
|
||||
|
||||
this.openmct = openmct;
|
||||
}
|
||||
invoke(objectPath, view) {
|
||||
const domainObject = objectPath[0];
|
||||
this.openmct.objectViews.emit('reload', domainObject);
|
||||
}
|
||||
}
|
28
src/plugins/reloadAction/plugin.js
Normal file
28
src/plugins/reloadAction/plugin.js
Normal file
@ -0,0 +1,28 @@
|
||||
/*****************************************************************************
|
||||
* 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 ReloadAction from './ReloadAction.js';
|
||||
|
||||
export default function plugin() {
|
||||
return function install(openmct) {
|
||||
openmct.actions.register(new ReloadAction(openmct));
|
||||
};
|
||||
}
|
@ -59,26 +59,21 @@ export default class RemoteClock extends DefaultClock {
|
||||
}
|
||||
|
||||
start() {
|
||||
this.openmct.objects
|
||||
.get(this.identifier)
|
||||
.then((domainObject) => {
|
||||
// The start method is called when at least one listener registers with the clock.
|
||||
// When the clock is changed, listeners are unregistered from the clock and the stop method is called.
|
||||
// Sometimes, the objects.get call above does not resolve before the stop method is called.
|
||||
// So when we proceed with the clock subscription below, we first need to ensure that there is at least one listener for our clock.
|
||||
if (this.eventNames().length === 0) {
|
||||
return;
|
||||
}
|
||||
this.openmct.time.on('timeSystem', this._timeSystemChange);
|
||||
this.timeTelemetryObject = domainObject;
|
||||
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
|
||||
this._timeSystemChange();
|
||||
this._requestLatest();
|
||||
this._subscribe();
|
||||
})
|
||||
.catch((error) => {
|
||||
throw new Error(error);
|
||||
});
|
||||
this.openmct.objects.get(this.identifier).then((domainObject) => {
|
||||
// The start method is called when at least one listener registers with the clock.
|
||||
// When the clock is changed, listeners are unregistered from the clock and the stop method is called.
|
||||
// Sometimes, the objects.get call above does not resolve before the stop method is called.
|
||||
// So when we proceed with the clock subscription below, we first need to ensure that there is at least one listener for our clock.
|
||||
if (this.eventNames().length === 0) {
|
||||
return;
|
||||
}
|
||||
this.openmct.time.on('timeSystem', this._timeSystemChange);
|
||||
this.timeTelemetryObject = domainObject;
|
||||
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
|
||||
this._timeSystemChange();
|
||||
this._requestLatest();
|
||||
this._subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
|
@ -21,8 +21,10 @@
|
||||
*****************************************************************************/
|
||||
import Tabs from './tabs.js';
|
||||
|
||||
export default function plugin() {
|
||||
export default function plugin(options) {
|
||||
return function install(openmct) {
|
||||
const eagerLoad = options?.eagerLoad ?? false;
|
||||
|
||||
openmct.objectViews.addProvider(new Tabs(openmct));
|
||||
|
||||
openmct.types.addType('tabs', {
|
||||
@ -32,13 +34,13 @@ export default function plugin() {
|
||||
cssClass: 'icon-tabs-view',
|
||||
initialize(domainObject) {
|
||||
domainObject.composition = [];
|
||||
domainObject.keep_alive = true;
|
||||
domainObject.keep_alive = eagerLoad;
|
||||
},
|
||||
form: [
|
||||
{
|
||||
key: 'keep_alive',
|
||||
name: 'Eager Load Tabs',
|
||||
control: 'select',
|
||||
control: 'toggleSwitch',
|
||||
options: [
|
||||
{
|
||||
name: 'True',
|
||||
|
@ -30,7 +30,8 @@ describe('the plugin', function () {
|
||||
let element;
|
||||
let child;
|
||||
let openmct;
|
||||
let tabsLayoutDefinition;
|
||||
let tabsType;
|
||||
|
||||
const testViewObject = {
|
||||
identifier: {
|
||||
key: 'mock-tabs-object',
|
||||
@ -85,8 +86,7 @@ describe('the plugin', function () {
|
||||
|
||||
beforeEach((done) => {
|
||||
openmct = createOpenMct();
|
||||
openmct.install(new TabsLayout());
|
||||
tabsLayoutDefinition = openmct.types.get('tabs');
|
||||
tabsType = openmct.types.get('tabs');
|
||||
|
||||
element = document.createElement('div');
|
||||
child = document.createElement('div');
|
||||
@ -100,15 +100,56 @@ describe('the plugin', function () {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
child = undefined;
|
||||
element = undefined;
|
||||
|
||||
return resetApplicationState(openmct);
|
||||
});
|
||||
|
||||
it('defines a tabs object type with the correct key', () => {
|
||||
expect(tabsLayoutDefinition.definition.name).toEqual('Tabs View');
|
||||
it('is installed by default and provides a tabs object', () => {
|
||||
expect(tabsType.definition.name).toEqual('Tabs View');
|
||||
});
|
||||
|
||||
it('is creatable', () => {
|
||||
expect(tabsLayoutDefinition.definition.creatable).toEqual(true);
|
||||
it('the tabs object is creatable', () => {
|
||||
expect(tabsType.definition.creatable).toEqual(true);
|
||||
});
|
||||
|
||||
it('sets eager load to false by default', () => {
|
||||
const tabsObject = {
|
||||
identifier: {
|
||||
key: 'some-tab-object',
|
||||
namespace: ''
|
||||
},
|
||||
type: 'tabs'
|
||||
};
|
||||
|
||||
tabsType.definition.initialize(tabsObject);
|
||||
|
||||
expect(tabsObject.keep_alive).toBeFalse();
|
||||
});
|
||||
|
||||
it('can be installed with eager load defaulting to true', () => {
|
||||
const options = {
|
||||
eagerLoad: true
|
||||
};
|
||||
const openmct2 = createOpenMct();
|
||||
openmct2.install(new TabsLayout(options));
|
||||
openmct2.startHeadless();
|
||||
|
||||
const tabsObject = {
|
||||
identifier: {
|
||||
key: 'some-tab-object',
|
||||
namespace: ''
|
||||
},
|
||||
type: 'tabs'
|
||||
};
|
||||
|
||||
const overriddenTabsType = openmct2.types.get('tabs');
|
||||
overriddenTabsType.definition.initialize(tabsObject);
|
||||
|
||||
expect(tabsObject.keep_alive).toBeTrue();
|
||||
|
||||
return resetApplicationState(openmct2);
|
||||
});
|
||||
|
||||
describe('the view', function () {
|
||||
|
@ -37,10 +37,11 @@ export default class TelemetryTable extends EventEmitter {
|
||||
|
||||
this.domainObject = domainObject;
|
||||
this.openmct = openmct;
|
||||
this.rowCount = 100;
|
||||
this.tableComposition = undefined;
|
||||
this.datumCache = [];
|
||||
this.configuration = new TelemetryTableConfiguration(domainObject, openmct);
|
||||
this.telemetryMode = this.configuration.getTelemetryMode();
|
||||
this.rowLimit = this.configuration.getRowLimit();
|
||||
this.paused = false;
|
||||
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
|
||||
@ -101,18 +102,40 @@ export default class TelemetryTable extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
updateTelemetryMode(mode) {
|
||||
if (this.telemetryMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.telemetryMode = mode;
|
||||
|
||||
this.updateRowLimit();
|
||||
|
||||
this.clearAndResubscribe();
|
||||
}
|
||||
|
||||
updateRowLimit() {
|
||||
if (this.telemetryMode === 'performance') {
|
||||
this.tableRows.setLimit(this.rowLimit);
|
||||
} else {
|
||||
this.tableRows.removeLimit();
|
||||
}
|
||||
}
|
||||
|
||||
createTableRowCollections() {
|
||||
this.tableRows = new TableRowCollection();
|
||||
|
||||
//Fetch any persisted default sort
|
||||
let sortOptions = this.configuration.getConfiguration().sortOptions;
|
||||
|
||||
//If no persisted sort order, default to sorting by time system, ascending.
|
||||
//If no persisted sort order, default to sorting by time system, descending.
|
||||
sortOptions = sortOptions || {
|
||||
key: this.openmct.time.timeSystem().key,
|
||||
direction: 'asc'
|
||||
direction: 'desc'
|
||||
};
|
||||
|
||||
this.updateRowLimit();
|
||||
|
||||
this.tableRows.sortBy(sortOptions);
|
||||
this.tableRows.on('resetRowsFromAllData', this.resetRowsFromAllData);
|
||||
}
|
||||
@ -144,6 +167,11 @@ export default class TelemetryTable extends EventEmitter {
|
||||
|
||||
this.removeTelemetryCollection(keyString);
|
||||
|
||||
if (this.telemetryMode === 'performance') {
|
||||
requestOptions.size = this.rowLimit;
|
||||
requestOptions.enforceSize = true;
|
||||
}
|
||||
|
||||
this.telemetryCollections[keyString] = this.openmct.telemetry.requestCollection(
|
||||
telemetryObject,
|
||||
requestOptions
|
||||
|
@ -48,6 +48,10 @@ export default class TelemetryTableConfiguration extends EventEmitter {
|
||||
configuration.columnOrder = configuration.columnOrder || [];
|
||||
configuration.cellFormat = configuration.cellFormat || {};
|
||||
configuration.autosize = configuration.autosize === undefined ? true : configuration.autosize;
|
||||
// anything that doesn't have a telemetryMode existed before the change and should stay as it was for consistency
|
||||
configuration.telemetryMode = configuration.telemetryMode ?? 'unlimited';
|
||||
configuration.persistModeChange = configuration.persistModeChange ?? true;
|
||||
configuration.rowLimit = configuration.rowLimit ?? 50;
|
||||
|
||||
return configuration;
|
||||
}
|
||||
@ -137,6 +141,42 @@ export default class TelemetryTableConfiguration extends EventEmitter {
|
||||
}, {});
|
||||
}
|
||||
|
||||
getTelemetryMode() {
|
||||
let configuration = this.getConfiguration();
|
||||
|
||||
return configuration.telemetryMode;
|
||||
}
|
||||
|
||||
setTelemetryMode(mode) {
|
||||
let configuration = this.getConfiguration();
|
||||
configuration.telemetryMode = mode;
|
||||
this.updateConfiguration(configuration);
|
||||
}
|
||||
|
||||
getRowLimit() {
|
||||
let configuration = this.getConfiguration();
|
||||
|
||||
return configuration.rowLimit;
|
||||
}
|
||||
|
||||
setRowLimit(limit) {
|
||||
let configuration = this.getConfiguration();
|
||||
configuration.rowLimit = limit;
|
||||
this.updateConfiguration(configuration);
|
||||
}
|
||||
|
||||
getPersistModeChange() {
|
||||
let configuration = this.getConfiguration();
|
||||
|
||||
return configuration.persistModeChange;
|
||||
}
|
||||
|
||||
setPersistModeChange(value) {
|
||||
let configuration = this.getConfiguration();
|
||||
configuration.persistModeChange = value;
|
||||
this.updateConfiguration(configuration);
|
||||
}
|
||||
|
||||
getColumnWidths() {
|
||||
let configuration = this.getConfiguration();
|
||||
|
||||
|
@ -20,17 +20,57 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
export default {
|
||||
name: 'Telemetry Table',
|
||||
description:
|
||||
'Display values for one or more telemetry end points in a scrolling table. Each row is a time-stamped value.',
|
||||
creatable: true,
|
||||
cssClass: 'icon-tabular-scrolling',
|
||||
initialize(domainObject) {
|
||||
domainObject.composition = [];
|
||||
domainObject.configuration = {
|
||||
columnWidths: {},
|
||||
hiddenColumns: {}
|
||||
};
|
||||
}
|
||||
};
|
||||
export default function getTelemetryTableType(options = {}) {
|
||||
const { telemetryMode = 'performance', persistModeChanges = true, rowLimit = 50 } = options;
|
||||
|
||||
return {
|
||||
name: 'Telemetry Table',
|
||||
description:
|
||||
'Display values for one or more telemetry end points in a scrolling table. Each row is a time-stamped value.',
|
||||
creatable: true,
|
||||
cssClass: 'icon-tabular-scrolling',
|
||||
form: [
|
||||
{
|
||||
key: 'telemetryMode',
|
||||
name: 'Telemetry Mode',
|
||||
control: 'select',
|
||||
options: [
|
||||
{
|
||||
value: 'performance',
|
||||
name: 'Performance Mode'
|
||||
},
|
||||
{
|
||||
value: 'unlimited',
|
||||
name: 'Unlimited Mode'
|
||||
}
|
||||
],
|
||||
cssClass: 'l-inline',
|
||||
property: ['configuration', 'telemetryMode']
|
||||
},
|
||||
{
|
||||
name: 'Persist Telemetry Mode Changes',
|
||||
control: 'toggleSwitch',
|
||||
cssClass: 'l-input',
|
||||
key: 'persistModeChanges',
|
||||
property: ['configuration', 'persistModeChanges']
|
||||
},
|
||||
{
|
||||
name: 'Performance Mode Row Limit',
|
||||
control: 'toggleSwitch',
|
||||
cssClass: 'l-input',
|
||||
key: 'rowLimit',
|
||||
property: ['configuration', 'rowLimit']
|
||||
}
|
||||
],
|
||||
initialize(domainObject) {
|
||||
domainObject.composition = [];
|
||||
domainObject.configuration = {
|
||||
columnWidths: {},
|
||||
hiddenColumns: {},
|
||||
telemetryMode,
|
||||
persistModeChanges,
|
||||
rowLimit
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ import TableComponent from './components/TableComponent.vue';
|
||||
import TelemetryTable from './TelemetryTable.js';
|
||||
|
||||
export default class TelemetryTableView {
|
||||
constructor(openmct, domainObject, objectPath) {
|
||||
constructor(openmct, domainObject, objectPath, options) {
|
||||
this.openmct = openmct;
|
||||
this.domainObject = domainObject;
|
||||
this.objectPath = objectPath;
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
import TelemetryTableView from './TelemetryTableView.js';
|
||||
|
||||
export default function TelemetryTableViewProvider(openmct) {
|
||||
export default function TelemetryTableViewProvider(openmct, options) {
|
||||
function hasTelemetry(domainObject) {
|
||||
if (!Object.prototype.hasOwnProperty.call(domainObject, 'telemetry')) {
|
||||
return false;
|
||||
@ -44,7 +44,7 @@ export default function TelemetryTableViewProvider(openmct) {
|
||||
return domainObject.type === 'table';
|
||||
},
|
||||
view(domainObject, objectPath) {
|
||||
return new TelemetryTableView(openmct, domainObject, objectPath);
|
||||
return new TelemetryTableView(openmct, domainObject, objectPath, options);
|
||||
},
|
||||
priority() {
|
||||
return 1;
|
||||
|
@ -129,6 +129,15 @@ export default class TableRowCollection extends EventEmitter {
|
||||
this.rows[index] = foundRow;
|
||||
}
|
||||
|
||||
setLimit(rowLimit) {
|
||||
this.rowLimit = rowLimit;
|
||||
}
|
||||
|
||||
removeLimit() {
|
||||
this.rowLimit = null;
|
||||
delete this.rowLimit;
|
||||
}
|
||||
|
||||
sortCollection(rows) {
|
||||
const sortedRows = _.orderBy(
|
||||
rows,
|
||||
@ -363,10 +372,22 @@ export default class TableRowCollection extends EventEmitter {
|
||||
}
|
||||
|
||||
getRows() {
|
||||
if (this.rowLimit && this.rows.length > this.rowLimit) {
|
||||
if (this.sortOptions.direction === 'desc') {
|
||||
return this.rows.slice(0, this.rowLimit);
|
||||
} else {
|
||||
return this.rows.slice(-this.rowLimit);
|
||||
}
|
||||
}
|
||||
|
||||
return this.rows;
|
||||
}
|
||||
|
||||
getRowsLength() {
|
||||
if (this.rowLimit && this.rows.length > this.rowLimit) {
|
||||
return this.rowLimit;
|
||||
}
|
||||
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
|
@ -22,8 +22,8 @@
|
||||
<template>
|
||||
<td
|
||||
ref="tableCell"
|
||||
:aria-label="formattedValue"
|
||||
:title="formattedValue"
|
||||
:aria-label="`${columnKey} table cell ${formattedValue}`"
|
||||
@click="selectCell($event.currentTarget, columnKey)"
|
||||
@mouseover.ctrl="showToolTip"
|
||||
@mouseleave="hideToolTip"
|
||||
|
@ -222,6 +222,7 @@
|
||||
ref="contentTable"
|
||||
class="c-table__body c-telemetry-table__body js-telemetry-table__content"
|
||||
:style="{ height: totalHeight + 'px' }"
|
||||
:aria-label="`${table.domainObject.name} table content`"
|
||||
>
|
||||
<tbody>
|
||||
<telemetry-table-row
|
||||
@ -233,7 +234,7 @@
|
||||
:object-path="objectPath"
|
||||
:row-offset="rowOffset"
|
||||
:row-height="rowHeight"
|
||||
:row="row"
|
||||
:row="getRow(rowIndex)"
|
||||
:marked="row.marked"
|
||||
@mark="markRow"
|
||||
@unmark="unmarkRow"
|
||||
@ -276,6 +277,8 @@
|
||||
class="c-telemetry-table__footer"
|
||||
:marked-rows="markedRows.length"
|
||||
:total-rows="totalNumberOfRows"
|
||||
:telemetry-mode="telemetryMode"
|
||||
@telemetry-mode-change="updateTelemetryMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -292,6 +295,7 @@ import CSVExporter from '../../../exporters/CSVExporter.js';
|
||||
import ProgressBar from '../../../ui/components/ProgressBar.vue';
|
||||
import Search from '../../../ui/components/SearchComponent.vue';
|
||||
import ToggleSwitch from '../../../ui/components/ToggleSwitch.vue';
|
||||
import throttle from '../../../utils/throttle';
|
||||
import SizingRow from './SizingRow.vue';
|
||||
import TableColumnHeader from './TableColumnHeader.vue';
|
||||
import TableFooterIndicator from './TableFooterIndicator.vue';
|
||||
@ -300,7 +304,7 @@ import TelemetryTableRow from './TableRow.vue';
|
||||
const VISIBLE_ROW_COUNT = 100;
|
||||
const ROW_HEIGHT = 17;
|
||||
const RESIZE_POLL_INTERVAL = 200;
|
||||
const AUTO_SCROLL_TRIGGER_HEIGHT = 100;
|
||||
const AUTO_SCROLL_TRIGGER_HEIGHT = ROW_HEIGHT * 3;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -384,7 +388,9 @@ export default {
|
||||
enableRegexSearch: {},
|
||||
hideHeaders: configuration.hideHeaders,
|
||||
totalNumberOfRows: 0,
|
||||
rowContext: {}
|
||||
rowContext: {},
|
||||
telemetryMode: configuration.telemetryMode,
|
||||
persistModeChanges: configuration.persistModeChanges
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -437,6 +443,12 @@ export default {
|
||||
watch: {
|
||||
loading: {
|
||||
handler(isLoading) {
|
||||
if (isLoading) {
|
||||
this.setLoadingPromise();
|
||||
} else {
|
||||
this.loadFinishResolve();
|
||||
}
|
||||
|
||||
if (this.viewActionsCollection) {
|
||||
let action = isLoading ? 'disable' : 'enable';
|
||||
this.viewActionsCollection[action](['export-csv-all']);
|
||||
@ -503,6 +515,8 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
this.updateVisibleRows = throttle(this.updateVisibleRows, 1000);
|
||||
|
||||
this.table.on('object-added', this.addObject);
|
||||
this.table.on('object-removed', this.removeObject);
|
||||
this.table.on('refresh', this.clearRowsAndRerender);
|
||||
@ -555,6 +569,12 @@ export default {
|
||||
this.table.destroy();
|
||||
},
|
||||
methods: {
|
||||
setLoadingPromise() {
|
||||
this.loadFinishResolve = null;
|
||||
this.isFinishedLoading = new Promise((resolve, reject) => {
|
||||
this.loadFinishResolve = resolve;
|
||||
});
|
||||
},
|
||||
updateVisibleRows() {
|
||||
if (!this.updatingView) {
|
||||
this.updatingView = this.renderWhenVisible(() => {
|
||||
@ -632,7 +652,21 @@ export default {
|
||||
|
||||
this.calculateScrollbarWidth();
|
||||
},
|
||||
getRow(rowIndex) {
|
||||
return toRaw(this.visibleRows[rowIndex]);
|
||||
},
|
||||
sortBy(columnKey) {
|
||||
let timeSystemKey = this.openmct.time.getTimeSystem().key;
|
||||
|
||||
if (this.telemetryMode === 'performance' && columnKey !== timeSystemKey) {
|
||||
this.confirmUnlimitedMode('Switch to Unlimited Telemetry and Sort', () => {
|
||||
this.initiateSort(columnKey);
|
||||
});
|
||||
} else {
|
||||
this.initiateSort(columnKey);
|
||||
}
|
||||
},
|
||||
initiateSort(columnKey) {
|
||||
// If sorting by the same column, flip the sort direction.
|
||||
if (this.sortOptions.key === columnKey) {
|
||||
if (this.sortOptions.direction === 'asc') {
|
||||
@ -643,7 +677,7 @@ export default {
|
||||
} else {
|
||||
this.sortOptions = {
|
||||
key: columnKey,
|
||||
direction: 'asc'
|
||||
direction: 'desc'
|
||||
};
|
||||
}
|
||||
|
||||
@ -653,7 +687,7 @@ export default {
|
||||
this.updateVisibleRows();
|
||||
this.synchronizeScrollX();
|
||||
|
||||
if (this.shouldSnapToBottom()) {
|
||||
if (this.shouldAutoScroll()) {
|
||||
this.autoScroll = true;
|
||||
} else {
|
||||
// If user scrolls away from bottom, disable auto-scroll.
|
||||
@ -661,13 +695,17 @@ export default {
|
||||
this.autoScroll = false;
|
||||
}
|
||||
},
|
||||
shouldSnapToBottom() {
|
||||
shouldAutoScroll() {
|
||||
if (this.sortOptions.direction === 'desc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.scrollable.scrollTop >=
|
||||
this.scrollable.scrollHeight - this.scrollable.offsetHeight - AUTO_SCROLL_TRIGGER_HEIGHT
|
||||
);
|
||||
},
|
||||
scrollToBottom() {
|
||||
initiateAutoScroll() {
|
||||
this.scrollable.scrollTop = Number.MAX_SAFE_INTEGER;
|
||||
},
|
||||
synchronizeScrollX() {
|
||||
@ -716,7 +754,7 @@ export default {
|
||||
}
|
||||
|
||||
if (this.autoScroll) {
|
||||
this.scrollToBottom();
|
||||
this.initiateAutoScroll();
|
||||
}
|
||||
|
||||
this.updateVisibleRows();
|
||||
@ -743,12 +781,25 @@ export default {
|
||||
headers: headerKeys
|
||||
});
|
||||
},
|
||||
exportAllDataAsCSV() {
|
||||
getTableRowData() {
|
||||
const justTheData = this.table.tableRows
|
||||
.getRows()
|
||||
.map((row) => row.getFormattedDatum(this.headers));
|
||||
|
||||
this.exportAsCSV(justTheData);
|
||||
return justTheData;
|
||||
},
|
||||
exportAllDataAsCSV() {
|
||||
if (this.telemetryMode === 'performance') {
|
||||
this.confirmUnlimitedMode('Switch to Unlimited Telemetry and Export', () => {
|
||||
const data = this.getTableRowData();
|
||||
|
||||
this.exportAsCSV(data);
|
||||
});
|
||||
} else {
|
||||
const data = this.getTableRowData();
|
||||
|
||||
this.exportAsCSV(data);
|
||||
}
|
||||
},
|
||||
exportMarkedDataAsCSV() {
|
||||
const data = this.table.tableRows
|
||||
@ -842,7 +893,7 @@ export default {
|
||||
// On some resize events scrollTop is reset to 0. Possibly due to a transition we're using?
|
||||
// Need to preserve scroll position in this case.
|
||||
if (this.autoScroll) {
|
||||
this.scrollToBottom();
|
||||
this.initiateAutoScroll();
|
||||
} else {
|
||||
this.scrollable.scrollTop = scrollTop;
|
||||
}
|
||||
@ -1099,6 +1150,54 @@ export default {
|
||||
this.viewActionsCollection.hide(['expand-columns']);
|
||||
}
|
||||
},
|
||||
confirmUnlimitedMode(
|
||||
label,
|
||||
callback,
|
||||
message = 'A new data request for all telemetry values for all endpoints will be made which will take some time. Do you want to continue?'
|
||||
) {
|
||||
const dialog = this.openmct.overlays.dialog({
|
||||
iconClass: 'alert',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
label,
|
||||
emphasis: true,
|
||||
callback: async () => {
|
||||
this.updateTelemetryMode();
|
||||
await this.isFinishedLoading;
|
||||
|
||||
callback();
|
||||
|
||||
dialog.dismiss();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
callback: () => {
|
||||
dialog.dismiss();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
updateTelemetryMode() {
|
||||
this.telemetryMode = this.telemetryMode === 'unlimited' ? 'performance' : 'unlimited';
|
||||
|
||||
if (this.persistModeChanges) {
|
||||
this.table.configuration.setTelemetryMode(this.telemetryMode);
|
||||
}
|
||||
|
||||
this.table.updateTelemetryMode(this.telemetryMode);
|
||||
|
||||
const timeSystemKey = this.openmct.time.getTimeSystem().key;
|
||||
|
||||
if (this.telemetryMode === 'performance' && this.sortOptions.key !== timeSystemKey) {
|
||||
this.openmct.notifications.info(
|
||||
'Switched to Performance Mode: Table now sorted by time for optimized efficiency.'
|
||||
);
|
||||
this.initiateSort(timeSystemKey);
|
||||
}
|
||||
},
|
||||
setRowHeight(height) {
|
||||
this.rowHeight = height;
|
||||
this.setHeight();
|
||||
|
@ -36,11 +36,11 @@
|
||||
|
||||
<div class="c-table-indicator__counts">
|
||||
<span
|
||||
:aria-label="totalRows + ' rows visible after any filtering'"
|
||||
:title="totalRows + ' rows visible after any filtering'"
|
||||
:aria-label="rowCountTitle"
|
||||
:title="rowCountTitle"
|
||||
class="c-table-indicator__elem c-table-indicator__row-count"
|
||||
>
|
||||
{{ totalRows }} Rows
|
||||
{{ rowCount }} Rows
|
||||
</span>
|
||||
|
||||
<span
|
||||
@ -51,6 +51,10 @@
|
||||
>
|
||||
{{ markedRows }} Marked
|
||||
</span>
|
||||
|
||||
<button :title="telemetryModeButtonTitle" class="c-button" @click="toggleTelemetryMode">
|
||||
{{ telemetryModeButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -74,8 +78,13 @@ export default {
|
||||
totalRows: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
telemetryMode: {
|
||||
type: String,
|
||||
default: 'performance'
|
||||
}
|
||||
},
|
||||
emits: ['telemetry-mode-change'],
|
||||
data() {
|
||||
return {
|
||||
filterNames: [],
|
||||
@ -93,6 +102,9 @@ export default {
|
||||
return !_.isEqual(filtersToCompare, _.omit(filters, [USE_GLOBAL]));
|
||||
});
|
||||
},
|
||||
isUnlimitedMode() {
|
||||
return this.telemetryMode === 'unlimited';
|
||||
},
|
||||
label() {
|
||||
if (this.hasMixedFilters) {
|
||||
return FILTER_INDICATOR_LABEL_MIXED;
|
||||
@ -100,6 +112,22 @@ export default {
|
||||
return FILTER_INDICATOR_LABEL;
|
||||
}
|
||||
},
|
||||
rowCount() {
|
||||
return this.isUnlimitedMode ? this.totalRows : 'LATEST 50';
|
||||
},
|
||||
rowCountTitle() {
|
||||
return this.isUnlimitedMode
|
||||
? this.totalRows + ' rows visible after any filtering'
|
||||
: 'performance mode limited to 50 rows';
|
||||
},
|
||||
telemetryModeButtonLabel() {
|
||||
return this.isUnlimitedMode ? 'SHOW LATEST 50' : 'SHOW ALL';
|
||||
},
|
||||
telemetryModeButtonTitle() {
|
||||
return this.isUnlimitedMode
|
||||
? 'Change to Performance mode (latest 50 values)'
|
||||
: 'Change to show all values';
|
||||
},
|
||||
title() {
|
||||
if (this.hasMixedFilters) {
|
||||
return FILTER_INDICATOR_TITLE_MIXED;
|
||||
@ -117,6 +145,9 @@ export default {
|
||||
this.table.configuration.off('change', this.handleConfigurationChanges);
|
||||
},
|
||||
methods: {
|
||||
toggleTelemetryMode() {
|
||||
this.$emit('telemetry-mode-change');
|
||||
},
|
||||
setFilterNames() {
|
||||
let names = [];
|
||||
let composition = this.openmct.composition.get(this.table.configuration.domainObject);
|
||||
|
@ -18,6 +18,7 @@
|
||||
&__counts {
|
||||
//background: rgba(deeppink, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
|
@ -123,15 +123,6 @@
|
||||
.is-editing .l-layout__frame & {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: $colorSelectedBg !important;
|
||||
color: $colorSelectedFg !important;
|
||||
td {
|
||||
background: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
@ -169,30 +160,13 @@
|
||||
}
|
||||
|
||||
&__footer {
|
||||
$pt: 2px;
|
||||
border-top: 1px solid $colorInteriorBorder;
|
||||
margin-top: $interiorMargin;
|
||||
padding: $pt 0;
|
||||
margin-bottom: $interiorMarginSm;
|
||||
overflow: hidden;
|
||||
transition: all 250ms;
|
||||
|
||||
&:not(.is-filtering) {
|
||||
.c-frame & {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.c-frame & {
|
||||
// target .c-frame .c-telemetry-table {}
|
||||
$pt: 2px;
|
||||
&:hover {
|
||||
.c-telemetry-table__footer:not(.is-filtering) {
|
||||
height: $pt + 16px;
|
||||
padding: initial;
|
||||
visibility: visible;
|
||||
.c-frame & {
|
||||
.c-button {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -203,6 +177,18 @@ td {
|
||||
@include isLimit();
|
||||
}
|
||||
|
||||
.c-table tr {
|
||||
&[s-selected],
|
||||
&.is-selected {
|
||||
background-color: $colorSelectedBg !important;
|
||||
color: $colorSelectedFg !important;
|
||||
td {
|
||||
background: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/******************************* SPECIFIC CASE WRAPPERS */
|
||||
.is-editing {
|
||||
.c-telemetry-table__headers__labels {
|
||||
|
@ -19,16 +19,17 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import TableConfigurationViewProvider from './TableConfigurationViewProvider.js';
|
||||
import TelemetryTableType from './TelemetryTableType.js';
|
||||
import getTelemetryTableType from './TelemetryTableType.js';
|
||||
import TelemetryTableViewProvider from './TelemetryTableViewProvider.js';
|
||||
import TelemetryTableViewActions from './ViewActions.js';
|
||||
|
||||
export default function plugin() {
|
||||
export default function plugin(options) {
|
||||
return function install(openmct) {
|
||||
openmct.objectViews.addProvider(new TelemetryTableViewProvider(openmct));
|
||||
openmct.objectViews.addProvider(new TelemetryTableViewProvider(openmct, options));
|
||||
openmct.inspectorViews.addProvider(new TableConfigurationViewProvider(openmct));
|
||||
openmct.types.addType('table', TelemetryTableType);
|
||||
openmct.types.addType('table', getTelemetryTableType(options));
|
||||
openmct.composition.addPolicy((parent, child) => {
|
||||
if (parent.type === 'table') {
|
||||
return Object.prototype.hasOwnProperty.call(child, 'telemetry');
|
||||
|
@ -27,6 +27,7 @@
|
||||
:header-items="headerItems"
|
||||
:default-sort="defaultSort"
|
||||
class="sticky"
|
||||
@item-selection-changed="setSelectionForActivity"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -38,7 +39,7 @@ import { v4 as uuid } from 'uuid';
|
||||
import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js';
|
||||
import ListView from '../../ui/components/List/ListView.vue';
|
||||
import { getPreciseDuration } from '../../utils/duration.js';
|
||||
import { getValidatedData, getValidatedGroups } from '../plan/util.js';
|
||||
import { getFilteredValues, getValidatedData, getValidatedGroups } from '../plan/util.js';
|
||||
import { SORT_ORDER_OPTIONS } from './constants.js';
|
||||
|
||||
const SCROLL_TIMEOUT = 10000;
|
||||
@ -208,22 +209,22 @@ export default {
|
||||
this.setViewFromConfig(mutatedObject.configuration);
|
||||
},
|
||||
setViewFromConfig(configuration) {
|
||||
this.filterValue = configuration.filter || '';
|
||||
this.filterMetadataValue = configuration.filterMetadata || '';
|
||||
if (this.isEditing) {
|
||||
this.filterValue = configuration.filter;
|
||||
this.hideAll = false;
|
||||
this.listActivities();
|
||||
} else {
|
||||
this.filterValue = configuration.filter;
|
||||
this.setSort();
|
||||
this.listActivities();
|
||||
}
|
||||
this.listActivities();
|
||||
},
|
||||
updateTimestamp(timestamp) {
|
||||
//The clock never stops ticking
|
||||
this.updateTimeStampAndListActivities(timestamp);
|
||||
},
|
||||
setFixedTime() {
|
||||
this.filterValue = this.domainObject.configuration.filter;
|
||||
this.filterValue = this.domainObject.configuration.filter || '';
|
||||
this.filterMetadataValue = this.domainObject.configuration.filterMetadata || '';
|
||||
this.isFixedTime = !this.timeContext.isRealTime();
|
||||
if (this.isFixedTime) {
|
||||
this.hideAll = false;
|
||||
@ -326,7 +327,21 @@ export default {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasFilterMatch = this.filterByName(activity.name);
|
||||
let hasNameMatch = false;
|
||||
let hasMetadataMatch = false;
|
||||
if (this.filterValue || this.filterMetadataValue) {
|
||||
if (this.filterValue) {
|
||||
hasNameMatch = this.filterByName(activity.name);
|
||||
}
|
||||
if (this.filterMetadataValue) {
|
||||
hasMetadataMatch = this.filterByMetadata(activity);
|
||||
}
|
||||
} else {
|
||||
hasNameMatch = true;
|
||||
hasMetadataMatch = true;
|
||||
}
|
||||
|
||||
const hasFilterMatch = hasNameMatch || hasMetadataMatch;
|
||||
if (hasFilterMatch === false || this.hideAll === true) {
|
||||
return false;
|
||||
}
|
||||
@ -354,6 +369,17 @@ export default {
|
||||
return regex.test(name.toLowerCase());
|
||||
});
|
||||
},
|
||||
filterByMetadata(activity) {
|
||||
const filters = this.filterMetadataValue.split(',');
|
||||
|
||||
return filters.some((search) => {
|
||||
const normalized = search.trim().toLowerCase();
|
||||
const regex = new RegExp(normalized);
|
||||
const activityValues = getFilteredValues(activity);
|
||||
|
||||
return regex.test(activityValues.join().toLowerCase());
|
||||
});
|
||||
},
|
||||
// Add activity classes, increase activity counts by type,
|
||||
// set indices of the first occurrences of current and future activities - used for scrolling
|
||||
styleActivity(activity, index) {
|
||||
@ -516,6 +542,29 @@ export default {
|
||||
setEditState(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
this.setViewFromConfig(this.domainObject.configuration);
|
||||
},
|
||||
setSelectionForActivity(activity, element) {
|
||||
const multiSelect = false;
|
||||
|
||||
this.openmct.selection.select(
|
||||
[
|
||||
{
|
||||
element: element,
|
||||
context: {
|
||||
type: 'activity',
|
||||
activity: activity
|
||||
}
|
||||
},
|
||||
{
|
||||
element: this.openmct.layout.$refs.browseObject.$el,
|
||||
context: {
|
||||
item: this.domainObject,
|
||||
supportsMultiSelect: false
|
||||
}
|
||||
}
|
||||
],
|
||||
multiSelect
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -22,20 +22,57 @@
|
||||
<template>
|
||||
<li class="c-inspect-properties__row">
|
||||
<div v-if="canEdit" class="c-inspect-properties__hint span-all">
|
||||
Filter this view by comma-separated keywords.
|
||||
Filter this view by comma-separated keywords. Filtering uses an 'OR' method.
|
||||
</div>
|
||||
<div class="c-inspect-properties__label" title="Filter by keyword.">Filters</div>
|
||||
<div v-if="canEdit" class="c-inspect-properties__value" :class="{ 'form-error': hasError }">
|
||||
<div class="c-inspect-properties__label" aria-label="Activity Names" title="Filter by keyword.">
|
||||
Activity Names
|
||||
</div>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="c-inspect-properties__value"
|
||||
:class="{ 'form-error': hasFilterError }"
|
||||
>
|
||||
<textarea
|
||||
v-model="filterValue"
|
||||
class="c-input--flex"
|
||||
type="text"
|
||||
@keydown.enter.exact.stop="forceBlur($event)"
|
||||
@keyup="updateForm($event, 'filter')"
|
||||
@keyup="updateNameFilter($event, 'filter')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-else class="c-inspect-properties__value">
|
||||
{{ filterValue }}
|
||||
<template v-if="filterValue.length > 0">
|
||||
{{ filterValue }}
|
||||
</template>
|
||||
<template v-else> No filters applied </template>
|
||||
</div>
|
||||
</li>
|
||||
<li class="c-inspect-properties__row">
|
||||
<div
|
||||
class="c-inspect-properties__label"
|
||||
aria-label="Meta-data Properties"
|
||||
title="Filter by keyword."
|
||||
>
|
||||
Meta-data Properties
|
||||
</div>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="c-inspect-properties__value"
|
||||
:class="{ 'form-error': hasMetadataFilterError }"
|
||||
>
|
||||
<textarea
|
||||
v-model="filterMetadataValue"
|
||||
class="c-input--flex"
|
||||
type="text"
|
||||
@keydown.enter.exact.stop="forceBlur($event)"
|
||||
@keyup="updateMetadataFilter($event, 'filterMetadata')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-else class="c-inspect-properties__value">
|
||||
<template v-if="filterMetadataValue.length > 0">
|
||||
{{ filterMetadataValue }}
|
||||
</template>
|
||||
<template v-else> No filters applied </template>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@ -48,7 +85,9 @@ export default {
|
||||
return {
|
||||
isEditing: this.openmct.editor.isEditing(),
|
||||
filterValue: this.domainObject.configuration.filter,
|
||||
hasError: false
|
||||
filterMetadataValue: this.domainObject.configuration.filterMetadata,
|
||||
hasFilterError: false,
|
||||
hasMetadataFilterError: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -65,37 +104,55 @@ export default {
|
||||
methods: {
|
||||
setEditState(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
if (!this.isEditing && this.hasError) {
|
||||
this.filterValue = this.domainObject.configuration.filter;
|
||||
this.hasError = false;
|
||||
if (!this.isEditing) {
|
||||
if (this.hasFilterError) {
|
||||
this.filterValue = this.domainObject.configuration.filter;
|
||||
}
|
||||
if (this.hasMetadataFilterError) {
|
||||
this.filterMetadataValue = this.domainObject.configuration.filterMetadata;
|
||||
}
|
||||
this.hasFilterError = false;
|
||||
this.hasMetadataFilterError = false;
|
||||
}
|
||||
},
|
||||
forceBlur(event) {
|
||||
event.target.blur();
|
||||
},
|
||||
updateForm(event, property) {
|
||||
if (!this.isValid()) {
|
||||
this.hasError = true;
|
||||
updateNameFilter(event, property) {
|
||||
if (!this.isValid(this.filterValue)) {
|
||||
this.hasFilterError = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasError = false;
|
||||
this.hasFilterError = false;
|
||||
|
||||
this.$emit('updated', {
|
||||
property,
|
||||
value: this.filterValue.replace(/,(\s)*$/, '')
|
||||
});
|
||||
},
|
||||
isValid() {
|
||||
updateMetadataFilter(event, property) {
|
||||
if (!this.isValid(this.filterMetadataValue)) {
|
||||
this.hasMetadataFilterError = true;
|
||||
|
||||
return;
|
||||
}
|
||||
this.hasMetadataFilterError = false;
|
||||
|
||||
this.$emit('updated', {
|
||||
property,
|
||||
value: this.filterMetadataValue.replace(/,(\s)*$/, '')
|
||||
});
|
||||
},
|
||||
isValid(value) {
|
||||
// Test for any word character, any whitespace character or comma
|
||||
if (this.filterValue === '') {
|
||||
if (value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const regex = new RegExp(/^([a-zA-Z0-9_\-\s,])+$/g);
|
||||
|
||||
return regex.test(this.filterValue);
|
||||
return regex.test(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -28,7 +28,7 @@ import TimelistPropertiesView from './TimelistPropertiesView.vue';
|
||||
export default function TimeListInspectorViewProvider(openmct) {
|
||||
return {
|
||||
key: 'timelist-inspector',
|
||||
name: 'Timelist Inspector View',
|
||||
name: 'Config',
|
||||
canView: function (selection) {
|
||||
if (selection.length === 0 || selection[0].length === 0) {
|
||||
return false;
|
||||
|
@ -38,16 +38,10 @@ export default function () {
|
||||
initialize: function (domainObject) {
|
||||
domainObject.configuration = {
|
||||
sortOrderIndex: 0,
|
||||
futureEventsIndex: 1,
|
||||
futureEventsDurationIndex: 0,
|
||||
futureEventsDuration: 20,
|
||||
currentEventsIndex: 1,
|
||||
currentEventsDurationIndex: 0,
|
||||
currentEventsDuration: 20,
|
||||
pastEventsIndex: 1,
|
||||
pastEventsDurationIndex: 0,
|
||||
pastEventsDuration: 20,
|
||||
filter: ''
|
||||
filter: '',
|
||||
filterMetadata: '',
|
||||
isCompact: false
|
||||
};
|
||||
domainObject.composition = [];
|
||||
}
|
||||
|
@ -71,7 +71,10 @@ describe('the plugin', function () {
|
||||
end: twoHoursFuture,
|
||||
type: 'TEST-GROUP',
|
||||
color: 'fuchsia',
|
||||
textColor: 'black'
|
||||
textColor: 'black',
|
||||
properties: {
|
||||
location: 'garden'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Sed ut perspiciatis two',
|
||||
@ -79,7 +82,10 @@ describe('the plugin', function () {
|
||||
end: threeHoursFuture,
|
||||
type: 'TEST-GROUP',
|
||||
color: 'fuchsia',
|
||||
textColor: 'black'
|
||||
textColor: 'black',
|
||||
properties: {
|
||||
location: 'hallway'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
@ -305,7 +311,7 @@ describe('the plugin', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
describe('filters by name', () => {
|
||||
let timelistDomainObject;
|
||||
let timelistView;
|
||||
|
||||
@ -379,6 +385,129 @@ describe('the plugin', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters by metadata', () => {
|
||||
let timelistDomainObject;
|
||||
let timelistView;
|
||||
|
||||
beforeEach(() => {
|
||||
timelistDomainObject = {
|
||||
identifier: {
|
||||
key: 'test-object',
|
||||
namespace: ''
|
||||
},
|
||||
type: TIMELIST_TYPE,
|
||||
id: 'test-object',
|
||||
configuration: {
|
||||
sortOrderIndex: 2,
|
||||
futureEventsIndex: 1,
|
||||
futureEventsDurationIndex: 0,
|
||||
futureEventsDuration: 0,
|
||||
currentEventsIndex: 1,
|
||||
currentEventsDurationIndex: 0,
|
||||
currentEventsDuration: 0,
|
||||
pastEventsIndex: 1,
|
||||
pastEventsDurationIndex: 0,
|
||||
pastEventsDuration: 0,
|
||||
filter: '',
|
||||
filterMetadata: 'hallway,garden'
|
||||
},
|
||||
composition: [
|
||||
{
|
||||
identifier: {
|
||||
key: 'test-plan-object',
|
||||
namespace: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
openmct.router.path = [timelistDomainObject];
|
||||
|
||||
const applicableViews = openmct.objectViews.get(timelistDomainObject, [timelistDomainObject]);
|
||||
timelistView = applicableViews.find((viewProvider) => viewProvider.key === 'timelist.view');
|
||||
let view = timelistView.view(timelistDomainObject, [timelistDomainObject]);
|
||||
view.show(child, true);
|
||||
|
||||
return nextTick();
|
||||
});
|
||||
|
||||
it('activities and sorts them correctly', () => {
|
||||
mockComposition.emit('add', planObject);
|
||||
|
||||
return nextTick(() => {
|
||||
const timeFormat = openmct.time.timeSystem().timeFormat;
|
||||
const timeFormatter = openmct.telemetry.getValueFormatter({ format: timeFormat }).formatter;
|
||||
|
||||
const items = element.querySelectorAll(LIST_ITEM_CLASS);
|
||||
expect(items.length).toEqual(2);
|
||||
|
||||
const itemValues = items[1].querySelectorAll(LIST_ITEM_VALUE_CLASS);
|
||||
expect(itemValues[0].innerHTML.trim()).toEqual(timeFormatter.format(now, TIME_FORMAT));
|
||||
expect(itemValues[1].innerHTML.trim()).toEqual(
|
||||
timeFormatter.format(threeHoursFuture, TIME_FORMAT)
|
||||
);
|
||||
expect(itemValues[3].innerHTML.trim()).toEqual('Sed ut perspiciatis two');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters by name and metadata', () => {
|
||||
let timelistDomainObject;
|
||||
let timelistView;
|
||||
|
||||
beforeEach(() => {
|
||||
timelistDomainObject = {
|
||||
identifier: {
|
||||
key: 'test-object',
|
||||
namespace: ''
|
||||
},
|
||||
type: TIMELIST_TYPE,
|
||||
id: 'test-object',
|
||||
configuration: {
|
||||
sortOrderIndex: 2,
|
||||
currentEventsIndex: 1,
|
||||
filter: 'two',
|
||||
filterMetadata: 'garden'
|
||||
},
|
||||
composition: [
|
||||
{
|
||||
identifier: {
|
||||
key: 'test-plan-object',
|
||||
namespace: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
openmct.router.path = [timelistDomainObject];
|
||||
|
||||
const applicableViews = openmct.objectViews.get(timelistDomainObject, [timelistDomainObject]);
|
||||
timelistView = applicableViews.find((viewProvider) => viewProvider.key === 'timelist.view');
|
||||
let view = timelistView.view(timelistDomainObject, [timelistDomainObject]);
|
||||
view.show(child, true);
|
||||
|
||||
return nextTick();
|
||||
});
|
||||
|
||||
it('activities and sorts them correctly', () => {
|
||||
mockComposition.emit('add', planObject);
|
||||
|
||||
return nextTick(() => {
|
||||
const timeFormat = openmct.time.timeSystem().timeFormat;
|
||||
const timeFormatter = openmct.telemetry.getValueFormatter({ format: timeFormat }).formatter;
|
||||
|
||||
const items = element.querySelectorAll(LIST_ITEM_CLASS);
|
||||
expect(items.length).toEqual(2);
|
||||
|
||||
const itemValues = items[0].querySelectorAll(LIST_ITEM_VALUE_CLASS);
|
||||
expect(itemValues[0].innerHTML.trim()).toEqual(timeFormatter.format(now, TIME_FORMAT));
|
||||
expect(itemValues[1].innerHTML.trim()).toEqual(
|
||||
timeFormatter.format(twoHoursFuture, TIME_FORMAT)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('time filtering - past', () => {
|
||||
let timelistDomainObject;
|
||||
let timelistView;
|
||||
|
@ -361,11 +361,12 @@ body.desktop .has-local-controls {
|
||||
}
|
||||
}
|
||||
|
||||
[aria-disabled = 'true'],
|
||||
*[disabled],
|
||||
.disabled {
|
||||
opacity: $controlDisabledOpacity;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
/******************************************************** RESPONSIVE CONTAINERS */
|
||||
|
@ -43,6 +43,7 @@
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:item-properties="itemProperties"
|
||||
@click.stop="itemSelected(item, $event)"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -86,6 +87,7 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
emits: ['item-selection-changed'],
|
||||
data() {
|
||||
let sortBy = this.defaultSort.property;
|
||||
let ascending = this.defaultSort.defaultDirection;
|
||||
@ -156,6 +158,9 @@ export default {
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
itemSelected(item, event) {
|
||||
this.$emit('item-selection-changed', item, event.currentTarget);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -33,6 +33,7 @@ import StyleRuleManager from '@/plugins/condition/StyleRuleManager';
|
||||
import { STYLE_CONSTANTS } from '@/plugins/condition/utils/constants';
|
||||
import stalenessMixin from '@/ui/mixins/staleness-mixin';
|
||||
|
||||
import objectUtils from '../../api/objects/object-utils.js';
|
||||
import VisibilityObserver from '../../utils/visibility/VisibilityObserver.js';
|
||||
|
||||
export default {
|
||||
@ -130,7 +131,10 @@ export default {
|
||||
this.debounceUpdateView = _.debounce(this.updateView, 10);
|
||||
},
|
||||
mounted() {
|
||||
this.visibilityObserver = new VisibilityObserver(this.$refs.objectViewWrapper);
|
||||
this.visibilityObserver = new VisibilityObserver(
|
||||
this.$refs.objectViewWrapper,
|
||||
this.openmct.element
|
||||
);
|
||||
this.updateView();
|
||||
this.$refs.objectViewWrapper.addEventListener('dragover', this.onDragOver, {
|
||||
capture: true
|
||||
@ -184,6 +188,7 @@ export default {
|
||||
this.triggerUnsubscribeFromStaleness(this.domainObject);
|
||||
|
||||
this.openmct.objectViews.off('clearData', this.clearData);
|
||||
this.openmct.objectViews.off('reload', this.reload);
|
||||
if (this.contextActionEvent) {
|
||||
this.openmct.objectViews.off(this.contextActionEvent, this.performContextAction);
|
||||
}
|
||||
@ -218,6 +223,13 @@ export default {
|
||||
this.clear();
|
||||
this.updateView(true);
|
||||
},
|
||||
reload(domainObjectToReload) {
|
||||
if (objectUtils.equals(domainObjectToReload, this.domainObject)) {
|
||||
this.updateView(true);
|
||||
this.initObjectStyles();
|
||||
this.triggerStalenessSubscribe(this.domainObject);
|
||||
}
|
||||
},
|
||||
triggerStalenessSubscribe(object) {
|
||||
if (this.openmct.telemetry.isTelemetryObject(object)) {
|
||||
this.subscribeToStaleness(object);
|
||||
@ -316,6 +328,7 @@ export default {
|
||||
this.domainObject.identifier
|
||||
)}`;
|
||||
this.openmct.objectViews.on('clearData', this.clearData);
|
||||
this.openmct.objectViews.on('reload', this.reload);
|
||||
this.openmct.objectViews.on(this.contextActionEvent, this.performContextAction);
|
||||
|
||||
this.$nextTick(() => {
|
||||
|
@ -23,6 +23,7 @@
|
||||
<div ref="createButton" class="c-create-button--w">
|
||||
<button
|
||||
class="c-create-button c-button--menu c-button--major icon-plus"
|
||||
:aria-disabled="isEditing"
|
||||
@click.prevent.stop="showCreateMenu"
|
||||
>
|
||||
<span class="c-button__label">Create</span>
|
||||
@ -38,6 +39,7 @@ export default {
|
||||
data: function () {
|
||||
return {
|
||||
menuItems: {},
|
||||
isEditing: this.openmct.editor.isEditing(),
|
||||
selectedMenuItem: {},
|
||||
opened: false
|
||||
};
|
||||
@ -57,6 +59,12 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.openmct.editor.on('isEditing', this.toggleEdit);
|
||||
},
|
||||
unmounted() {
|
||||
this.openmct.editor.off('isEditing', this.toggleEdit);
|
||||
},
|
||||
methods: {
|
||||
getItems() {
|
||||
let keys = this.openmct.types.listKeys();
|
||||
@ -89,6 +97,9 @@ export default {
|
||||
|
||||
this.openmct.menus.showSuperMenu(x, y, this.sortedItems, menuOptions);
|
||||
},
|
||||
toggleEdit(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
},
|
||||
create(key) {
|
||||
const createAction = new CreateAction(this.openmct, key, this.openmct.router.path[0]);
|
||||
|
||||
|
@ -4,10 +4,6 @@
|
||||
}
|
||||
|
||||
.c-create-button {
|
||||
.is-editing & {
|
||||
@include disabled();
|
||||
}
|
||||
|
||||
.c-button__label {
|
||||
text-transform: $createBtnTextTransform;
|
||||
}
|
||||
|
@ -203,8 +203,6 @@
|
||||
|
||||
.is-editing .is-navigated-object {
|
||||
a[class*='__item__label'] {
|
||||
opacity: 0.4;
|
||||
|
||||
[class*='__name'] {
|
||||
font-style: italic;
|
||||
}
|
||||
|
@ -108,6 +108,11 @@ export default {
|
||||
this.addExistingViewBackToParent();
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
// FIXME: fixes a problem where the some context menu items are not available when in Preview Mode
|
||||
// see https://github.com/nasa/openmct/issues/7158
|
||||
this.getActionsCollection(this.view);
|
||||
},
|
||||
methods: {
|
||||
clear() {
|
||||
if (this.view) {
|
||||
|
@ -32,17 +32,22 @@ export default class VisibilityObserver {
|
||||
* Constructs a VisibilityObserver instance to manage visibility-based requestAnimationFrame calls.
|
||||
*
|
||||
* @param {HTMLElement} element - The DOM element to observe for visibility changes.
|
||||
* @param {HTMLElement} rootContainer - The DOM element that is the root of the viewport.
|
||||
* @throws {Error} If element is not provided.
|
||||
*/
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error(`VisibilityObserver must be created with an element`);
|
||||
constructor(element, rootContainer) {
|
||||
if (!element || !rootContainer) {
|
||||
throw new Error(`VisibilityObserver must be created with an element and a rootContainer.`);
|
||||
}
|
||||
this.#element = element;
|
||||
this.isIntersecting = true;
|
||||
this.calledOnce = false;
|
||||
|
||||
this.#observer = new IntersectionObserver(this.#observerCallback);
|
||||
const options = {
|
||||
root: rootContainer,
|
||||
rootMargin: '0px',
|
||||
threshold: 1.0
|
||||
};
|
||||
this.#observer = new IntersectionObserver(this.#observerCallback, options);
|
||||
this.lastUnfiredFunc = null;
|
||||
this.renderWhenVisible = this.renderWhenVisible.bind(this);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user