Merge branch 'master' into mission-status-situational-awareness

This commit is contained in:
Jesse Mazzella 2024-01-29 16:31:51 -08:00
commit 90d08982e0
82 changed files with 2890 additions and 471 deletions

View File

@ -5,20 +5,20 @@ executors:
- image: mcr.microsoft.com/playwright:v1.39.0-focal - image: mcr.microsoft.com/playwright:v1.39.0-focal
environment: environment:
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps PERCY_POSTINSTALL_BROWSER: "true" # Needed to store the percy browser in cache deps
PERCY_LOGLEVEL: 'debug' # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742) PERCY_LOGLEVEL: "debug" # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742)
ubuntu: ubuntu:
machine: machine:
image: ubuntu-2204:current image: ubuntu-2204:current
docker_layer_caching: true docker_layer_caching: true
parameters: parameters:
BUST_CACHE: 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 default: false
type: boolean type: boolean
commands: commands:
build_and_install: 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: parameters:
node-version: node-version:
type: string type: string
@ -30,7 +30,7 @@ commands:
node-version: << parameters.node-version >> node-version: << parameters.node-version >>
- run: npm install --no-audit --progress=false - run: npm install --no-audit --progress=false
restore_cache_cmd: 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: parameters:
node-version: node-version:
type: string type: string
@ -42,7 +42,7 @@ commands:
- restore_cache: - restore_cache:
key: deps--{{ arch }}--{{ .Branch }}--<< parameters.node-version >>--{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} key: deps--{{ arch }}--{{ .Branch }}--<< parameters.node-version >>--{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }}
save_cache_cmd: save_cache_cmd:
description: 'Custom command for saving cache.' description: "Custom command for saving cache."
parameters: parameters:
node-version: node-version:
type: string type: string
@ -53,7 +53,7 @@ commands:
- ~/.npm - ~/.npm
- node_modules - node_modules
generate_and_store_version_and_filesystem_artifacts: generate_and_store_version_and_filesystem_artifacts:
description: 'Track important packages and files' description: "Track important packages and files"
steps: steps:
- run: | - run: |
[[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts) [[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts)
@ -64,7 +64,7 @@ commands:
- store_artifacts: - store_artifacts:
path: /tmp/artifacts/ path: /tmp/artifacts/
generate_e2e_code_cov_report: generate_e2e_code_cov_report:
description: 'Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test' description: "Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test"
parameters: parameters:
suite: suite:
type: string type: string
@ -129,7 +129,7 @@ jobs:
node-version: lts/hydrogen node-version: lts/hydrogen
- when: #Only install chrome-beta when running the 'full' suite to save $$$ - when: #Only install chrome-beta when running the 'full' suite to save $$$
condition: condition:
equal: ['full', <<parameters.suite>>] equal: ["full", <<parameters.suite>>]
steps: steps:
- run: npx playwright install chrome-beta - run: npx playwright install chrome-beta
- run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; npm run test:e2e:<<parameters.suite>> -- --shard=${SHARD}/${CIRCLE_NODE_TOTAL} - run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; npm run test:e2e:<<parameters.suite>> -- --shard=${SHARD}/${CIRCLE_NODE_TOTAL}
@ -251,8 +251,6 @@ workflows:
- e2e-test: - e2e-test:
name: e2e-stable name: e2e-stable
suite: stable suite: stable
- mem-test
- perf-test
- visual-a11y-tests: - visual-a11y-tests:
name: visual-test-ci name: visual-test-ci
suite: ci suite: ci
@ -278,7 +276,7 @@ workflows:
- e2e-couchdb - e2e-couchdb
triggers: triggers:
- schedule: - schedule:
cron: '0 0 * * *' cron: "0 0 * * *"
filters: filters:
branches: branches:
only: only:

View File

@ -493,6 +493,8 @@
"WCAG", "WCAG",
"stackedplot", "stackedplot",
"Andale", "Andale",
"checksnapshots",
"specced",
"composables" "composables"
], ],
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US"], "dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US"],

58
.github/workflows/e2e-perf.yml vendored Normal file
View 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}`);
}

View File

@ -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 // 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 docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v{X.X.X}-focal /bin/bash
npm install npm install
npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot npm run test:e2e:checksnapshots
``` ```
### Updating Snapshots ### Updating Snapshots
@ -134,6 +134,12 @@ npm install
npm run test:e2e:updatesnapshots 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 ## Automated Accessibility (a11y) Testing
Open MCT incorporates accessibility testing through two primary methods to ensure its compliance with accessibility standards: Open MCT incorporates accessibility testing through two primary methods to ensure its compliance with accessibility standards:

View File

@ -6,7 +6,8 @@
"end": 1660343797000, "end": 1660343797000,
"type": "Group 1", "type": "Group 1",
"color": "orange", "color": "orange",
"textColor": "white" "textColor": "white",
"id": 1
}, },
{ {
"name": "Past event 2", "name": "Past event 2",
@ -14,7 +15,8 @@
"end": 1660429160000, "end": 1660429160000,
"type": "Group 1", "type": "Group 1",
"color": "orange", "color": "orange",
"textColor": "white" "textColor": "white",
"id": 2
}, },
{ {
"name": "Past event 3", "name": "Past event 3",
@ -22,7 +24,8 @@
"end": 1660503981000, "end": 1660503981000,
"type": "Group 1", "type": "Group 1",
"color": "orange", "color": "orange",
"textColor": "white" "textColor": "white",
"id": 3
}, },
{ {
"name": "Past event 4", "name": "Past event 4",
@ -30,7 +33,8 @@
"end": 1660624108000, "end": 1660624108000,
"type": "Group 1", "type": "Group 1",
"color": "orange", "color": "orange",
"textColor": "white" "textColor": "white",
"id": 4
}, },
{ {
"name": "Past event 5", "name": "Past event 5",
@ -38,7 +42,8 @@
"end": 1660681529000, "end": 1660681529000,
"type": "Group 1", "type": "Group 1",
"color": "orange", "color": "orange",
"textColor": "white" "textColor": "white",
"id": 5
} }
] ]
} }

View File

@ -6,7 +6,8 @@
"end": 1660343797000, "end": 1660343797000,
"type": "Group 1", "type": "Group 1",
"color": "orange", "color": "orange",
"textColor": "white" "textColor": "white",
"id": 1
}, },
{ {
"name": "Time until supper", "name": "Time until supper",
@ -14,7 +15,8 @@
"end": 1650420410000, "end": 1650420410000,
"type": "Group 2", "type": "Group 2",
"color": "blue", "color": "blue",
"textColor": "white" "textColor": "white",
"id": 2
} }
], ],
"Group 2": [ "Group 2": [
@ -24,7 +26,8 @@
"end": 1650320102001, "end": 1650320102001,
"type": "Group 2", "type": "Group 2",
"color": "green", "color": "green",
"textColor": "white" "textColor": "white",
"id": 3
}, },
{ {
"name": "Time since last accident", "name": "Time since last accident",
@ -32,7 +35,8 @@
"end": 1650320102002, "end": 1650320102002,
"type": "Group 1", "type": "Group 1",
"color": "yellow", "color": "yellow",
"textColor": "white" "textColor": "white",
"id": 4
} }
] ]
} }

View File

@ -27,7 +27,7 @@ import {
assertPlanActivities, assertPlanActivities,
assertPlanOrderedSwimLanes assertPlanOrderedSwimLanes
} from '../../../helper/planningUtils.js'; } from '../../../helper/planningUtils.js';
import { test } from '../../../pluginFixtures.js'; import { expect, test } from '../../../pluginFixtures.js';
const testPlan1 = JSON.parse( const testPlan1 = JSON.parse(
fs.readFileSync( fs.readFileSync(
@ -63,4 +63,47 @@ test.describe('Plan', () => {
}); });
await assertPlanOrderedSwimLanes(page, testPlanWithOrderedLanes, planWithSwimLanes.url); 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'
);
});
}); });

View File

@ -30,6 +30,11 @@ const examplePlanSmall3 = JSON.parse(
new URL('../../../test-data/examplePlans/ExamplePlan_Small3.json', import.meta.url) 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 // eslint-disable-next-line no-unused-vars
const START_TIME_COLUMN = 0; const START_TIME_COLUMN = 0;
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@ -40,53 +45,8 @@ const ACTIVITY_COLUMN = 3;
const HEADER_ROW = 0; const HEADER_ROW = 0;
const NUM_COLUMNS = 4; 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.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 page
}) => { }) => {
// Goto baseURL // Goto baseURL
@ -103,12 +63,16 @@ test.describe('Time List', () => {
await test.step('Create a Plan and add it to the timelist', async () => { await test.step('Create a Plan and add it to the timelist', async () => {
await createPlanFromJSON(page, { await createPlanFromJSON(page, {
name: 'Test Plan', name: 'Test Plan',
json: testPlan, json: examplePlanSmall1,
parent: timelist.uuid parent: timelist.uuid
}); });
const groups = Object.keys(examplePlanSmall1);
const startBound = testPlan.TEST_GROUP[0].start; const firstGroupKey = groups[0];
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end; 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 // Switch to fixed time mode with all plan events within the bounds
await page.goto( await page.goto(
@ -118,7 +82,7 @@ test.describe('Time List', () => {
// Verify all events are displayed // Verify all events are displayed
const eventCount = await page.getByRole('row').count(); const eventCount = await page.getByRole('row').count();
// subtracting one for the header // 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 () => { 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('.--end')).not.toContainText('.');
await expect(row.locator('.--duration')).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'
);
});
}); });
}); });

View File

@ -35,7 +35,7 @@ import { expect, test } from '../../../../pluginFixtures.js';
let conditionSetUrl; 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 }) => { test.beforeAll(async ({ browser }) => {
//TODO: This needs to be refactored //TODO: This needs to be refactored
const context = await browser.newContext(); const context = await browser.newContext();
@ -68,30 +68,35 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
}); });
//Begin suite of tests again localStorage //Begin suite of tests again localStorage
test('Condition set object properties persist in main view and inspector @localStorage', async ({ test.fixme(
page 'Condition set object properties persist in main view and inspector @localStorage',
}) => { async ({ page }) => {
//Navigate to baseURL with injected localStorage test.info().annotations.push({
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' }); 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() //Assertions on loaded Condition Set in main view. This is a stateful transition step after page.goto()
await expect await expect
.soft(page.locator('.l-browse-bar__object-name')) .soft(page.locator('.l-browse-bar__object-name'))
.toContainText('Unnamed Condition Set'); .toContainText('Unnamed Condition Set');
//Assertions on loaded Condition Set in Inspector //Assertions on loaded Condition Set in Inspector
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy(); expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy();
//Reload Page //Reload Page
await Promise.all([page.reload(), page.waitForLoadState('networkidle')]); await Promise.all([page.reload(), page.waitForLoadState('networkidle')]);
//Re-verify after reload //Re-verify after reload
await expect await expect
.soft(page.locator('.l-browse-bar__object-name')) .soft(page.locator('.l-browse-bar__object-name'))
.toContainText('Unnamed Condition Set'); .toContainText('Unnamed Condition Set');
//Assertions on loaded Condition Set in Inspector //Assertions on loaded Condition Set in Inspector
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy(); expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy();
}); }
);
test('condition set object can be modified on @localStorage', async ({ page, openmctConfig }) => { test('condition set object can be modified on @localStorage', async ({ page, openmctConfig }) => {
const { myItemsFolderName } = openmctConfig; const { myItemsFolderName } = openmctConfig;

View File

@ -161,6 +161,13 @@ test.describe('Display Layout', () => {
const trimmedDisplayValue = displayLayoutValue.trim(); const trimmedDisplayValue = displayLayoutValue.trim();
expect(trimmedDisplayValue).toBe(formattedTelemetryValue); 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 ({ test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in fixed time', async ({
page page

View File

@ -136,7 +136,11 @@ test.describe('Gauge', () => {
// TODO: Verify changes in the UI // 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 // Create a Gauge
const gauge = await createDomainObjectWithDefaults(page, { const gauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge' 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

View File

@ -224,31 +224,37 @@ test.describe('Overlay Plot', () => {
expect(yAxis3Group.getByRole('listitem').nth(0).getByText(swgB.name)).toBeTruthy(); 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 ({ test.fixme(
page 'Clicking on an item in the elements pool brings up the plot preview with data points',
}) => { async ({ page }) => {
const overlayPlot = await createDomainObjectWithDefaults(page, { test.info().annotations.push({
type: 'Overlay Plot' type: 'issue',
}); description: 'https://github.com/nasa/openmct/issues/7421'
});
const swgA = await createDomainObjectWithDefaults(page, { const overlayPlot = await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator', type: 'Overlay Plot'
parent: overlayPlot.uuid });
});
await page.goto(overlayPlot.url); const swgA = await createDomainObjectWithDefaults(page, {
// Wait for plot series data to load and be drawn type: 'Sine Wave Generator',
await waitForPlotsToRender(page); parent: overlayPlot.uuid
await page.getByLabel('Edit Object').click(); });
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'); await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).click();
const plotPixelSize = plotPixels.length;
expect(plotPixelSize).toBeGreaterThan(0); const plotPixels = await getCanvasPixels(page, '.js-overlay canvas');
}); const plotPixelSize = plotPixels.length;
expect(plotPixelSize).toBeGreaterThan(0);
}
);
}); });
/** /**

View File

@ -52,7 +52,11 @@ test.describe('Plot Rendering', () => {
expect(createMineFolderRequests.length).toEqual(0); 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 // Edit Plot
await editSineWaveToUseInfinityOption(page, sineWaveGeneratorObject); await editSineWaveToUseInfinityOption(page, sineWaveGeneratorObject);

View 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)
});
});

View File

@ -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);
});
});

View File

@ -24,13 +24,18 @@ import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Tabs View', () => { 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' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
const tabsView = await createDomainObjectWithDefaults(page, { tabsView = await createDomainObjectWithDefaults(page, {
type: 'Tabs View' type: 'Tabs View'
}); });
const table = await createDomainObjectWithDefaults(page, { table = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table', type: 'Telemetry Table',
parent: tabsView.uuid parent: tabsView.uuid
}); });
@ -38,19 +43,21 @@ test.describe('Tabs View', () => {
type: 'Event Message Generator', type: 'Event Message Generator',
parent: table.uuid parent: table.uuid
}); });
const notebook = await createDomainObjectWithDefaults(page, { notebook = await createDomainObjectWithDefaults(page, {
type: 'Notebook', type: 'Notebook',
parent: tabsView.uuid parent: tabsView.uuid
}); });
const sineWaveGenerator = await createDomainObjectWithDefaults(page, { sineWaveGenerator = await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator', type: 'Sine Wave Generator',
parent: tabsView.uuid parent: tabsView.uuid
}); });
});
page.goto(tabsView.url); test('Renders tabbed elements', async ({ page }) => {
await page.goto(tabsView.url);
// select first tab // select first tab
await page.getByLabel(`${table.name} tab`).click(); await page.getByLabel(`${table.name} tab`, { exact: true }).click();
// ensure table header visible // ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
@ -58,7 +65,7 @@ test.describe('Tabs View', () => {
await expect(page.locator('canvas[id=webglContext]')).toBeHidden(); await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
// select second tab // select second tab
await page.getByLabel(`${notebook.name} tab`).click(); await page.getByLabel(`${notebook.name} tab`, { exact: true }).click();
// ensure notebook visible // ensure notebook visible
await expect(page.locator('.c-notebook__drag-area')).toBeVisible(); 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(); await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
// select third tab // select third tab
await page.getByLabel(`${sineWaveGenerator.name} tab`).click(); await page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true }).click();
// expect sine wave generator visible // expect sine wave generator visible
await expect(page.locator('.c-plot')).toBeVisible(); await expect(page.locator('.c-plot')).toBeVisible();
@ -78,7 +85,7 @@ test.describe('Tabs View', () => {
await expect(page.locator('canvas').nth(1)).toBeVisible(); await expect(page.locator('canvas').nth(1)).toBeVisible();
// now try to select the first tab again // 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 // ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); 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(); 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();
});
});

View File

@ -64,10 +64,9 @@ test.describe('Telemetry Table', () => {
// Get the most recent telemetry date // Get the most recent telemetry date
const latestTelemetryDate = await page const latestTelemetryDate = await page
.locator('table.c-telemetry-table__body > tbody > tr') .getByLabel('table content')
.getByLabel('utc table cell')
.last() .last()
.locator('td')
.nth(1)
.getAttribute('title'); .getAttribute('title');
// Verify that it is <= our new end bound // 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' }).click();
await page.getByRole('searchbox', { name: 'message filter input' }).fill('Roger'); 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 // ensure we've got more than one cell
expect(cells.length).toBeGreaterThan(1); expect(cells.length).toBeGreaterThan(1);
// ensure the text content of each cell contains the search term // 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' }).click();
await page.getByRole('searchbox', { name: 'message filter input' }).fill('Dodger'); 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 // ensure we've got more than one cell
expect(cells.length).toBe(0); expect(cells.length).toBe(0);
// ensure the text content of each cell contains the search term // 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' }).click();
await page.getByRole('searchbox', { name: 'message filter input' }).fill('/[Rr]oger/'); 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 // ensure we've got more than one cell
expect(cells.length).toBeGreaterThan(1); expect(cells.length).toBeGreaterThan(1);
// ensure the text content of each cell contains the search term // 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' }).click();
await page.getByRole('searchbox', { name: 'message filter input' }).fill('/[Dd]oger/'); 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 // ensure we've got more than one cell
expect(cells.length).toBe(0); expect(cells.length).toBe(0);
// ensure the text content of each cell contains the search term // ensure the text content of each cell contains the search term

View File

@ -24,7 +24,7 @@ import { createDomainObjectWithDefaults, waitForPlotsToRender } from '../../appA
import { expect, test } from '../../pluginFixtures.js'; import { expect, test } from '../../pluginFixtures.js';
test.describe('Tabs View', () => { 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 // Code to hook into the requestAnimationFrame function and log each call
let animationCalls = []; let animationCalls = [];
await page.exposeFunction('logCall', (callCount) => { await page.exposeFunction('logCall', (callCount) => {
@ -64,24 +64,24 @@ test.describe('Tabs View', () => {
page.goto(tabsView.url); page.goto(tabsView.url);
// select first tab // select first tab
await page.getByLabel(`${table.name} tab`).click(); await page.getByLabel(`${table.name} tab`, { exact: true }).click();
// ensure table header visible // ensure table header visible
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
// select second tab // select second tab
await page.getByLabel(`${notebook.name} tab`).click(); await page.getByLabel(`${notebook.name} tab`, { exact: true }).click();
// expect notebook visible // expect notebook visible
await expect(page.locator('.c-notebook__drag-area')).toBeVisible(); await expect(page.locator('.c-notebook__drag-area')).toBeVisible();
// select third tab // select third tab
await page.getByLabel(`${sineWaveGenerator.name} tab`).click(); await page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true }).click();
// ensure sine wave generator visible // ensure sine wave generator visible
expect(await page.locator('.c-plot').isVisible()).toBe(true); expect(await page.locator('.c-plot').isVisible()).toBe(true);
// now select notebook and clear animation calls // now select notebook and clear animation calls
await page.getByLabel(`${notebook.name} tab`).click(); await page.getByLabel(`${notebook.name} tab`, { exact: true }).click();
animationCalls = []; animationCalls = [];
// expect notebook visible // expect notebook visible
await expect(page.locator('.c-notebook__drag-area')).toBeVisible(); 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 // select sine wave generator and clear animation calls
animationCalls = []; animationCalls = [];
await page.getByLabel(`${sineWaveGenerator.name} tab`).click(); await page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true }).click();
// ensure sine wave generator visible // ensure sine wave generator visible
await waitForPlotsToRender(page); await waitForPlotsToRender(page);

View File

@ -0,0 +1,93 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
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})`);
});
});

View File

@ -92,6 +92,8 @@ GeneratorProvider.prototype.request = function (domainObject, request) {
var workerRequest = this.makeWorkerRequest(domainObject, request); var workerRequest = this.makeWorkerRequest(domainObject, request);
workerRequest.start = request.start; workerRequest.start = request.start;
workerRequest.end = request.end; workerRequest.end = request.end;
workerRequest.size = request.size;
workerRequest.strategy = request.strategy;
return this.workerInterface.request(workerRequest); return this.workerInterface.request(workerRequest);
}; };

View File

@ -130,48 +130,37 @@
var now = Date.now(); var now = Date.now();
var start = request.start; var start = request.start;
var end = request.end > now ? now : request.end; var end = request.end > now ? now : request.end;
var amplitude = request.amplitude;
var period = request.period; var period = request.period;
var offset = request.offset;
var dataRateInHz = request.dataRateInHz; var dataRateInHz = request.dataRateInHz;
var phase = request.phase;
var randomness = request.randomness;
var loadDelay = Math.max(request.loadDelay, 0); var loadDelay = Math.max(request.loadDelay, 0);
var infinityValues = request.infinityValues; var size = request.size;
var exceedFloat32 = request.exceedFloat32; var duration = end - start;
var step = 1000 / dataRateInHz; var step = 1000 / dataRateInHz;
var maxPoints = Math.floor(duration / step);
var nextStep = start - (start % step) + step; var nextStep = start - (start % step) + step;
var data = []; var data = [];
for (; nextStep < end && data.length < 5000; nextStep += step) { if (request.strategy === 'minmax' && size) {
data.push({ // Calculate the number of cycles to include based on size (2 points per cycle)
utc: nextStep, var totalCycles = Math.min(Math.floor(size / 2), Math.floor(duration / period));
yesterday: nextStep - 60 * 60 * 24 * 1000,
sin: sin( for (let cycle = 0; cycle < totalCycles; cycle++) {
nextStep, // Distribute cycles evenly across the time range
period, let cycleStart = start + (duration / totalCycles) * cycle;
amplitude, let minPointTime = cycleStart; // Assuming min at the start of the cycle
offset, let maxPointTime = cycleStart + period / 2; // Assuming max at the halfway of the cycle
phase,
randomness, data.push(createDataPoint(minPointTime, request), createDataPoint(maxPointTime, request));
infinityValues, }
exceedFloat32 } else {
), for (let i = 0; i < maxPoints && nextStep < end; i++, nextStep += step) {
wavelengths: wavelengths(), data.push(createDataPoint(nextStep, request));
intensities: intensities(), }
cos: cos( }
nextStep,
period, if (request.strategy !== 'minmax' && size) {
amplitude, data = data.slice(-size);
offset,
phase,
randomness,
infinityValues,
exceedFloat32
)
});
} }
if (loadDelay === 0) { 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) { function postOnRequest(message, request, data) {
self.postMessage({ self.postMessage({
id: message.id, id: message.id,

View File

@ -57,9 +57,9 @@
"karma-webpack": "5.0.0", "karma-webpack": "5.0.0",
"location-bar": "3.0.1", "location-bar": "3.0.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"marked": "11.1.0", "marked": "11.2.0",
"mini-css-extract-plugin": "2.7.6", "mini-css-extract-plugin": "2.7.6",
"moment": "2.29.4", "moment": "2.30.1",
"moment-duration-format": "2.3.2", "moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41", "moment-timezone": "0.5.41",
"npm-run-all2": "6.1.1", "npm-run-all2": "6.1.1",
@ -72,7 +72,7 @@
"resolve-url-loader": "5.0.0", "resolve-url-loader": "5.0.0",
"sanitize-html": "2.11.0", "sanitize-html": "2.11.0",
"sass": "1.68.0", "sass": "1.68.0",
"sass-loader": "13.3.2", "sass-loader": "14.0.0",
"sinon": "17.0.0", "sinon": "17.0.0",
"style-loader": "3.3.3", "style-loader": "3.3.3",
"terser-webpack-plugin": "5.3.9", "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: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: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: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: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: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", "test:e2e:visual:full": "percy exec --config ./e2e/.percy.nightly.yml -- npx playwright test --config=e2e/playwright-visual-a11y.config.js --grep-invert @unstable",

View File

@ -251,6 +251,7 @@ export class MCT extends EventEmitter {
this.install(this.plugins.FlexibleLayout()); this.install(this.plugins.FlexibleLayout());
this.install(this.plugins.GoToOriginalAction()); this.install(this.plugins.GoToOriginalAction());
this.install(this.plugins.OpenInNewTabAction()); this.install(this.plugins.OpenInNewTabAction());
this.install(this.plugins.ReloadAction());
this.install(this.plugins.WebPage()); this.install(this.plugins.WebPage());
this.install(this.plugins.Condition()); this.install(this.plugins.Condition());
this.install(this.plugins.ConditionWidget()); this.install(this.plugins.ConditionWidget());

View File

@ -28,7 +28,8 @@
v-for="action in actionGroups" v-for="action in actionGroups"
:key="action.name" :key="action.name"
role="menuitem" role="menuitem"
:class="[action.cssClass, action.isDisabled ? 'disabled' : '']" :aria-disabled="action.isDisabled"
:class="action.cssClass"
:aria-label="action.name" :aria-label="action.name"
:title="action.description" :title="action.description"
@click="action.onItemClicked" @click="action.onItemClicked"
@ -51,7 +52,8 @@
v-for="action in options.actions" v-for="action in options.actions"
:key="action.name" :key="action.name"
role="menuitem" role="menuitem"
:class="[action.cssClass, action.isDisabled ? 'disabled' : '']" :aria-disabled="action.isDisabled"
:class="action.cssClass"
:aria-label="action.name" :aria-label="action.name"
:title="action.description" :title="action.description"
@click="action.onItemClicked" @click="action.onItemClicked"

View File

@ -37,7 +37,8 @@
v-for="action in actionGroups" v-for="action in actionGroups"
:key="action.name" :key="action.name"
role="menuitem" role="menuitem"
:class="[action.cssClass, action.isDisabled ? 'disabled' : '']" :aria-disabled="action.isDisabled"
:class="action.cssClass"
:title="action.description" :title="action.description"
@click="action.onItemClicked" @click="action.onItemClicked"
@mouseover="toggleItemDescription(action)" @mouseover="toggleItemDescription(action)"

View File

@ -99,7 +99,13 @@ export default class ObjectAPI {
this.cache = {}; this.cache = {};
this.interceptorRegistry = new InterceptorRegistry(); 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 = { this.errors = {
Conflict: ConflictError Conflict: ConflictError

View 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;

View File

@ -23,6 +23,7 @@
import objectUtils from 'objectUtils'; import objectUtils from 'objectUtils';
import CustomStringFormatter from '../../plugins/displayLayout/CustomStringFormatter.js'; import CustomStringFormatter from '../../plugins/displayLayout/CustomStringFormatter.js';
import BatchingWebSocket from './BatchingWebSocket.js';
import DefaultMetadataProvider from './DefaultMetadataProvider.js'; import DefaultMetadataProvider from './DefaultMetadataProvider.js';
import TelemetryCollection from './TelemetryCollection.js'; import TelemetryCollection from './TelemetryCollection.js';
import TelemetryMetadataManager from './TelemetryMetadataManager.js'; import TelemetryMetadataManager from './TelemetryMetadataManager.js';
@ -54,6 +55,28 @@ import TelemetryValueFormatter from './TelemetryValueFormatter.js';
* @memberof module:openmct.TelemetryAPI~ * @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 * Utilities for telemetry
* @interface TelemetryAPI * @interface TelemetryAPI
@ -61,6 +84,11 @@ import TelemetryValueFormatter from './TelemetryValueFormatter.js';
*/ */
export default class TelemetryAPI { export default class TelemetryAPI {
#isGreedyLAD; #isGreedyLAD;
#subscribeCache;
get SUBSCRIBE_STRATEGY() {
return SUBSCRIBE_STRATEGY;
}
constructor(openmct) { constructor(openmct) {
this.openmct = openmct; this.openmct = openmct;
@ -78,6 +106,8 @@ export default class TelemetryAPI {
this.valueFormatterCache = new WeakMap(); this.valueFormatterCache = new WeakMap();
this.requestInterceptorRegistry = new TelemetryRequestInterceptorRegistry(); this.requestInterceptorRegistry = new TelemetryRequestInterceptorRegistry();
this.#isGreedyLAD = true; this.#isGreedyLAD = true;
this.BatchingWebSocket = BatchingWebSocket;
this.#subscribeCache = {};
} }
abortAllRequests() { abortAllRequests() {
@ -378,54 +408,111 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider# * @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object * @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry * 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 * @param {Function} callback the callback to invoke with new data, as
* it becomes available * it becomes available
* @returns {Function} a function which may be called to terminate * @returns {Function} a function which may be called to terminate
* the subscription * 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') { if (domainObject.type === 'unknown') {
return () => {}; return () => {};
} }
const provider = this.findSubscriptionProvider(domainObject); const provider = this.findSubscriptionProvider(domainObject, options);
const supportsBatching =
Boolean(provider?.supportsBatching) && provider?.supportsBatching(domainObject, options);
if (!this.subscribeCache) { if (!this.#subscribeCache) {
this.subscribeCache = {}; this.#subscribeCache = {};
} }
const keyString = objectUtils.makeKeyString(domainObject.identifier); 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) { if (!subscriber) {
subscriber = this.subscribeCache[keyString] = { subscriber = this.#subscribeCache[cacheKey] = {
callbacks: [callback] latestCallbacks: [],
batchCallbacks: []
}; };
if (provider) { if (provider) {
subscriber.unsubscribe = provider.subscribe( subscriber.unsubscribe = provider.subscribe(
domainObject, domainObject,
function (value) { invokeCallbackWithRequestedStrategy,
subscriber.callbacks.forEach(function (cb) { optionsWithSupportedStrategy
cb(value);
});
},
options
); );
} else { } else {
subscriber.unsubscribe = function () {}; subscriber.unsubscribe = function () {};
} }
}
if (requestedStrategy === SUBSCRIBE_STRATEGY.BATCH) {
subscriber.batchCallbacks.push(callback);
} else { } 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() { return function unsubscribe() {
subscriber.callbacks = subscriber.callbacks.filter(function (cb) { subscriber.latestCallbacks = subscriber.latestCallbacks.filter(function (cb) {
return cb !== callback; 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(); subscriber.unsubscribe();
delete this.subscribeCache[keyString]; delete this.#subscribeCache[cacheKey];
} }
}.bind(this); }.bind(this);
} }

View File

@ -90,7 +90,9 @@ describe('Telemetry API', () => {
const callback = jasmine.createSpy('callback'); const callback = jasmine.createSpy('callback');
const unsubscribe = telemetryAPI.subscribe(domainObject, 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(telemetryProvider.subscribe).not.toHaveBeenCalled();
expect(unsubscribe).toEqual(jasmine.any(Function)); expect(unsubscribe).toEqual(jasmine.any(Function));
@ -111,12 +113,16 @@ describe('Telemetry API', () => {
const callback = jasmine.createSpy('callback'); const callback = jasmine.createSpy('callback');
const unsubscribe = telemetryAPI.subscribe(domainObject, callback); const unsubscribe = telemetryAPI.subscribe(domainObject, callback);
expect(telemetryProvider.supportsSubscribe.calls.count()).toBe(1); 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.calls.count()).toBe(1);
expect(telemetryProvider.subscribe).toHaveBeenCalledWith( expect(telemetryProvider.subscribe).toHaveBeenCalledWith(
domainObject, domainObject,
jasmine.any(Function), jasmine.any(Function),
undefined {
strategy: 'latest'
}
); );
const notify = telemetryProvider.subscribe.calls.mostRecent().args[1]; const notify = telemetryProvider.subscribe.calls.mostRecent().args[1];
@ -321,6 +327,126 @@ describe('Telemetry API', () => {
signal 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', () => { describe('metadata', () => {

View File

@ -180,11 +180,14 @@ export default class TelemetryCollection extends EventEmitter {
if (this.unsubscribe) { if (this.unsubscribe) {
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.unsubscribe = this.openmct.telemetry.subscribe(
this.domainObject, this.domainObject,
(datum) => this._processNewTelemetry(datum), (datum) => this._processNewTelemetry(datum),
this.options options
); );
} }
@ -209,6 +212,8 @@ export default class TelemetryCollection extends EventEmitter {
let added = []; let added = [];
let addedIndices = []; let addedIndices = [];
let hasDataBeforeStartBound = false; let hasDataBeforeStartBound = false;
let size = this.options.size;
let enforceSize = size !== undefined && this.options.enforceSize;
// loop through, sort and dedupe // loop through, sort and dedupe
for (let datum of data) { for (let datum of data) {
@ -271,6 +276,13 @@ export default class TelemetryCollection extends EventEmitter {
} }
} else { } else {
this.emit('add', added, addedIndices); 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);
}
} }
} }
} }

View 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);
});
}

View 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;

View 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
};
}

View 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();
});
});
});
});

View File

@ -84,12 +84,7 @@ import LayoutFrame from './LayoutFrame.vue';
const DEFAULT_TELEMETRY_DIMENSIONS = [10, 5]; const DEFAULT_TELEMETRY_DIMENSIONS = [10, 5];
const DEFAULT_POSITION = [1, 1]; const DEFAULT_POSITION = [1, 1];
const CONTEXT_MENU_ACTIONS = [ const CONTEXT_MENU_ACTIONS = ['copyToClipboard', 'copyToNotebook', 'viewHistoricalData'];
'copyToClipboard',
'copyToNotebook',
'viewHistoricalData',
'renderWhenVisible'
];
export default { export default {
makeDefinition(openmct, gridSize, domainObject, position) { makeDefinition(openmct, gridSize, domainObject, position) {

View File

@ -145,7 +145,7 @@
v-if="relatedTelemetry.hasRelatedTelemetry && isSpacecraftPositionFresh" v-if="relatedTelemetry.hasRelatedTelemetry && isSpacecraftPositionFresh"
class="c-imagery__age icon-check c-imagery--new no-animation" class="c-imagery__age icon-check c-imagery--new no-animation"
> >
POS ROV
</div> </div>
<!-- camera position fresh --> <!-- camera position fresh -->

View File

@ -112,7 +112,7 @@ export default {
}, },
renderPlot(plotObject) { renderPlot(plotObject) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
const visibilityObserver = new VisibilityObserver(wrapper); const visibilityObserver = new VisibilityObserver(wrapper, this.openmct.element);
const { destroy } = mount( const { destroy } = mount(
{ {

View File

@ -96,7 +96,7 @@
</div> </div>
<div <div
v-if="selectedPage && !selectedPage.isLocked" v-if="selectedPage && !selectedPage.isLocked"
:class="{ disabled: activeTransaction }" :aria-disabled="activeTransaction"
class="c-notebook__drag-area icon-plus" class="c-notebook__drag-area icon-plus"
@click="newEntry(null, $event)" @click="newEntry(null, $event)"
@dragover="dragOver" @dragover="dragOver"

View File

@ -135,6 +135,7 @@ export default {
default: 22 default: 22
} }
}, },
emits: ['activity-selected'],
data() { data() {
return { return {
lineHeight: 10 lineHeight: 10
@ -142,30 +143,11 @@ export default {
}, },
methods: { methods: {
setSelectionForActivity(activity, event) { setSelectionForActivity(activity, event) {
const element = event.currentTarget;
const multiSelect = event.metaKey;
event.stopPropagation(); event.stopPropagation();
this.$emit('activity-selected', {
this.openmct.selection.select( event,
[ selection: activity.selection
{ });
element: element,
context: {
type: 'activity',
activity: activity
}
},
{
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject,
supportsMultiSelect: true
}
}
],
multiSelect
);
} }
} }
}; };

View File

@ -47,6 +47,7 @@
:width="group.width" :width="group.width"
:is-nested="options.isChildObject" :is-nested="options.isChildObject"
:status="status" :status="status"
@activity-selected="selectActivity"
/> />
</div> </div>
</div> </div>
@ -134,7 +135,7 @@ export default {
this.swimlaneVisibility = this.configuration.swimlaneVisibility; this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.clipActivityNames = this.configuration.clipActivityNames; this.clipActivityNames = this.configuration.clipActivityNames;
if (this.domainObject.type === 'plan') { if (this.domainObject.type === 'plan') {
this.planData = getValidatedData(this.domainObject); this.setPlanData(this.domainObject);
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -177,6 +178,9 @@ export default {
this.planViewConfiguration.destroy(); this.planViewConfiguration.destroy();
}, },
methods: { methods: {
setPlanData(domainObject) {
this.planData = getValidatedData(domainObject);
},
activityNameFitsRect(activityName, rectWidth) { activityNameFitsRect(activityName, rectWidth) {
return this.getTextWidth(activityName) + TEXT_LEFT_PADDING < rectWidth; return this.getTextWidth(activityName) + TEXT_LEFT_PADDING < rectWidth;
}, },
@ -215,9 +219,7 @@ export default {
callback: () => { callback: () => {
this.removeFromComposition(this.planObject); this.removeFromComposition(this.planObject);
this.planObject = domainObject; this.planObject = domainObject;
this.planData = getValidatedData(domainObject); this.handleSelectFileChange();
this.setStatus(this.openmct.status.get(domainObject.identifier));
this.setScaleAndGenerateActivities();
dialog.dismiss(); dialog.dismiss();
} }
}, },
@ -237,9 +239,7 @@ export default {
} else { } else {
this.planObject = domainObject; this.planObject = domainObject;
this.swimlaneVisibility = this.configuration.swimlaneVisibility; this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.planData = getValidatedData(domainObject); this.handleSelectFileChange(domainObject);
this.setStatus(this.openmct.status.get(domainObject.identifier));
this.setScaleAndGenerateActivities();
} }
}, },
handleConfigurationChange(newConfiguration) { handleConfigurationChange(newConfiguration) {
@ -259,8 +259,10 @@ export default {
this.setScaleAndGenerateActivities(); this.setScaleAndGenerateActivities();
}, },
handleSelectFileChange() { handleSelectFileChange(domainObject) {
this.planData = getValidatedData(this.domainObject); const planDomainObject = domainObject || this.domainObject;
this.setPlanData(planDomainObject);
this.setStatus(this.openmct.status.get(planDomainObject.identifier));
this.setScaleAndGenerateActivities(); this.setScaleAndGenerateActivities();
}, },
removeFromComposition(domainObject) { removeFromComposition(domainObject) {
@ -434,7 +436,7 @@ export default {
return; return;
} }
rawActivities.forEach((rawActivity) => { rawActivities.forEach((rawActivity, index) => {
if (!this.isActivityInBounds(rawActivity)) { if (!this.isActivityInBounds(rawActivity)) {
return; return;
} }
@ -481,13 +483,10 @@ export default {
const activity = { const activity = {
color: color, color: color,
textColor: textColor, textColor: textColor,
name: rawActivity.name,
exceeds: { exceeds: {
start: this.xScale(this.viewBounds.start) > this.xScale(rawActivity.start), start: this.xScale(this.viewBounds.start) > this.xScale(rawActivity.start),
end: this.xScale(this.viewBounds.end) < this.xScale(rawActivity.end) end: this.xScale(this.viewBounds.end) < this.xScale(rawActivity.end)
}, },
start: rawActivity.start,
end: rawActivity.end,
row: currentRow, row: currentRow,
textLines: textLines, textLines: textLines,
textStart: textStart, textStart: textStart,
@ -496,7 +495,11 @@ export default {
rectStart: rectX1, rectStart: rectX1,
rectEnd: showTextInsideRect ? rectX2 : textStart + textWidth, rectEnd: showTextInsideRect ? rectX2 : textStart + textWidth,
rectWidth: rectWidth, rectWidth: rectWidth,
clipPathId: this.getClipPathId(groupName, rawActivity, currentRow) clipPathId: this.getClipPathId(groupName, rawActivity, currentRow),
selection: {
groupName,
index
}
}; };
activitiesByRow[currentRow].push(activity); activitiesByRow[currentRow].push(activity);
}); });
@ -573,6 +576,31 @@ export default {
const activityName = activity.name.toLowerCase().replace(/ /g, '-'); const activityName = activity.name.toLowerCase().replace(/ /g, '-');
return `${groupName}-${activityName}-${activity.start}-${activity.end}-${row}`; 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
);
} }
} }
}; };

View File

@ -20,21 +20,35 @@
at runtime from the About dialog for additional information. at runtime from the About dialog for additional information.
--> -->
<template> <template>
<div class="c-inspector__properties c-inspect-properties"> <plan-activity-time-view
<plan-activity-view v-for="activity in activities"
v-for="activity in activities" :key="activity.key"
:key="activity.id" :activity="activity"
:activity="activity" :heading="heading"
:heading="heading" />
/> <plan-activity-properties-view
</div> 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> </template>
<script> <script>
import { getPreciseDuration } from 'utils/duration'; 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 = { const propertyLabels = {
start: 'Start DateTime', start: 'Start DateTime',
@ -44,23 +58,34 @@ const propertyLabels = {
latestEnd: 'Latest End', latestEnd: 'Latest End',
gap: 'Gap', gap: 'Gap',
overlap: 'Overlap', overlap: 'Overlap',
totalTime: 'Total Time' totalTime: 'Total Time',
description: 'Description'
}; };
export default { export default {
components: { components: {
PlanActivityView PlanActivityTimeView,
PlanActivityPropertiesView,
PlanActivityStatusView
}, },
inject: ['openmct', 'selection'], inject: ['openmct', 'selection'],
data() { data() {
return { return {
name: '', name: '',
activities: [], activities: [],
selectedActivities: [],
activityExecutionState: undefined,
heading: '' heading: ''
}; };
}, },
computed: {
canPersistState() {
return this.selectedActivities.length === 1 && this.activities?.[0]?.id;
}
},
mounted() { mounted() {
this.setFormatters(); this.setFormatters();
this.getPlanData(this.selection); this.getPlanData(this.selection);
this.getActivityStates();
this.getActivities(); this.getActivities();
this.openmct.selection.on('change', this.updateSelection); this.openmct.selection.on('change', this.updateSelection);
this.openmct.time.on('timeSystem', this.setFormatters); this.openmct.time.on('timeSystem', this.setFormatters);
@ -68,8 +93,28 @@ export default {
beforeUnmount() { beforeUnmount() {
this.openmct.selection.off('change', this.updateSelection); this.openmct.selection.off('change', this.updateSelection);
this.openmct.time.off('timeSystem', this.setFormatters); this.openmct.time.off('timeSystem', this.setFormatters);
if (this.stopObservingActivityStatesObject) {
this.stopObservingActivityStatesObject();
}
}, },
methods: { 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() { setFormatters() {
let timeSystem = this.openmct.time.timeSystem(); let timeSystem = this.openmct.time.timeSystem();
this.timeFormatter = this.openmct.telemetry.getValueFormatter({ this.timeFormatter = this.openmct.telemetry.getValueFormatter({
@ -86,6 +131,7 @@ export default {
if (selectionItem[0].context.type === 'activity') { if (selectionItem[0].context.type === 'activity') {
const activity = selectionItem[0].context.activity; const activity = selectionItem[0].context.activity;
if (activity) { if (activity) {
activity.key = activity.id ?? activity.name;
this.selectedActivities.push(activity); this.selectedActivities.push(activity);
} }
} }
@ -104,20 +150,37 @@ export default {
this.activities.splice(0); this.activities.splice(0);
this.selectedActivities.forEach((selectedActivity, index) => { this.selectedActivities.forEach((selectedActivity, index) => {
const activity = { const activity = {
id: uuid(), id: selectedActivity.id,
start: { key: selectedActivity.key,
label: propertyLabels.start, timeProperties: {
value: this.formatTime(selectedActivity.start) start: {
}, label: propertyLabels.start,
end: { value: this.formatTime(selectedActivity.start)
label: propertyLabels.end, },
value: this.formatTime(selectedActivity.end) end: {
}, label: propertyLabels.end,
duration: { value: this.formatTime(selectedActivity.end)
label: propertyLabels.duration, },
value: this.formatDuration(selectedActivity.end - selectedActivity.start) 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; this.activities[index] = activity;
}); });
}, },
@ -141,6 +204,8 @@ export default {
let latestEnd; let latestEnd;
let gap; let gap;
let overlap; let overlap;
let id;
let key;
//Sort by start time //Sort by start time
let selectedActivities = this.selectedActivities.sort(this.sortFn); let selectedActivities = this.selectedActivities.sort(this.sortFn);
@ -159,6 +224,8 @@ export default {
earliestStart = Math.min(earliestStart, selectedActivity.start); earliestStart = Math.min(earliestStart, selectedActivity.start);
latestEnd = Math.max(latestEnd, selectedActivity.end); latestEnd = Math.max(latestEnd, selectedActivity.end);
} else { } else {
id = selectedActivity.id;
key = selectedActivity.id ?? selectedActivity.name;
earliestStart = selectedActivity.start; earliestStart = selectedActivity.start;
latestEnd = selectedActivity.end; latestEnd = selectedActivity.end;
} }
@ -166,30 +233,33 @@ export default {
let totalTime = latestEnd - earliestStart; let totalTime = latestEnd - earliestStart;
const activity = { const activity = {
id: uuid(), id,
earliestStart: { key,
label: propertyLabels.earliestStart, timeProperties: {
value: this.formatTime(earliestStart) earliestStart: {
}, label: propertyLabels.earliestStart,
latestEnd: { value: this.formatTime(earliestStart)
label: propertyLabels.latestEnd, },
value: this.formatTime(latestEnd) latestEnd: {
label: propertyLabels.latestEnd,
value: this.formatTime(latestEnd)
}
} }
}; };
if (gap) { if (gap) {
activity.gap = { activity.timeProperties.gap = {
label: propertyLabels.gap, label: propertyLabels.gap,
value: this.formatDuration(gap) value: this.formatDuration(gap)
}; };
} else if (overlap) { } else if (overlap) {
activity.overlap = { activity.timeProperties.overlap = {
label: propertyLabels.overlap, label: propertyLabels.overlap,
value: this.formatDuration(overlap) value: this.formatDuration(overlap)
}; };
} }
activity.totalTime = { activity.timeProperties.totalTime = {
label: propertyLabels.totalTime, label: propertyLabels.totalTime,
value: this.formatDuration(totalTime) value: this.formatDuration(totalTime)
}; };
@ -201,6 +271,11 @@ export default {
}, },
formatTime(time) { formatTime(time) {
return this.timeFormatter.format(time); return this.timeFormatter.format(time);
},
persistActivityState(data) {
const { key, executionState } = data;
const activitiesPath = `activities.${key}`;
this.openmct.objects.mutate(this.activityStatesObject, activitiesPath, executionState);
} }
} }
}; };

View File

@ -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>

View 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>

View File

@ -21,23 +21,23 @@
--> -->
<template> <template>
<div v-if="timeProperties.length" class="u-contents"> <div class="c-inspector__properties c-inspect-properties">
<div class="c-inspect-properties__header"> <div v-if="timeProperties.length" class="u-contents">
{{ heading }} <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> </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> </div>
</template> </template>
<script> <script>
import { v4 as uuid } from 'uuid';
import ActivityProperty from './ActivityProperty.vue'; import ActivityProperty from './ActivityProperty.vue';
export default { export default {
@ -64,13 +64,14 @@ export default {
}, },
methods: { methods: {
setProperties() { setProperties() {
Object.keys(this.activity).forEach((key) => { Object.keys(this.activity.timeProperties).forEach((key) => {
if (this.activity[key].label) { if (this.activity.timeProperties[key].label) {
const label = this.activity[key].label; const label = this.activity.timeProperties[key].label;
const value = String(this.activity[key].value); const value = String(this.activity.timeProperties[key].value);
const id = this.activity.id;
this.timeProperties[this.timeProperties.length] = { this.timeProperties[this.timeProperties.length] = {
id: uuid(), id,
label, label,
value value
}; };

View File

@ -20,12 +20,28 @@
* at runtime from the About dialog for additional information. * 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 ganttChartCompositionPolicy from './GanttChartCompositionPolicy.js';
import ActivityInspectorViewProvider from './inspector/ActivityInspectorViewProvider.js'; import ActivityInspectorViewProvider from './inspector/ActivityInspectorViewProvider.js';
import GanttChartInspectorViewProvider from './inspector/GanttChartInspectorViewProvider.js'; import GanttChartInspectorViewProvider from './inspector/GanttChartInspectorViewProvider.js';
import { DEFAULT_CONFIGURATION } from './PlanViewConfiguration.js'; import { DEFAULT_CONFIGURATION } from './PlanViewConfiguration.js';
import PlanViewProvider from './PlanViewProvider.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 = {}) { export default function (options = {}) {
return function install(openmct) { return function install(openmct) {
openmct.types.addType('plan', { openmct.types.addType('plan', {
@ -70,5 +86,13 @@ export default function (options = {}) {
openmct.inspectorViews.addProvider(new ActivityInspectorViewProvider(openmct)); openmct.inspectorViews.addProvider(new ActivityInspectorViewProvider(openmct));
openmct.inspectorViews.addProvider(new GanttChartInspectorViewProvider(openmct)); openmct.inspectorViews.addProvider(new GanttChartInspectorViewProvider(openmct));
openmct.composition.addPolicy(ganttChartCompositionPolicy(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 })
);
}; };
} }

View File

@ -20,6 +20,19 @@
* at runtime from the About dialog for additional information. * 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) { export function getValidatedData(domainObject) {
const sourceMap = domainObject.sourceMap; const sourceMap = domainObject.sourceMap;
const json = getObjectJson(domainObject); const json = getObjectJson(domainObject);
@ -45,6 +58,24 @@ export function getValidatedData(domainObject) {
groupActivity.end = activity[sourceMap.end]; 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]) { if (!mappedJson[groupIdKey]) {
mappedJson[groupIdKey] = []; mappedJson[groupIdKey] = [];
} }
@ -92,7 +123,6 @@ export function getValidatedGroups(domainObject, planData) {
orderedGroupNames = groups; orderedGroupNames = groups;
} }
} }
if (orderedGroupNames === undefined) { if (orderedGroupNames === undefined) {
orderedGroupNames = Object.keys(planData); orderedGroupNames = Object.keys(planData);
} }
@ -100,6 +130,37 @@ export function getValidatedGroups(domainObject, planData) {
return orderedGroupNames; 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) { export function getContrastingColor(hexColor) {
function cutHex(h, start, end) { function cutHex(h, start, end) {
const hStr = h.charAt(0) === '#' ? h.substring(1, 7) : h; const hStr = h.charAt(0) === '#' ? h.substring(1, 7) : h;

View File

@ -200,7 +200,13 @@ export default {
this.chartVisible = true; this.chartVisible = true;
this.chartContainer = this.$refs.chart; this.chartContainer = this.$refs.chart;
this.drawnOnce = false; 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); eventHelpers.extend(this);
this.seriesModels = []; this.seriesModels = [];
this.config = this.getConfig(); this.config = this.getConfig();
@ -276,6 +282,8 @@ export default {
return config; return config;
}, },
visibilityChanged([entry]) { 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) { if (entry.target === this.chartContainer) {
const wasVisible = this.chartVisible; const wasVisible = this.chartVisible;
this.chartVisible = entry.isIntersecting; this.chartVisible = entry.isIntersecting;

View File

@ -139,7 +139,9 @@ export default class Model extends EventEmitter {
/** @typedef {any} TODO */ /** @typedef {any} TODO */
/** @typedef {TODO} OpenMCT */ /**
* @typedef {import('../../../../openmct.js').OpenMCT} OpenMCT
*/
/** /**
@template {object} T @template {object} T

View File

@ -211,9 +211,16 @@ export default class PlotSeries extends Model {
); );
if (!this.unsubscribe) { if (!this.unsubscribe) {
this.unsubscribe = this.openmct.telemetry.subscribe(this.domainObject, this.add.bind(this), { this.unsubscribe = this.openmct.telemetry.subscribe(
filters: this.filters this.domainObject,
}); (data) => {
this.addAll(data, true);
},
{
filters: this.filters,
strategy: this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH
}
);
} }
try { try {
@ -302,9 +309,7 @@ export default class PlotSeries extends Model {
this.resetStats(); this.resetStats();
this.emit('reset'); this.emit('reset');
if (newData) { if (newData) {
newData.forEach(function (point) { this.addAll(newData, true);
this.add(point, true);
}, this);
} }
} }
/** /**
@ -416,14 +421,14 @@ export default class PlotSeries extends Model {
* when adding an array of points that are already properly sorted. * when adding an array of points that are already properly sorted.
* *
* @private * @private
* @param {Object} point a telemetry datum. * @param {Object} newData a telemetry datum.
* @param {Boolean} [appendOnly] default false, if true will append * @param {Boolean} [sorted] default false, if true will append
* a point to the end without dupe checking. * a point to the end without dupe checking.
*/ */
add(point, appendOnly) { add(newData, sorted = false) {
let data = this.getSeriesData(); let data = this.getSeriesData();
let insertIndex = data.length; let insertIndex = data.length;
const currentYVal = this.getYVal(point); const currentYVal = this.getYVal(newData);
const lastYVal = this.getYVal(data[insertIndex - 1]); const lastYVal = this.getYVal(data[insertIndex - 1]);
if (this.isValueInvalid(currentYVal) && this.isValueInvalid(lastYVal)) { if (this.isValueInvalid(currentYVal) && this.isValueInvalid(lastYVal)) {
@ -432,22 +437,28 @@ export default class PlotSeries extends Model {
return; return;
} }
if (!appendOnly) { if (!sorted) {
insertIndex = this.sortedIndex(point); insertIndex = this.sortedIndex(newData);
if (this.getXVal(data[insertIndex]) === this.getXVal(point)) { if (this.getXVal(data[insertIndex]) === this.getXVal(newData)) {
return; return;
} }
if (this.getXVal(data[insertIndex - 1]) === this.getXVal(point)) { if (this.getXVal(data[insertIndex - 1]) === this.getXVal(newData)) {
return; return;
} }
} }
this.updateStats(point); this.updateStats(newData);
point.mctLimitState = this.evaluate(point); newData.mctLimitState = this.evaluate(newData);
data.splice(insertIndex, 0, point); data.splice(insertIndex, 0, newData);
this.updateSeriesData(data); 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);
}
} }
/** /**

View File

@ -65,6 +65,7 @@ import PerformanceIndicator from './performanceIndicator/plugin.js';
import CouchDBPlugin from './persistence/couch/plugin.js'; import CouchDBPlugin from './persistence/couch/plugin.js';
import PlanLayout from './plan/plugin.js'; import PlanLayout from './plan/plugin.js';
import PlotPlugin from './plot/plugin.js'; import PlotPlugin from './plot/plugin.js';
import ReloadAction from './reloadAction/plugin.js';
import RemoteClock from './remoteClock/plugin.js'; import RemoteClock from './remoteClock/plugin.js';
import StaticRootPlugin from './staticRootPlugin/plugin.js'; import StaticRootPlugin from './staticRootPlugin/plugin.js';
import SummaryWidget from './summaryWidget/plugin.js'; import SummaryWidget from './summaryWidget/plugin.js';
@ -141,6 +142,7 @@ plugins.Filters = Filters;
plugins.ObjectMigration = ObjectMigration; plugins.ObjectMigration = ObjectMigration;
plugins.GoToOriginalAction = GoToOriginalAction; plugins.GoToOriginalAction = GoToOriginalAction;
plugins.OpenInNewTabAction = OpenInNewTabAction; plugins.OpenInNewTabAction = OpenInNewTabAction;
plugins.ReloadAction = ReloadAction;
plugins.ClearData = ClearData; plugins.ClearData = ClearData;
plugins.WebPage = WebPagePlugin; plugins.WebPage = WebPagePlugin;
plugins.Espresso = Espresso; plugins.Espresso = Espresso;

View 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);
}
}

View 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));
};
}

View File

@ -59,26 +59,21 @@ export default class RemoteClock extends DefaultClock {
} }
start() { start() {
this.openmct.objects this.openmct.objects.get(this.identifier).then((domainObject) => {
.get(this.identifier) // The start method is called when at least one listener registers with the clock.
.then((domainObject) => { // When the clock is changed, listeners are unregistered from the clock and the stop method is called.
// The start method is called when at least one listener registers with the clock. // Sometimes, the objects.get call above does not resolve before the stop method is called.
// When the clock is changed, listeners are unregistered from the clock and 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.
// Sometimes, the objects.get call above does not resolve before the stop method is called. if (this.eventNames().length === 0) {
// So when we proceed with the clock subscription below, we first need to ensure that there is at least one listener for our clock. return;
if (this.eventNames().length === 0) { }
return; this.openmct.time.on('timeSystem', this._timeSystemChange);
} this.timeTelemetryObject = domainObject;
this.openmct.time.on('timeSystem', this._timeSystemChange); this.metadata = this.openmct.telemetry.getMetadata(domainObject);
this.timeTelemetryObject = domainObject; this._timeSystemChange();
this.metadata = this.openmct.telemetry.getMetadata(domainObject); this._requestLatest();
this._timeSystemChange(); this._subscribe();
this._requestLatest(); });
this._subscribe();
})
.catch((error) => {
throw new Error(error);
});
} }
stop() { stop() {

View File

@ -21,8 +21,10 @@
*****************************************************************************/ *****************************************************************************/
import Tabs from './tabs.js'; import Tabs from './tabs.js';
export default function plugin() { export default function plugin(options) {
return function install(openmct) { return function install(openmct) {
const eagerLoad = options?.eagerLoad ?? false;
openmct.objectViews.addProvider(new Tabs(openmct)); openmct.objectViews.addProvider(new Tabs(openmct));
openmct.types.addType('tabs', { openmct.types.addType('tabs', {
@ -32,13 +34,13 @@ export default function plugin() {
cssClass: 'icon-tabs-view', cssClass: 'icon-tabs-view',
initialize(domainObject) { initialize(domainObject) {
domainObject.composition = []; domainObject.composition = [];
domainObject.keep_alive = true; domainObject.keep_alive = eagerLoad;
}, },
form: [ form: [
{ {
key: 'keep_alive', key: 'keep_alive',
name: 'Eager Load Tabs', name: 'Eager Load Tabs',
control: 'select', control: 'toggleSwitch',
options: [ options: [
{ {
name: 'True', name: 'True',

View File

@ -30,7 +30,8 @@ describe('the plugin', function () {
let element; let element;
let child; let child;
let openmct; let openmct;
let tabsLayoutDefinition; let tabsType;
const testViewObject = { const testViewObject = {
identifier: { identifier: {
key: 'mock-tabs-object', key: 'mock-tabs-object',
@ -85,8 +86,7 @@ describe('the plugin', function () {
beforeEach((done) => { beforeEach((done) => {
openmct = createOpenMct(); openmct = createOpenMct();
openmct.install(new TabsLayout()); tabsType = openmct.types.get('tabs');
tabsLayoutDefinition = openmct.types.get('tabs');
element = document.createElement('div'); element = document.createElement('div');
child = document.createElement('div'); child = document.createElement('div');
@ -100,15 +100,56 @@ describe('the plugin', function () {
}); });
afterEach(() => { afterEach(() => {
child = undefined;
element = undefined;
return resetApplicationState(openmct); return resetApplicationState(openmct);
}); });
it('defines a tabs object type with the correct key', () => { it('is installed by default and provides a tabs object', () => {
expect(tabsLayoutDefinition.definition.name).toEqual('Tabs View'); expect(tabsType.definition.name).toEqual('Tabs View');
}); });
it('is creatable', () => { it('the tabs object is creatable', () => {
expect(tabsLayoutDefinition.definition.creatable).toEqual(true); 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 () { describe('the view', function () {

View File

@ -37,10 +37,11 @@ export default class TelemetryTable extends EventEmitter {
this.domainObject = domainObject; this.domainObject = domainObject;
this.openmct = openmct; this.openmct = openmct;
this.rowCount = 100;
this.tableComposition = undefined; this.tableComposition = undefined;
this.datumCache = []; this.datumCache = [];
this.configuration = new TelemetryTableConfiguration(domainObject, openmct); this.configuration = new TelemetryTableConfiguration(domainObject, openmct);
this.telemetryMode = this.configuration.getTelemetryMode();
this.rowLimit = this.configuration.getRowLimit();
this.paused = false; this.paused = false;
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier); 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() { createTableRowCollections() {
this.tableRows = new TableRowCollection(); this.tableRows = new TableRowCollection();
//Fetch any persisted default sort //Fetch any persisted default sort
let sortOptions = this.configuration.getConfiguration().sortOptions; 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 || { sortOptions = sortOptions || {
key: this.openmct.time.timeSystem().key, key: this.openmct.time.timeSystem().key,
direction: 'asc' direction: 'desc'
}; };
this.updateRowLimit();
this.tableRows.sortBy(sortOptions); this.tableRows.sortBy(sortOptions);
this.tableRows.on('resetRowsFromAllData', this.resetRowsFromAllData); this.tableRows.on('resetRowsFromAllData', this.resetRowsFromAllData);
} }
@ -144,6 +167,11 @@ export default class TelemetryTable extends EventEmitter {
this.removeTelemetryCollection(keyString); this.removeTelemetryCollection(keyString);
if (this.telemetryMode === 'performance') {
requestOptions.size = this.rowLimit;
requestOptions.enforceSize = true;
}
this.telemetryCollections[keyString] = this.openmct.telemetry.requestCollection( this.telemetryCollections[keyString] = this.openmct.telemetry.requestCollection(
telemetryObject, telemetryObject,
requestOptions requestOptions

View File

@ -48,6 +48,10 @@ export default class TelemetryTableConfiguration extends EventEmitter {
configuration.columnOrder = configuration.columnOrder || []; configuration.columnOrder = configuration.columnOrder || [];
configuration.cellFormat = configuration.cellFormat || {}; configuration.cellFormat = configuration.cellFormat || {};
configuration.autosize = configuration.autosize === undefined ? true : configuration.autosize; 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; 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() { getColumnWidths() {
let configuration = this.getConfiguration(); let configuration = this.getConfiguration();

View File

@ -20,17 +20,57 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
export default { export default function getTelemetryTableType(options = {}) {
name: 'Telemetry Table', const { telemetryMode = 'performance', persistModeChanges = true, rowLimit = 50 } = options;
description:
'Display values for one or more telemetry end points in a scrolling table. Each row is a time-stamped value.', return {
creatable: true, name: 'Telemetry Table',
cssClass: 'icon-tabular-scrolling', description:
initialize(domainObject) { 'Display values for one or more telemetry end points in a scrolling table. Each row is a time-stamped value.',
domainObject.composition = []; creatable: true,
domainObject.configuration = { cssClass: 'icon-tabular-scrolling',
columnWidths: {}, form: [
hiddenColumns: {} {
}; 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
};
}
};
}

View File

@ -25,7 +25,7 @@ import TableComponent from './components/TableComponent.vue';
import TelemetryTable from './TelemetryTable.js'; import TelemetryTable from './TelemetryTable.js';
export default class TelemetryTableView { export default class TelemetryTableView {
constructor(openmct, domainObject, objectPath) { constructor(openmct, domainObject, objectPath, options) {
this.openmct = openmct; this.openmct = openmct;
this.domainObject = domainObject; this.domainObject = domainObject;
this.objectPath = objectPath; this.objectPath = objectPath;

View File

@ -22,7 +22,7 @@
import TelemetryTableView from './TelemetryTableView.js'; import TelemetryTableView from './TelemetryTableView.js';
export default function TelemetryTableViewProvider(openmct) { export default function TelemetryTableViewProvider(openmct, options) {
function hasTelemetry(domainObject) { function hasTelemetry(domainObject) {
if (!Object.prototype.hasOwnProperty.call(domainObject, 'telemetry')) { if (!Object.prototype.hasOwnProperty.call(domainObject, 'telemetry')) {
return false; return false;
@ -44,7 +44,7 @@ export default function TelemetryTableViewProvider(openmct) {
return domainObject.type === 'table'; return domainObject.type === 'table';
}, },
view(domainObject, objectPath) { view(domainObject, objectPath) {
return new TelemetryTableView(openmct, domainObject, objectPath); return new TelemetryTableView(openmct, domainObject, objectPath, options);
}, },
priority() { priority() {
return 1; return 1;

View File

@ -129,6 +129,15 @@ export default class TableRowCollection extends EventEmitter {
this.rows[index] = foundRow; this.rows[index] = foundRow;
} }
setLimit(rowLimit) {
this.rowLimit = rowLimit;
}
removeLimit() {
this.rowLimit = null;
delete this.rowLimit;
}
sortCollection(rows) { sortCollection(rows) {
const sortedRows = _.orderBy( const sortedRows = _.orderBy(
rows, rows,
@ -363,10 +372,22 @@ export default class TableRowCollection extends EventEmitter {
} }
getRows() { 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; return this.rows;
} }
getRowsLength() { getRowsLength() {
if (this.rowLimit && this.rows.length > this.rowLimit) {
return this.rowLimit;
}
return this.rows.length; return this.rows.length;
} }

View File

@ -22,8 +22,8 @@
<template> <template>
<td <td
ref="tableCell" ref="tableCell"
:aria-label="formattedValue"
:title="formattedValue" :title="formattedValue"
:aria-label="`${columnKey} table cell ${formattedValue}`"
@click="selectCell($event.currentTarget, columnKey)" @click="selectCell($event.currentTarget, columnKey)"
@mouseover.ctrl="showToolTip" @mouseover.ctrl="showToolTip"
@mouseleave="hideToolTip" @mouseleave="hideToolTip"

View File

@ -222,6 +222,7 @@
ref="contentTable" ref="contentTable"
class="c-table__body c-telemetry-table__body js-telemetry-table__content" class="c-table__body c-telemetry-table__body js-telemetry-table__content"
:style="{ height: totalHeight + 'px' }" :style="{ height: totalHeight + 'px' }"
:aria-label="`${table.domainObject.name} table content`"
> >
<tbody> <tbody>
<telemetry-table-row <telemetry-table-row
@ -233,7 +234,7 @@
:object-path="objectPath" :object-path="objectPath"
:row-offset="rowOffset" :row-offset="rowOffset"
:row-height="rowHeight" :row-height="rowHeight"
:row="row" :row="getRow(rowIndex)"
:marked="row.marked" :marked="row.marked"
@mark="markRow" @mark="markRow"
@unmark="unmarkRow" @unmark="unmarkRow"
@ -276,6 +277,8 @@
class="c-telemetry-table__footer" class="c-telemetry-table__footer"
:marked-rows="markedRows.length" :marked-rows="markedRows.length"
:total-rows="totalNumberOfRows" :total-rows="totalNumberOfRows"
:telemetry-mode="telemetryMode"
@telemetry-mode-change="updateTelemetryMode"
/> />
</div> </div>
</div> </div>
@ -292,6 +295,7 @@ import CSVExporter from '../../../exporters/CSVExporter.js';
import ProgressBar from '../../../ui/components/ProgressBar.vue'; import ProgressBar from '../../../ui/components/ProgressBar.vue';
import Search from '../../../ui/components/SearchComponent.vue'; import Search from '../../../ui/components/SearchComponent.vue';
import ToggleSwitch from '../../../ui/components/ToggleSwitch.vue'; import ToggleSwitch from '../../../ui/components/ToggleSwitch.vue';
import throttle from '../../../utils/throttle';
import SizingRow from './SizingRow.vue'; import SizingRow from './SizingRow.vue';
import TableColumnHeader from './TableColumnHeader.vue'; import TableColumnHeader from './TableColumnHeader.vue';
import TableFooterIndicator from './TableFooterIndicator.vue'; import TableFooterIndicator from './TableFooterIndicator.vue';
@ -300,7 +304,7 @@ import TelemetryTableRow from './TableRow.vue';
const VISIBLE_ROW_COUNT = 100; const VISIBLE_ROW_COUNT = 100;
const ROW_HEIGHT = 17; const ROW_HEIGHT = 17;
const RESIZE_POLL_INTERVAL = 200; const RESIZE_POLL_INTERVAL = 200;
const AUTO_SCROLL_TRIGGER_HEIGHT = 100; const AUTO_SCROLL_TRIGGER_HEIGHT = ROW_HEIGHT * 3;
export default { export default {
components: { components: {
@ -384,7 +388,9 @@ export default {
enableRegexSearch: {}, enableRegexSearch: {},
hideHeaders: configuration.hideHeaders, hideHeaders: configuration.hideHeaders,
totalNumberOfRows: 0, totalNumberOfRows: 0,
rowContext: {} rowContext: {},
telemetryMode: configuration.telemetryMode,
persistModeChanges: configuration.persistModeChanges
}; };
}, },
computed: { computed: {
@ -437,6 +443,12 @@ export default {
watch: { watch: {
loading: { loading: {
handler(isLoading) { handler(isLoading) {
if (isLoading) {
this.setLoadingPromise();
} else {
this.loadFinishResolve();
}
if (this.viewActionsCollection) { if (this.viewActionsCollection) {
let action = isLoading ? 'disable' : 'enable'; let action = isLoading ? 'disable' : 'enable';
this.viewActionsCollection[action](['export-csv-all']); 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-added', this.addObject);
this.table.on('object-removed', this.removeObject); this.table.on('object-removed', this.removeObject);
this.table.on('refresh', this.clearRowsAndRerender); this.table.on('refresh', this.clearRowsAndRerender);
@ -555,6 +569,12 @@ export default {
this.table.destroy(); this.table.destroy();
}, },
methods: { methods: {
setLoadingPromise() {
this.loadFinishResolve = null;
this.isFinishedLoading = new Promise((resolve, reject) => {
this.loadFinishResolve = resolve;
});
},
updateVisibleRows() { updateVisibleRows() {
if (!this.updatingView) { if (!this.updatingView) {
this.updatingView = this.renderWhenVisible(() => { this.updatingView = this.renderWhenVisible(() => {
@ -632,7 +652,21 @@ export default {
this.calculateScrollbarWidth(); this.calculateScrollbarWidth();
}, },
getRow(rowIndex) {
return toRaw(this.visibleRows[rowIndex]);
},
sortBy(columnKey) { 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 sorting by the same column, flip the sort direction.
if (this.sortOptions.key === columnKey) { if (this.sortOptions.key === columnKey) {
if (this.sortOptions.direction === 'asc') { if (this.sortOptions.direction === 'asc') {
@ -643,7 +677,7 @@ export default {
} else { } else {
this.sortOptions = { this.sortOptions = {
key: columnKey, key: columnKey,
direction: 'asc' direction: 'desc'
}; };
} }
@ -653,7 +687,7 @@ export default {
this.updateVisibleRows(); this.updateVisibleRows();
this.synchronizeScrollX(); this.synchronizeScrollX();
if (this.shouldSnapToBottom()) { if (this.shouldAutoScroll()) {
this.autoScroll = true; this.autoScroll = true;
} else { } else {
// If user scrolls away from bottom, disable auto-scroll. // If user scrolls away from bottom, disable auto-scroll.
@ -661,13 +695,17 @@ export default {
this.autoScroll = false; this.autoScroll = false;
} }
}, },
shouldSnapToBottom() { shouldAutoScroll() {
if (this.sortOptions.direction === 'desc') {
return false;
}
return ( return (
this.scrollable.scrollTop >= this.scrollable.scrollTop >=
this.scrollable.scrollHeight - this.scrollable.offsetHeight - AUTO_SCROLL_TRIGGER_HEIGHT this.scrollable.scrollHeight - this.scrollable.offsetHeight - AUTO_SCROLL_TRIGGER_HEIGHT
); );
}, },
scrollToBottom() { initiateAutoScroll() {
this.scrollable.scrollTop = Number.MAX_SAFE_INTEGER; this.scrollable.scrollTop = Number.MAX_SAFE_INTEGER;
}, },
synchronizeScrollX() { synchronizeScrollX() {
@ -716,7 +754,7 @@ export default {
} }
if (this.autoScroll) { if (this.autoScroll) {
this.scrollToBottom(); this.initiateAutoScroll();
} }
this.updateVisibleRows(); this.updateVisibleRows();
@ -743,12 +781,25 @@ export default {
headers: headerKeys headers: headerKeys
}); });
}, },
exportAllDataAsCSV() { getTableRowData() {
const justTheData = this.table.tableRows const justTheData = this.table.tableRows
.getRows() .getRows()
.map((row) => row.getFormattedDatum(this.headers)); .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() { exportMarkedDataAsCSV() {
const data = this.table.tableRows 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? // 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. // Need to preserve scroll position in this case.
if (this.autoScroll) { if (this.autoScroll) {
this.scrollToBottom(); this.initiateAutoScroll();
} else { } else {
this.scrollable.scrollTop = scrollTop; this.scrollable.scrollTop = scrollTop;
} }
@ -1099,6 +1150,54 @@ export default {
this.viewActionsCollection.hide(['expand-columns']); 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) { setRowHeight(height) {
this.rowHeight = height; this.rowHeight = height;
this.setHeight(); this.setHeight();

View File

@ -36,11 +36,11 @@
<div class="c-table-indicator__counts"> <div class="c-table-indicator__counts">
<span <span
:aria-label="totalRows + ' rows visible after any filtering'" :aria-label="rowCountTitle"
:title="totalRows + ' rows visible after any filtering'" :title="rowCountTitle"
class="c-table-indicator__elem c-table-indicator__row-count" class="c-table-indicator__elem c-table-indicator__row-count"
> >
{{ totalRows }} Rows {{ rowCount }} Rows
</span> </span>
<span <span
@ -51,6 +51,10 @@
> >
{{ markedRows }} Marked {{ markedRows }} Marked
</span> </span>
<button :title="telemetryModeButtonTitle" class="c-button" @click="toggleTelemetryMode">
{{ telemetryModeButtonLabel }}
</button>
</div> </div>
</div> </div>
</template> </template>
@ -74,8 +78,13 @@ export default {
totalRows: { totalRows: {
type: Number, type: Number,
default: 0 default: 0
},
telemetryMode: {
type: String,
default: 'performance'
} }
}, },
emits: ['telemetry-mode-change'],
data() { data() {
return { return {
filterNames: [], filterNames: [],
@ -93,6 +102,9 @@ export default {
return !_.isEqual(filtersToCompare, _.omit(filters, [USE_GLOBAL])); return !_.isEqual(filtersToCompare, _.omit(filters, [USE_GLOBAL]));
}); });
}, },
isUnlimitedMode() {
return this.telemetryMode === 'unlimited';
},
label() { label() {
if (this.hasMixedFilters) { if (this.hasMixedFilters) {
return FILTER_INDICATOR_LABEL_MIXED; return FILTER_INDICATOR_LABEL_MIXED;
@ -100,6 +112,22 @@ export default {
return FILTER_INDICATOR_LABEL; 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() { title() {
if (this.hasMixedFilters) { if (this.hasMixedFilters) {
return FILTER_INDICATOR_TITLE_MIXED; return FILTER_INDICATOR_TITLE_MIXED;
@ -117,6 +145,9 @@ export default {
this.table.configuration.off('change', this.handleConfigurationChanges); this.table.configuration.off('change', this.handleConfigurationChanges);
}, },
methods: { methods: {
toggleTelemetryMode() {
this.$emit('telemetry-mode-change');
},
setFilterNames() { setFilterNames() {
let names = []; let names = [];
let composition = this.openmct.composition.get(this.table.configuration.domainObject); let composition = this.openmct.composition.get(this.table.configuration.domainObject);

View File

@ -18,6 +18,7 @@
&__counts { &__counts {
//background: rgba(deeppink, 0.1); //background: rgba(deeppink, 0.1);
display: flex; display: flex;
align-items: center;
flex: 1 1 auto; flex: 1 1 auto;
justify-content: flex-end; justify-content: flex-end;
overflow: hidden; overflow: hidden;

View File

@ -123,15 +123,6 @@
.is-editing .l-layout__frame & { .is-editing .l-layout__frame & {
pointer-events: none; pointer-events: none;
} }
&.is-selected {
background-color: $colorSelectedBg !important;
color: $colorSelectedFg !important;
td {
background: none !important;
color: inherit !important;
}
}
} }
td { td {
@ -169,30 +160,13 @@
} }
&__footer { &__footer {
$pt: 2px;
border-top: 1px solid $colorInteriorBorder;
margin-top: $interiorMargin; margin-top: $interiorMargin;
padding: $pt 0; margin-bottom: $interiorMarginSm;
overflow: hidden; overflow: hidden;
transition: all 250ms;
&:not(.is-filtering) { .c-frame & {
.c-frame & { .c-button {
height: 0; padding: 2px 5px;
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;
} }
} }
} }
@ -203,6 +177,18 @@ td {
@include isLimit(); @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 */ /******************************* SPECIFIC CASE WRAPPERS */
.is-editing { .is-editing {
.c-telemetry-table__headers__labels { .c-telemetry-table__headers__labels {

View File

@ -19,16 +19,17 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import TableConfigurationViewProvider from './TableConfigurationViewProvider.js'; import TableConfigurationViewProvider from './TableConfigurationViewProvider.js';
import TelemetryTableType from './TelemetryTableType.js'; import getTelemetryTableType from './TelemetryTableType.js';
import TelemetryTableViewProvider from './TelemetryTableViewProvider.js'; import TelemetryTableViewProvider from './TelemetryTableViewProvider.js';
import TelemetryTableViewActions from './ViewActions.js'; import TelemetryTableViewActions from './ViewActions.js';
export default function plugin() { export default function plugin(options) {
return function install(openmct) { return function install(openmct) {
openmct.objectViews.addProvider(new TelemetryTableViewProvider(openmct)); openmct.objectViews.addProvider(new TelemetryTableViewProvider(openmct, options));
openmct.inspectorViews.addProvider(new TableConfigurationViewProvider(openmct)); openmct.inspectorViews.addProvider(new TableConfigurationViewProvider(openmct));
openmct.types.addType('table', TelemetryTableType); openmct.types.addType('table', getTelemetryTableType(options));
openmct.composition.addPolicy((parent, child) => { openmct.composition.addPolicy((parent, child) => {
if (parent.type === 'table') { if (parent.type === 'table') {
return Object.prototype.hasOwnProperty.call(child, 'telemetry'); return Object.prototype.hasOwnProperty.call(child, 'telemetry');

View File

@ -27,6 +27,7 @@
:header-items="headerItems" :header-items="headerItems"
:default-sort="defaultSort" :default-sort="defaultSort"
class="sticky" class="sticky"
@item-selection-changed="setSelectionForActivity"
/> />
</div> </div>
</template> </template>
@ -38,7 +39,7 @@ import { v4 as uuid } from 'uuid';
import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js'; import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js';
import ListView from '../../ui/components/List/ListView.vue'; import ListView from '../../ui/components/List/ListView.vue';
import { getPreciseDuration } from '../../utils/duration.js'; 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'; import { SORT_ORDER_OPTIONS } from './constants.js';
const SCROLL_TIMEOUT = 10000; const SCROLL_TIMEOUT = 10000;
@ -208,22 +209,22 @@ export default {
this.setViewFromConfig(mutatedObject.configuration); this.setViewFromConfig(mutatedObject.configuration);
}, },
setViewFromConfig(configuration) { setViewFromConfig(configuration) {
this.filterValue = configuration.filter || '';
this.filterMetadataValue = configuration.filterMetadata || '';
if (this.isEditing) { if (this.isEditing) {
this.filterValue = configuration.filter;
this.hideAll = false; this.hideAll = false;
this.listActivities();
} else { } else {
this.filterValue = configuration.filter;
this.setSort(); this.setSort();
this.listActivities();
} }
this.listActivities();
}, },
updateTimestamp(timestamp) { updateTimestamp(timestamp) {
//The clock never stops ticking //The clock never stops ticking
this.updateTimeStampAndListActivities(timestamp); this.updateTimeStampAndListActivities(timestamp);
}, },
setFixedTime() { setFixedTime() {
this.filterValue = this.domainObject.configuration.filter; this.filterValue = this.domainObject.configuration.filter || '';
this.filterMetadataValue = this.domainObject.configuration.filterMetadata || '';
this.isFixedTime = !this.timeContext.isRealTime(); this.isFixedTime = !this.timeContext.isRealTime();
if (this.isFixedTime) { if (this.isFixedTime) {
this.hideAll = false; this.hideAll = false;
@ -326,7 +327,21 @@ export default {
return true; 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) { if (hasFilterMatch === false || this.hideAll === true) {
return false; return false;
} }
@ -354,6 +369,17 @@ export default {
return regex.test(name.toLowerCase()); 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, // Add activity classes, increase activity counts by type,
// set indices of the first occurrences of current and future activities - used for scrolling // set indices of the first occurrences of current and future activities - used for scrolling
styleActivity(activity, index) { styleActivity(activity, index) {
@ -516,6 +542,29 @@ export default {
setEditState(isEditing) { setEditState(isEditing) {
this.isEditing = isEditing; this.isEditing = isEditing;
this.setViewFromConfig(this.domainObject.configuration); 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
);
} }
} }
}; };

View File

@ -22,20 +22,57 @@
<template> <template>
<li class="c-inspect-properties__row"> <li class="c-inspect-properties__row">
<div v-if="canEdit" class="c-inspect-properties__hint span-all"> <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>
<div class="c-inspect-properties__label" title="Filter by keyword.">Filters</div> <div class="c-inspect-properties__label" aria-label="Activity Names" title="Filter by keyword.">
<div v-if="canEdit" class="c-inspect-properties__value" :class="{ 'form-error': hasError }"> Activity Names
</div>
<div
v-if="canEdit"
class="c-inspect-properties__value"
:class="{ 'form-error': hasFilterError }"
>
<textarea <textarea
v-model="filterValue" v-model="filterValue"
class="c-input--flex" class="c-input--flex"
type="text" type="text"
@keydown.enter.exact.stop="forceBlur($event)" @keydown.enter.exact.stop="forceBlur($event)"
@keyup="updateForm($event, 'filter')" @keyup="updateNameFilter($event, 'filter')"
></textarea> ></textarea>
</div> </div>
<div v-else class="c-inspect-properties__value"> <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> </div>
</li> </li>
</template> </template>
@ -48,7 +85,9 @@ export default {
return { return {
isEditing: this.openmct.editor.isEditing(), isEditing: this.openmct.editor.isEditing(),
filterValue: this.domainObject.configuration.filter, filterValue: this.domainObject.configuration.filter,
hasError: false filterMetadataValue: this.domainObject.configuration.filterMetadata,
hasFilterError: false,
hasMetadataFilterError: false
}; };
}, },
computed: { computed: {
@ -65,37 +104,55 @@ export default {
methods: { methods: {
setEditState(isEditing) { setEditState(isEditing) {
this.isEditing = isEditing; this.isEditing = isEditing;
if (!this.isEditing && this.hasError) { if (!this.isEditing) {
this.filterValue = this.domainObject.configuration.filter; if (this.hasFilterError) {
this.hasError = false; this.filterValue = this.domainObject.configuration.filter;
}
if (this.hasMetadataFilterError) {
this.filterMetadataValue = this.domainObject.configuration.filterMetadata;
}
this.hasFilterError = false;
this.hasMetadataFilterError = false;
} }
}, },
forceBlur(event) { forceBlur(event) {
event.target.blur(); event.target.blur();
}, },
updateForm(event, property) { updateNameFilter(event, property) {
if (!this.isValid()) { if (!this.isValid(this.filterValue)) {
this.hasError = true; this.hasFilterError = true;
return; return;
} }
this.hasFilterError = false;
this.hasError = false;
this.$emit('updated', { this.$emit('updated', {
property, property,
value: this.filterValue.replace(/,(\s)*$/, '') 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 // Test for any word character, any whitespace character or comma
if (this.filterValue === '') { if (value === '') {
return true; return true;
} }
const regex = new RegExp(/^([a-zA-Z0-9_\-\s,])+$/g); const regex = new RegExp(/^([a-zA-Z0-9_\-\s,])+$/g);
return regex.test(this.filterValue); return regex.test(value);
} }
} }
}; };

View File

@ -28,7 +28,7 @@ import TimelistPropertiesView from './TimelistPropertiesView.vue';
export default function TimeListInspectorViewProvider(openmct) { export default function TimeListInspectorViewProvider(openmct) {
return { return {
key: 'timelist-inspector', key: 'timelist-inspector',
name: 'Timelist Inspector View', name: 'Config',
canView: function (selection) { canView: function (selection) {
if (selection.length === 0 || selection[0].length === 0) { if (selection.length === 0 || selection[0].length === 0) {
return false; return false;

View File

@ -38,16 +38,10 @@ export default function () {
initialize: function (domainObject) { initialize: function (domainObject) {
domainObject.configuration = { domainObject.configuration = {
sortOrderIndex: 0, sortOrderIndex: 0,
futureEventsIndex: 1,
futureEventsDurationIndex: 0,
futureEventsDuration: 20,
currentEventsIndex: 1, currentEventsIndex: 1,
currentEventsDurationIndex: 0, filter: '',
currentEventsDuration: 20, filterMetadata: '',
pastEventsIndex: 1, isCompact: false
pastEventsDurationIndex: 0,
pastEventsDuration: 20,
filter: ''
}; };
domainObject.composition = []; domainObject.composition = [];
} }

View File

@ -71,7 +71,10 @@ describe('the plugin', function () {
end: twoHoursFuture, end: twoHoursFuture,
type: 'TEST-GROUP', type: 'TEST-GROUP',
color: 'fuchsia', color: 'fuchsia',
textColor: 'black' textColor: 'black',
properties: {
location: 'garden'
}
}, },
{ {
name: 'Sed ut perspiciatis two', name: 'Sed ut perspiciatis two',
@ -79,7 +82,10 @@ describe('the plugin', function () {
end: threeHoursFuture, end: threeHoursFuture,
type: 'TEST-GROUP', type: 'TEST-GROUP',
color: 'fuchsia', 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 timelistDomainObject;
let timelistView; 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', () => { describe('time filtering - past', () => {
let timelistDomainObject; let timelistDomainObject;
let timelistView; let timelistView;

View File

@ -361,11 +361,12 @@ body.desktop .has-local-controls {
} }
} }
[aria-disabled = 'true'],
*[disabled], *[disabled],
.disabled { .disabled {
opacity: $controlDisabledOpacity; opacity: $controlDisabledOpacity;
cursor: not-allowed !important;
pointer-events: none !important; pointer-events: none !important;
cursor: default !important;
} }
/******************************************************** RESPONSIVE CONTAINERS */ /******************************************************** RESPONSIVE CONTAINERS */

View File

@ -43,6 +43,7 @@
:key="item.key" :key="item.key"
:item="item" :item="item"
:item-properties="itemProperties" :item-properties="itemProperties"
@click.stop="itemSelected(item, $event)"
/> />
</tbody> </tbody>
</table> </table>
@ -86,6 +87,7 @@ export default {
} }
} }
}, },
emits: ['item-selection-changed'],
data() { data() {
let sortBy = this.defaultSort.property; let sortBy = this.defaultSort.property;
let ascending = this.defaultSort.defaultDirection; let ascending = this.defaultSort.defaultDirection;
@ -156,6 +158,9 @@ export default {
}) })
); );
} }
},
itemSelected(item, event) {
this.$emit('item-selection-changed', item, event.currentTarget);
} }
} }
}; };

View File

@ -33,6 +33,7 @@ import StyleRuleManager from '@/plugins/condition/StyleRuleManager';
import { STYLE_CONSTANTS } from '@/plugins/condition/utils/constants'; import { STYLE_CONSTANTS } from '@/plugins/condition/utils/constants';
import stalenessMixin from '@/ui/mixins/staleness-mixin'; import stalenessMixin from '@/ui/mixins/staleness-mixin';
import objectUtils from '../../api/objects/object-utils.js';
import VisibilityObserver from '../../utils/visibility/VisibilityObserver.js'; import VisibilityObserver from '../../utils/visibility/VisibilityObserver.js';
export default { export default {
@ -130,7 +131,10 @@ export default {
this.debounceUpdateView = _.debounce(this.updateView, 10); this.debounceUpdateView = _.debounce(this.updateView, 10);
}, },
mounted() { mounted() {
this.visibilityObserver = new VisibilityObserver(this.$refs.objectViewWrapper); this.visibilityObserver = new VisibilityObserver(
this.$refs.objectViewWrapper,
this.openmct.element
);
this.updateView(); this.updateView();
this.$refs.objectViewWrapper.addEventListener('dragover', this.onDragOver, { this.$refs.objectViewWrapper.addEventListener('dragover', this.onDragOver, {
capture: true capture: true
@ -184,6 +188,7 @@ export default {
this.triggerUnsubscribeFromStaleness(this.domainObject); this.triggerUnsubscribeFromStaleness(this.domainObject);
this.openmct.objectViews.off('clearData', this.clearData); this.openmct.objectViews.off('clearData', this.clearData);
this.openmct.objectViews.off('reload', this.reload);
if (this.contextActionEvent) { if (this.contextActionEvent) {
this.openmct.objectViews.off(this.contextActionEvent, this.performContextAction); this.openmct.objectViews.off(this.contextActionEvent, this.performContextAction);
} }
@ -218,6 +223,13 @@ export default {
this.clear(); this.clear();
this.updateView(true); this.updateView(true);
}, },
reload(domainObjectToReload) {
if (objectUtils.equals(domainObjectToReload, this.domainObject)) {
this.updateView(true);
this.initObjectStyles();
this.triggerStalenessSubscribe(this.domainObject);
}
},
triggerStalenessSubscribe(object) { triggerStalenessSubscribe(object) {
if (this.openmct.telemetry.isTelemetryObject(object)) { if (this.openmct.telemetry.isTelemetryObject(object)) {
this.subscribeToStaleness(object); this.subscribeToStaleness(object);
@ -316,6 +328,7 @@ export default {
this.domainObject.identifier this.domainObject.identifier
)}`; )}`;
this.openmct.objectViews.on('clearData', this.clearData); this.openmct.objectViews.on('clearData', this.clearData);
this.openmct.objectViews.on('reload', this.reload);
this.openmct.objectViews.on(this.contextActionEvent, this.performContextAction); this.openmct.objectViews.on(this.contextActionEvent, this.performContextAction);
this.$nextTick(() => { this.$nextTick(() => {

View File

@ -23,6 +23,7 @@
<div ref="createButton" class="c-create-button--w"> <div ref="createButton" class="c-create-button--w">
<button <button
class="c-create-button c-button--menu c-button--major icon-plus" class="c-create-button c-button--menu c-button--major icon-plus"
:aria-disabled="isEditing"
@click.prevent.stop="showCreateMenu" @click.prevent.stop="showCreateMenu"
> >
<span class="c-button__label">Create</span> <span class="c-button__label">Create</span>
@ -38,6 +39,7 @@ export default {
data: function () { data: function () {
return { return {
menuItems: {}, menuItems: {},
isEditing: this.openmct.editor.isEditing(),
selectedMenuItem: {}, selectedMenuItem: {},
opened: false 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: { methods: {
getItems() { getItems() {
let keys = this.openmct.types.listKeys(); let keys = this.openmct.types.listKeys();
@ -89,6 +97,9 @@ export default {
this.openmct.menus.showSuperMenu(x, y, this.sortedItems, menuOptions); this.openmct.menus.showSuperMenu(x, y, this.sortedItems, menuOptions);
}, },
toggleEdit(isEditing) {
this.isEditing = isEditing;
},
create(key) { create(key) {
const createAction = new CreateAction(this.openmct, key, this.openmct.router.path[0]); const createAction = new CreateAction(this.openmct, key, this.openmct.router.path[0]);

View File

@ -4,10 +4,6 @@
} }
.c-create-button { .c-create-button {
.is-editing & {
@include disabled();
}
.c-button__label { .c-button__label {
text-transform: $createBtnTextTransform; text-transform: $createBtnTextTransform;
} }

View File

@ -203,8 +203,6 @@
.is-editing .is-navigated-object { .is-editing .is-navigated-object {
a[class*='__item__label'] { a[class*='__item__label'] {
opacity: 0.4;
[class*='__name'] { [class*='__name'] {
font-style: italic; font-style: italic;
} }

View File

@ -108,6 +108,11 @@ export default {
this.addExistingViewBackToParent(); 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: { methods: {
clear() { clear() {
if (this.view) { if (this.view) {

View File

@ -32,17 +32,22 @@ export default class VisibilityObserver {
* Constructs a VisibilityObserver instance to manage visibility-based requestAnimationFrame calls. * Constructs a VisibilityObserver instance to manage visibility-based requestAnimationFrame calls.
* *
* @param {HTMLElement} element - The DOM element to observe for visibility changes. * @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. * @throws {Error} If element is not provided.
*/ */
constructor(element) { constructor(element, rootContainer) {
if (!element) { if (!element || !rootContainer) {
throw new Error(`VisibilityObserver must be created with an element`); throw new Error(`VisibilityObserver must be created with an element and a rootContainer.`);
} }
this.#element = element; this.#element = element;
this.isIntersecting = true; this.isIntersecting = true;
this.calledOnce = false; this.calledOnce = false;
const options = {
this.#observer = new IntersectionObserver(this.#observerCallback); root: rootContainer,
rootMargin: '0px',
threshold: 1.0
};
this.#observer = new IntersectionObserver(this.#observerCallback, options);
this.lastUnfiredFunc = null; this.lastUnfiredFunc = null;
this.renderWhenVisible = this.renderWhenVisible.bind(this); this.renderWhenVisible = this.renderWhenVisible.bind(this);
} }