From a8fbabe695a82f2f1826f8d2aeeb3f42ddfe5ebe Mon Sep 17 00:00:00 2001 From: David Tsay <3614296+davetsay@users.noreply.github.com> Date: Fri, 27 Sep 2024 14:32:14 -0700 Subject: [PATCH 1/3] fix(#7825): imagery pause (#7842) * more readable * unpause explicitly * fix jsdoc * e2e testing multiple image removal * prettier * fix to remove multiple images from history * move tests that use playwright clock api into own file * fix playwright clock tests * add aria-label to element * prevent straggler debounced function call on unmount * clean up and fix tests * update paths * lint fix * lint fix --------- Co-authored-by: John Hill --- e2e/helper/imageryUtils.js | 33 ++ .../imagery/exampleImagery.e2e.spec.js | 211 +++----- .../exampleImageryControlledClock.e2e.spec.js | 489 ++++++++++++++++++ src/api/telemetry/TelemetryCollection.js | 3 +- .../imagery/components/ImageryTimeView.vue | 1 + .../imagery/components/ImageryView.vue | 4 +- src/plugins/imagery/mixins/imageryData.js | 17 +- src/plugins/timeline/TimelineViewLayout.vue | 1 + 8 files changed, 613 insertions(+), 146 deletions(-) create mode 100644 e2e/helper/imageryUtils.js create mode 100644 e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js diff --git a/e2e/helper/imageryUtils.js b/e2e/helper/imageryUtils.js new file mode 100644 index 0000000000..880964076c --- /dev/null +++ b/e2e/helper/imageryUtils.js @@ -0,0 +1,33 @@ +import { createDomainObjectWithDefaults } from '../appActions.js'; +import { expect } from '../pluginFixtures.js'; + +const IMAGE_LOAD_DELAY = 5 * 1000; +const FIVE_MINUTES = 1000 * 60 * 5; +const THIRTY_SECONDS = 1000 * 30; +const MOUSE_WHEEL_DELTA_Y = 120; + +/** + * @param {import('@playwright/test').Page} page + */ +async function createImageryViewWithShortDelay(page, { name, parent }) { + await createDomainObjectWithDefaults(page, { + name, + type: 'Example Imagery', + parent + }); + + await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery'); + await page.getByLabel('More actions').click(); + await page.getByLabel('Edit Properties').click(); + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + await page.getByLabel('Save').click(); +} + +export { + createImageryViewWithShortDelay, + FIVE_MINUTES, + IMAGE_LOAD_DELAY, + MOUSE_WHEEL_DELTA_Y, + THIRTY_SECONDS +}; diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js index d75e104e0d..d5d218bc05 100644 --- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js @@ -30,16 +30,19 @@ import { navigateToObjectWithRealTime, setRealTimeMode } from '../../../../appActions.js'; -import { MISSION_TIME } from '../../../../constants.js'; +import { + createImageryViewWithShortDelay, + FIVE_MINUTES, + IMAGE_LOAD_DELAY, + MOUSE_WHEEL_DELTA_Y, + THIRTY_SECONDS +} from '../../../../helper/imageryUtils.js'; import { expect, test } from '../../../../pluginFixtures.js'; + const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt']; const tagHotkey = ['Shift', 'Alt']; const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan'; const thumbnailUrlParamsRegexp = /\?w=100&h=100/; -const IMAGE_LOAD_DELAY = 5 * 1000; -const MOUSE_WHEEL_DELTA_Y = 120; -const FIVE_MINUTES = 1000 * 60 * 5; -const THIRTY_SECONDS = 1000 * 30; //The following block of tests verifies the basic functionality of example imagery and serves as a template for Imagery objects embedded in other objects. test.describe('Example Imagery Object', () => { @@ -357,15 +360,10 @@ test.describe('Example Imagery Object', () => { }); }); -test.describe('Example Imagery in Display Layout @clock', () => { +test.describe('Example Imagery in Display Layout', () => { let displayLayout; test.beforeEach(async ({ page }) => { - // We mock the clock so that we don't need to wait for time driven events - // to verify functionality. - await page.clock.install({ time: MISSION_TIME }); - await page.clock.resume(); - // Go to baseURL await page.goto('./', { waitUntil: 'domcontentloaded' }); @@ -428,12 +426,7 @@ test.describe('Example Imagery in Display Layout @clock', () => { await expect.soft(pausePlayButton).toHaveClass(/is-paused/); }); - test('Imagery View operations @clock', async ({ page }) => { - test.info().annotations.push({ - type: 'issue', - description: 'https://github.com/nasa/openmct/issues/5265' - }); - + test('Imagery View operations', async ({ page }) => { // Edit mode await page.getByLabel('Edit Object').click(); @@ -526,14 +519,9 @@ test.describe('Example Imagery in Display Layout @clock', () => { }); }); -test.describe('Example Imagery in Flexible layout @clock', () => { +test.describe('Example Imagery in Flexible layout', () => { let flexibleLayout; test.beforeEach(async ({ page }) => { - // We mock the clock so that we don't need to wait for time driven events - // to verify functionality. - await page.clock.install({ time: MISSION_TIME }); - await page.clock.resume(); - await page.goto('./', { waitUntil: 'domcontentloaded' }); flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' }); @@ -562,7 +550,7 @@ test.describe('Example Imagery in Flexible layout @clock', () => { await page.getByRole('button', { name: 'Close' }).click(); }); - test('Imagery View operations @clock', async ({ page, browserName }) => { + test('Imagery View operations', async ({ page, browserName }) => { test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox'); test.info().annotations.push({ type: 'issue', @@ -573,14 +561,10 @@ test.describe('Example Imagery in Flexible layout @clock', () => { }); }); -test.describe('Example Imagery in Tabs View @clock', () => { +test.describe('Example Imagery in Tabs View', () => { let tabsView; - test.beforeEach(async ({ page }) => { - // We mock the clock so that we don't need to wait for time driven events - // to verify functionality. - await page.clock.install({ time: MISSION_TIME }); - await page.clock.resume(); + test.beforeEach(async ({ page }) => { await page.goto('./', { waitUntil: 'domcontentloaded' }); tabsView = await createDomainObjectWithDefaults(page, { type: 'Tabs View' }); @@ -607,7 +591,8 @@ test.describe('Example Imagery in Tabs View @clock', () => { // Wait for image thumbnail auto-scroll to complete await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); }); - test('Imagery View operations @clock', async ({ page }) => { + + test('Imagery View operations', async ({ page }) => { await performImageryViewOperationsAndAssert(page, tabsView); }); }); @@ -668,16 +653,19 @@ test.describe('Example Imagery in Time Strip', () => { * 3. Can pan the image using the pan hotkey + mouse drag * 4. Clicking on the left arrow button pauses imagery and moves to the previous image * 5. Imagery is updated as new images stream in, regardless of pause status - * 6. Old images are discarded when new images stream in - * 7. Image brightness/contrast can be adjusted by dragging the sliders + * 6. Old images are discarded when their timestamps fall out of bounds + * 7. Multiple images can be discarded when their timestamps fall out of bounds + * 8. Image brightness/contrast can be adjusted by dragging the sliders * @param {import('@playwright/test').Page} page */ async function performImageryViewOperationsAndAssert(page, layoutObject) { - // Verify that imagery thumbnails use a thumbnail url - const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image'); - const mainImage = page.locator('.c-imagery__main-image__image'); - await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp); - await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp); + await test.step('Verify that imagery thumbnails use a thumbnail url', async () => { + const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image'); + const mainImage = page.locator('.c-imagery__main-image__image'); + await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp); + await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp); + }); + // Click previous image button const previousImageButton = page.getByLabel('Previous image'); await expect(previousImageButton).toBeVisible(); @@ -736,19 +724,6 @@ async function performImageryViewOperationsAndAssert(page, layoutObject) { // Unpause imagery await page.locator('.pause-play').click(); - // verify that old images are discarded - const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); - const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); - expect(lastImageTimestamp).not.toBeNull(); - - // go forward in time to ensure old images are discarded - await page.clock.fastForward(IMAGE_LOAD_DELAY); - await page.clock.resume(); - await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); - - //Get background-image url from background-image css prop - await assertBackgroundImageUrlFromBackgroundCss(page); - // Open the image filter menu await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click(); @@ -815,24 +790,6 @@ async function assertBackgroundImageBrightness(page, expected) { expect(actual).toBe(expected); } -/** - * @param {import('@playwright/test').Page} page - */ -async function assertBackgroundImageUrlFromBackgroundCss(page) { - const backgroundImage = page.getByLabel('Focused Image Element'); - const backgroundImageUrl = await backgroundImage.evaluate((el) => { - return window - .getComputedStyle(el) - .getPropertyValue('background-image') - .match(/url\(([^)]+)\)/)[1]; - }); - - // go forward in time to ensure old images are discarded - await page.clock.fastForward(IMAGE_LOAD_DELAY); - await page.clock.resume(); - await expect(backgroundImage).not.toHaveJSProperty('background-image', backgroundImageUrl); -} - /** * @param {import('@playwright/test').Page} page */ @@ -918,62 +875,66 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) { * @param {import('@playwright/test').Page} page */ async function buttonZoomOnImageAndAssert(page) { - // Lock the zoom and pan so it doesn't reset if a new image comes in - await page.getByLabel('Focused Image Element').hover({ trial: true }); - const lockButton = page.getByRole('button', { - name: 'Lock current zoom and pan across all images' - }); - if (!(await lockButton.isVisible())) { + await test.step('Can zoom using buttons', async () => { + // Lock the zoom and pan so it doesn't reset if a new image comes in await page.getByLabel('Focused Image Element').hover({ trial: true }); - } - await lockButton.click(); + const lockButton = page.getByRole('button', { + name: 'Lock current zoom and pan across all images' + }); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(1) translate(0px, 0px)' - ); + await lockButton.isVisible(); + // if (!(await lockButton.isVisible())) { + // await page.getByLabel('Focused Image Element').hover({ trial: true }); + // } + await lockButton.click(); - // Get initial image dimensions - const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(1) translate(0px, 0px)' + ); - // Zoom in twice via button - await zoomIntoImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(2) translate(0px, 0px)' - ); - await zoomIntoImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(3) translate(0px, 0px)' - ); + // Get initial image dimensions + const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - // Get and assert zoomed in image dimensions - const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); - expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); + // Zoom in twice via button + await zoomIntoImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(2) translate(0px, 0px)' + ); + await zoomIntoImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(3) translate(0px, 0px)' + ); - // Zoom out once via button - await zoomOutOfImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(2) translate(0px, 0px)' - ); + // Get and assert zoomed in image dimensions + const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); + expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); - // Get and assert zoomed out image dimensions - const zoomedOutBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); - expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); + // Zoom out once via button + await zoomOutOfImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(2) translate(0px, 0px)' + ); - // Zoom out again via button, assert against the initial image dimensions - await zoomOutOfImageryByButton(page); - await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( - 'style.transform', - 'scale(1) translate(0px, 0px)' - ); + // Get and assert zoomed out image dimensions + const zoomedOutBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); + expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); - const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); - expect(finalBoundingBox).toEqual(initialBoundingBox); + // Zoom out again via button, assert against the initial image dimensions + await zoomOutOfImageryByButton(page); + await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty( + 'style.transform', + 'scale(1) translate(0px, 0px)' + ); + + const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox(); + expect(finalBoundingBox).toEqual(initialBoundingBox); + }); } /** @@ -1035,24 +996,6 @@ async function resetImageryPanAndZoom(page) { await expect(page.locator('.c-thumb__viewable-area')).toBeHidden(); } -/** - * @param {import('@playwright/test').Page} page - */ -async function createImageryViewWithShortDelay(page, { name, parent }) { - await createDomainObjectWithDefaults(page, { - name, - type: 'Example Imagery', - parent - }); - - await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery'); - await page.getByLabel('More actions').click(); - await page.getByLabel('Edit Properties').click(); - // Clear and set Image load delay to minimum value - await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); - await page.getByLabel('Save').click(); -} - /** * @param {import('@playwright/test').Page} page */ diff --git a/e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js new file mode 100644 index 0000000000..97c45acbd7 --- /dev/null +++ b/e2e/tests/functional/plugins/imagery/exampleImageryControlledClock.e2e.spec.js @@ -0,0 +1,489 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/* +This test suite is dedicated to testing how imagery functions over time. +It only assumes that example imagery is present. +It uses https://playwright.dev/docs/clock to have control over time +*/ + +import { + createDomainObjectWithDefaults, + navigateToObjectWithRealTime, + setRealTimeMode, + setStartOffset +} from '../../../../appActions.js'; +import { MISSION_TIME } from '../../../../constants.js'; +import { + createImageryViewWithShortDelay, + FIVE_MINUTES, + IMAGE_LOAD_DELAY, + THIRTY_SECONDS +} from '../../../../helper/imageryUtils.js'; +import { expect, test } from '../../../../pluginFixtures.js'; + +test.describe('Example Imagery Object with Controlled Clock @clock', () => { + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + //Go to baseURL + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + // Create a default 'Example Imagery' object + // Click the Create button + await page.getByRole('button', { name: 'Create' }).click(); + + // Click text=Example Imagery + await page.getByRole('menuitem', { name: 'Example Imagery' }).click(); + + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').clear(); + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + + await page.getByLabel('Save').click(); + + // Verify that the created object is focused + await expect(page.locator('.l-browse-bar__object-name')).toContainText( + 'Unnamed Example Imagery' + ); + await page.getByLabel('Focused Image Element').hover({ trial: true }); + + // set realtime mode + await setRealTimeMode(page); + await setStartOffset(page, { startMins: '05' }); + }); + + test('Imagery Time Bounding', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Display Layout with Controlled Clock @clock', () => { + let displayLayout; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + // Go to baseURL + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' }); + + // Create Example Imagery inside Display Layout + await createImageryViewWithShortDelay(page, { + name: 'Unnamed Example Imagery', + parent: displayLayout.uuid + }); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + displayLayout.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + }); + + test('Imagery Time Bounding', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // Edit mode + await page.getByLabel('Edit Object').click(); + + // Click on example imagery to expose toolbar + await page.locator('.c-so-view__header').click(); + + // Adjust object height + await page.locator('div[title="Resize object height"] > input').click(); + await page.locator('div[title="Resize object height"] > input').fill('50'); + + // Adjust object width + await page.locator('div[title="Resize object width"] > input').click(); + await page.locator('div[title="Resize object width"] > input').fill('50'); + + await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop @clock', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Flexible layout with Controlled Clock @clock', () => { + let flexibleLayout; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' }); + + // Create Example Imagery inside the Flexible Layout + await createImageryViewWithShortDelay(page, { + name: 'Unnamed Example Imagery', + parent: flexibleLayout.uuid + }); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + flexibleLayout.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + + // Wait for image thumbnail auto-scroll to complete + await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); + }); + + test('Imagery Time Bounding @clock', async ({ page, browserName }) => { + test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox'); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5326' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop @clock', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Tabs View with Controlled Clock @clock', () => { + let timeStripObject; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Tabs View' }); + await page.goto(timeStripObject.url); + + /* Create Example Imagery with minimum Image Load Delay */ + // Click the Create button + await page.getByRole('button', { name: 'Create' }).click(); + + // Click text=Example Imagery + await page.getByRole('menuitem', { name: 'Example Imagery' }).click(); + + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').clear(); + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + + await page.getByLabel('Save').click(); + + await expect(page.locator('.l-browse-bar__object-name')).toContainText( + 'Unnamed Example Imagery' + ); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + timeStripObject.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + + // Wait for image thumbnail auto-scroll to complete + await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport(); + }); + + test('Imagery Time Bounding @clock', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('Image thumbnail from').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('title'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page + .getByLabel('Image thumbnail from') + .nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('title'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); + + test('Get background-image url from background-image css prop @clock', async ({ page }) => { + await assertBackgroundImageUrlFromBackgroundCss(page); + }); +}); + +test.describe('Example Imagery in Time Strip with Controlled Clock @clock', () => { + let timeStripObject; + + test.beforeEach(async ({ page }) => { + // We mock the clock so that we don't need to wait for time driven events + // to verify functionality. + await page.clock.install({ time: MISSION_TIME }); + await page.clock.resume(); + + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Time Strip' }); + await page.goto(timeStripObject.url); + + /* Create Example Imagery with minimum Image Load Delay */ + // Click the Create button + await page.getByRole('button', { name: 'Create' }).click(); + + // Click text=Example Imagery + await page.getByRole('menuitem', { name: 'Example Imagery' }).click(); + + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').clear(); + await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`); + + await page.getByLabel('Save').click(); + + await expect(page.locator('.l-browse-bar__object-name')).toContainText( + 'Unnamed Example Imagery' + ); + + // set realtime mode + await navigateToObjectWithRealTime( + page, + timeStripObject.url, + `${FIVE_MINUTES}`, + `${THIRTY_SECONDS}` + ); + + // Wait for image thumbnail auto-scroll to complete + await expect(page.getByLabel('wrapper-').last()).toBeInViewport(); + }); + + test('Imagery Time Bounding @clock', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5265' + }); + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/7825' + }); + + // verify that old images are discarded + const lastImageInBounds = page.getByLabel('wrapper-').first(); + const lastImageTimestamp = await lastImageInBounds.getAttribute('aria-label'); + expect(lastImageTimestamp).not.toBeNull(); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(page.getByLabel(lastImageTimestamp)).toBeHidden(); + + // go way forward in time to ensure multiple images are discarded + const IMAGES_TO_DISCARD_COUNT = 5; + + const lastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT - 1); + const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('aria-label'); + expect(lastImageToDiscardTimestamp).not.toBeNull(); + + const imageAfterLastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT); + const imageAfterLastImageToDiscardTimestamp = + await imageAfterLastImageToDiscard.getAttribute('aria-label'); + expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull(); + + await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT); + await page.clock.resume(); + + await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden(); + await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible(); + }); +}); + +/** + * @param {import('@playwright/test').Page} page + */ +async function assertBackgroundImageUrlFromBackgroundCss(page) { + const backgroundImage = page.getByLabel('Focused Image Element'); + const backgroundImageUrl = await backgroundImage.evaluate((el) => { + return window + .getComputedStyle(el) + .getPropertyValue('background-image') + .match(/url\(([^)]+)\)/)[1]; + }); + + // go forward in time to ensure old images are discarded + await page.clock.fastForward(IMAGE_LOAD_DELAY); + await page.clock.resume(); + await expect(backgroundImage).not.toHaveJSProperty('background-image', backgroundImageUrl); +} diff --git a/src/api/telemetry/TelemetryCollection.js b/src/api/telemetry/TelemetryCollection.js index 78e48dc89b..d6f53816b9 100644 --- a/src/api/telemetry/TelemetryCollection.js +++ b/src/api/telemetry/TelemetryCollection.js @@ -116,8 +116,7 @@ export default class TelemetryCollection extends EventEmitter { } /** - * This will start the requests for historical and realtime data, - * as well as setting up initial values and watchers + * @returns {Array} All bounded telemetry */ getAll() { return this.boundedTelemetry; diff --git a/src/plugins/imagery/components/ImageryTimeView.vue b/src/plugins/imagery/components/ImageryTimeView.vue index dfb796b5b6..9c65939e65 100644 --- a/src/plugins/imagery/components/ImageryTimeView.vue +++ b/src/plugins/imagery/components/ImageryTimeView.vue @@ -370,6 +370,7 @@ export default { createImageWrapper(index, image, showImagePlaceholders) { const id = `${ID_PREFIX}${image.time}`; let imageWrapper = document.createElement('div'); + imageWrapper.ariaLabel = id; imageWrapper.classList.add(IMAGE_WRAPPER_CLASS); imageWrapper.style.left = `${this.xScale(image.time)}px`; this.setNSAttributesForElement(imageWrapper, { diff --git a/src/plugins/imagery/components/ImageryView.vue b/src/plugins/imagery/components/ImageryView.vue index 5f868b9eb8..db0541f029 100644 --- a/src/plugins/imagery/components/ImageryView.vue +++ b/src/plugins/imagery/components/ImageryView.vue @@ -620,7 +620,7 @@ export default { if (matchIndex > -1) { this.setFocusedImage(matchIndex); } else { - this.paused(); + this.paused(false); } } @@ -1082,7 +1082,7 @@ export default { paused(state) { this.isPaused = Boolean(state); - if (!state) { + if (!this.isPaused) { this.previousFocusedImage = null; this.setFocusedImage(this.nextImageIndex); this.autoScroll = true; diff --git a/src/plugins/imagery/mixins/imageryData.js b/src/plugins/imagery/mixins/imageryData.js index 804d06f4e3..04914b30a0 100644 --- a/src/plugins/imagery/mixins/imageryData.js +++ b/src/plugins/imagery/mixins/imageryData.js @@ -90,16 +90,17 @@ export default { dataCleared() { this.imageHistory = []; }, - dataRemoved(dataToRemove) { - this.imageHistory = this.imageHistory.filter((existingDatum) => { - const shouldKeep = dataToRemove.some((datumToRemove) => { - const existingDatumTimestamp = this.parseTime(existingDatum); - const datumToRemoveTimestamp = this.parseTime(datumToRemove); + dataRemoved(removed) { + const removedTimestamps = {}; + removed.forEach((_removed) => { + const removedTimestamp = this.parseTime(_removed); + removedTimestamps[removedTimestamp] = true; + }); - return existingDatumTimestamp !== datumToRemoveTimestamp; - }); + this.imageHistory = this.imageHistory.filter((image) => { + const imageTimestamp = this.parseTime(image); - return shouldKeep; + return !removedTimestamps[imageTimestamp]; }); }, setDataTimeContext() { diff --git a/src/plugins/timeline/TimelineViewLayout.vue b/src/plugins/timeline/TimelineViewLayout.vue index 4a85a9a04d..b1652599d4 100644 --- a/src/plugins/timeline/TimelineViewLayout.vue +++ b/src/plugins/timeline/TimelineViewLayout.vue @@ -99,6 +99,7 @@ export default { this.composition.off('remove', this.removeItem); this.composition.off('reorder', this.reorder); this.stopFollowingTimeContext(); + this.handleContentResize.cancel(); this.contentResizeObserver.disconnect(); }, mounted() { From c498f7d20c449a899e83e3a3446cb75b44ad6157 Mon Sep 17 00:00:00 2001 From: Charles Hacskaylo Date: Sat, 28 Sep 2024 09:17:32 -0700 Subject: [PATCH 2/3] Fix bad color value for Gauge 'Needle' type (#7821) * Closes #7820 - Fix 0 opacity fill in theme constants files for Gauge type needle. * move gauge plugin to its own suite * add two more snapshots * driveby: fix some flake * bug: update linting rule --------- Co-authored-by: John Hill Co-authored-by: Andrew Henry --- e2e/.eslintrc.cjs | 6 ++ e2e/README.md | 1 + .../visual-a11y/defaultPlugins.visual.spec.js | 10 --- e2e/tests/visual-a11y/gauge.visual.spec.js | 81 +++++++++++++++++++ e2e/tests/visual-a11y/notebook.visual.spec.js | 2 +- e2e/tests/visual-a11y/search.visual.spec.js | 2 +- src/styles/_constants-espresso.scss | 2 +- src/styles/_constants-maelstrom.scss | 2 +- src/styles/_constants-snow.scss | 2 +- 9 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 e2e/tests/visual-a11y/gauge.visual.spec.js diff --git a/e2e/.eslintrc.cjs b/e2e/.eslintrc.cjs index e044757b41..a8fefcb27e 100644 --- a/e2e/.eslintrc.cjs +++ b/e2e/.eslintrc.cjs @@ -27,6 +27,12 @@ module.exports = { rules: { 'playwright/no-raw-locators': 'off' } + }, + { + files: ['**/*.visual.spec.js'], + rules: { + 'playwright/no-networkidle': 'off' //https://github.com/nasa/openmct/issues/7549 + } } ] }; diff --git a/e2e/README.md b/e2e/README.md index b10956ba44..881d8d2327 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -469,6 +469,7 @@ By adhering to this principle, we can create tests that are both robust and refl //Select from object await percySnapshot(page, `object selected (theme: ${theme})`) ``` +8. **Use `networkidle` to wait for network requests to complete**: This is necessary to ensure that all network requests have completed before taking a snapshot. This ensures that icons are loaded and other assets are available. https://github.com/nasa/openmct/issues/7549 #### How to write a great network test diff --git a/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js b/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js index d7d022d39d..4246141203 100644 --- a/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js +++ b/e2e/tests/visual-a11y/defaultPlugins.visual.spec.js @@ -85,16 +85,6 @@ test.describe('Visual - Default @a11y', () => { await percySnapshot(page, `Display Layout Create Menu (theme: '${theme}')`); }); - test('Visual - Default Gauge', async ({ page, theme }) => { - await createDomainObjectWithDefaults(page, { - type: 'Gauge', - name: 'Default Gauge' - }); - - // Take a snapshot of the newly created Gauge object - await percySnapshot(page, `Default Gauge (theme: '${theme}')`); - }); - test.afterEach(async ({ page }, testInfo) => { await scanForA11yViolations(page, testInfo.title); }); diff --git a/e2e/tests/visual-a11y/gauge.visual.spec.js b/e2e/tests/visual-a11y/gauge.visual.spec.js new file mode 100644 index 0000000000..15d1d5f08d --- /dev/null +++ b/e2e/tests/visual-a11y/gauge.visual.spec.js @@ -0,0 +1,81 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ +import percySnapshot from '@percy/playwright'; + +import { createDomainObjectWithDefaults } from '../../appActions.js'; +import { scanForA11yViolations, test } from '../../avpFixtures.js'; +import { VISUAL_FIXED_URL } from '../../constants.js'; + +test.describe('Visual - Gauges', () => { + test.beforeEach(async ({ page }) => { + await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' }); + }); + + test('Visual - Default Gauge', async ({ page, theme }) => { + await createDomainObjectWithDefaults(page, { + type: 'Gauge', + name: 'Default Gauge' + }); + + // Take a snapshot of the newly created Gauge object + await percySnapshot(page, `Default Gauge (theme: '${theme}')`); + }); + + test('Visual - Needle Gauge with State Generator', async ({ page, theme }) => { + const needleGauge = await createDomainObjectWithDefaults(page, { + type: 'Gauge', + name: 'Needle Gauge' + }); + + //Modify the Gauge to be a Needle Gauge + await page.getByLabel('More actions').click(); + await page.getByLabel('Edit Properties...').click(); + await page.getByLabel('Gauge type', { exact: true }).selectOption('dial-needle'); + await page.getByText('Ok').click(); + + //Add a State Generator to the Gauge + await page.goto(needleGauge.url + '?hideTree=true&hideInspector=true', { + waitUntil: 'domcontentloaded' + }); + + // Take a snapshot of the newly created Gauge object + await percySnapshot(page, `Needle Gauge with no telemetry source (theme: '${theme}')`); + + //Add a State Generator to the Gauge. Note this requires that snapshots are taken within 5 seconds + await page.getByLabel('Create', { exact: true }).click(); + await page.getByLabel('State Generator').click(); + await page.getByLabel('Modal Overlay').getByLabel('Navigate to Needle Gauge').click(); + await page.getByLabel('Save').click(); + + //Add a State Generator to the Gauge + await page.goto(needleGauge.url + '?hideTree=true&hideInspector=true', { + waitUntil: 'domcontentloaded' + }); + + // Take a snapshot of the newly created Gauge object + await percySnapshot(page, `Needle Gauge with State Generator (theme: '${theme}')`); + }); + + test.afterEach(async ({ page }, testInfo) => { + await scanForA11yViolations(page, testInfo.title); + }); +}); diff --git a/e2e/tests/visual-a11y/notebook.visual.spec.js b/e2e/tests/visual-a11y/notebook.visual.spec.js index 3532953fbf..ca3f801fc2 100644 --- a/e2e/tests/visual-a11y/notebook.visual.spec.js +++ b/e2e/tests/visual-a11y/notebook.visual.spec.js @@ -98,7 +98,7 @@ test.describe('Visual - Notebook @a11y', () => { await page.getByLabel('Expand My Items folder').click(); - await page.goto(notebook.url); + await page.goto(notebook.url, { waitUntil: 'networkidle' }); await page .getByLabel('Navigate to Dropped Overlay Plot') diff --git a/e2e/tests/visual-a11y/search.visual.spec.js b/e2e/tests/visual-a11y/search.visual.spec.js index b6a4966e45..b399556913 100644 --- a/e2e/tests/visual-a11y/search.visual.spec.js +++ b/e2e/tests/visual-a11y/search.visual.spec.js @@ -53,7 +53,7 @@ test.describe('Grand Search @a11y', () => { theme }) => { // Navigate to display layout - await page.goto(displayLayout.url); + await page.goto(displayLayout.url, { waitUntil: 'networkidle' }); // Search for the object await page.getByRole('searchbox', { name: 'Search Input' }).click(); diff --git a/src/styles/_constants-espresso.scss b/src/styles/_constants-espresso.scss index 3abae3f1dc..36702317a5 100644 --- a/src/styles/_constants-espresso.scss +++ b/src/styles/_constants-espresso.scss @@ -461,7 +461,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o $colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4); $colorGaugeLimitLow: $colorGaugeLimitHigh; -$colorGaugeNeedle: rgba(#fff, 0); +$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge. $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $gaugeMeterValueShadow: rgba(255, 255, 255, 0); diff --git a/src/styles/_constants-maelstrom.scss b/src/styles/_constants-maelstrom.scss index 8935dfb65b..b0f2999174 100644 --- a/src/styles/_constants-maelstrom.scss +++ b/src/styles/_constants-maelstrom.scss @@ -477,7 +477,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o $colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.4); $colorGaugeLimitLow: $colorGaugeLimitHigh; -$colorGaugeNeedle: rgba(#fff, 0); +$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge. $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $gaugeMeterValueShadow: rgba(255, 255, 255, 0); diff --git a/src/styles/_constants-snow.scss b/src/styles/_constants-snow.scss index 3bc73d0184..4b4efdb52b 100644 --- a/src/styles/_constants-snow.scss +++ b/src/styles/_constants-snow.scss @@ -460,7 +460,7 @@ $colorGaugeMeterTextValue: $colorGaugeTextValue; // Meter text value, overlaid o $colorGaugeRange: $colorBodyFg; // Range text color $colorGaugeLimitHigh: rgba($colorLimitRedBg, 0.2); $colorGaugeLimitLow: $colorGaugeLimitHigh; -$colorGaugeNeedle: rgba(#fff, 0); +$colorGaugeNeedle: $colorGaugeValue; // Color of needle in a needle gauge. $transitionTimeGauge: 150ms; // CSS transition time used to smooth needle gauge and meter value transitions $marginGaugeMeterValue: 10%; // Margin between meter value bar and bounds edges $gaugeMeterValueShadow: rgba(255, 255, 255, 0); From 29f1956d1ab2749b3a753e8267a0ae9f051d2d0f Mon Sep 17 00:00:00 2001 From: Andrew Henry Date: Mon, 30 Sep 2024 14:36:40 -0700 Subject: [PATCH 3/3] Improve telemetry buffering implementation (#7837) * Simplifies the implementation of telemetry buffering in Open MCT. * Switches from per-parameter buffering to a shared queue. Per-parameter buffering is too easily overwhelmed by bursts of telemetry from high-frequency parameters. A single shared buffer has more overhead to grow when a burst arrives without overflowing, because the buffer is shared across all parameters. * Removes the need for plugins to pass serialized code to a worker. * Switched to a "one-size-fits-all" batching strategy removing the need for plugins to define a batching strategy at all. * Captures buffering statistics for display in the PerformanceIndicator. --- .../performanceIndicator.e2e.spec.js | 72 +++++++ src/api/telemetry/BatchingWebSocket.js | 195 ++++++++++-------- src/api/telemetry/WebSocketWorker.js | 178 ++++++---------- src/plugins/performanceIndicator/plugin.js | 64 +++++- 4 files changed, 309 insertions(+), 200 deletions(-) create mode 100644 e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js diff --git a/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js b/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js new file mode 100644 index 0000000000..ef1709fb99 --- /dev/null +++ b/e2e/tests/functional/plugins/performanceIndicator/performanceIndicator.e2e.spec.js @@ -0,0 +1,72 @@ +/***************************************************************************** + * 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 { expect, test } from '../../../../pluginFixtures.js'; + +test.describe('The performance indicator', () => { + test.beforeEach(async ({ page }) => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + const openmct = window.openmct; + openmct.install(openmct.plugins.PerformanceIndicator()); + }); + }); + + test('can be installed', ({ page }) => { + const performanceIndicator = page.getByTitle('Performance Indicator'); + expect(performanceIndicator).toBeDefined(); + }); + + test('Shows a numerical FPS value', async ({ page }) => { + // Frames Per Second. We need to wait at least 1 second to get a value. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + await expect(page.getByTitle('Performance Indicator')).toHaveText(/\d\d? fps/); + }); + + test('Supports showing optional extended performance information in an overlay for debugging', async ({ + page + }) => { + const performanceMeasurementLabel = 'Some measurement'; + const performanceMeasurementValue = 'Some value'; + + await page.evaluate( + ({ performanceMeasurementLabel: label, performanceMeasurementValue: value }) => { + const openmct = window.openmct; + openmct.performance.measurements.set(label, value); + }, + { performanceMeasurementLabel, performanceMeasurementValue } + ); + const performanceIndicator = page.getByTitle('Performance Indicator'); + await performanceIndicator.click(); + //Performance overlay is a crude debugging tool, it's evaluated once per second. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + const performanceOverlay = page.getByTitle('Performance Overlay'); + await expect(performanceOverlay).toBeVisible(); + await expect(performanceOverlay).toHaveText(new RegExp(`${performanceMeasurementLabel}.*`)); + await expect(performanceOverlay).toHaveText(new RegExp(`.*${performanceMeasurementValue}`)); + + //Confirm that it disappears if we click on it again. + await performanceIndicator.click(); + await expect(performanceOverlay).toBeHidden(); + }); +}); diff --git a/src/api/telemetry/BatchingWebSocket.js b/src/api/telemetry/BatchingWebSocket.js index a0687751b4..9c5596d0d6 100644 --- a/src/api/telemetry/BatchingWebSocket.js +++ b/src/api/telemetry/BatchingWebSocket.js @@ -20,25 +20,46 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ import installWorker from './WebSocketWorker.js'; + /** - * Describes the strategy to be used when batching WebSocket messages - * - * @typedef BatchingStrategy - * @property {Function} shouldBatchMessage a function that accepts a single - * argument - the raw message received from the websocket. Every message - * received will be evaluated against this function so it should be performant. - * Note also that this function is executed in a worker, so it must be - * completely self-contained with no external dependencies. The function - * should return `true` if the message should be batched, and `false` if not. - * @property {Function} getBatchIdFromMessage a function that accepts a - * single argument - the raw message received from the websocket. Only messages - * where `shouldBatchMessage` has evaluated to true will be passed into this - * function. The function should return a unique value on which to batch the - * messages. For example a telemetry, channel, or parameter identifier. + * @typedef RequestIdleCallbackOptions + * @prop {Number} timeout If the number of milliseconds represented by this + * parameter has elapsed and the callback has not already been called, invoke + * the callback. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback */ + /** - * Provides a reliable and convenient WebSocket abstraction layer that handles - * a lot of boilerplate common to managing WebSocket connections such as: + * Mocks requestIdleCallback for Safari using setTimeout. Functionality will be + * identical to setTimeout in Safari, which is to fire the callback function + * after the provided timeout period. + * + * In browsers that support requestIdleCallback, this const is just a + * pointer to the native function. + * + * @param {Function} callback a callback to be invoked during the next idle period, or + * after the specified timeout + * @param {RequestIdleCallbackOptions} options + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + * + */ +function requestIdleCallbackPolyfill(callback, options) { + return ( + // eslint-disable-next-line compat/compat + window.requestIdleCallback ?? + ((fn, { timeout }) => + setTimeout(() => { + fn({ didTimeout: false }); + }, timeout)) + ); +} +const requestIdleCallback = requestIdleCallbackPolyfill(); + +const ONE_SECOND = 1000; + +/** + * Provides a WebSocket abstraction layer that handles a lot of boilerplate common + * to managing WebSocket connections such as: * - Establishing a WebSocket connection to a server * - Reconnecting on error, with a fallback strategy * - Queuing messages so that clients can send messages without concern for the current @@ -49,22 +70,19 @@ import installWorker from './WebSocketWorker.js'; * and batching of messages without blocking either the UI or server. * */ -// Shim for Internet Explorer, I mean Safari. It doesn't support requestIdleCallback, but it's in a tech preview, so it will be dropping soon. -const requestIdleCallback = - // eslint-disable-next-line compat/compat - window.requestIdleCallback ?? ((fn, { timeout }) => setTimeout(fn, timeout)); -const ONE_SECOND = 1000; -const FIVE_SECONDS = 5 * ONE_SECOND; - class BatchingWebSocket extends EventTarget { #worker; #openmct; #showingRateLimitNotification; - #maxBatchSize; - #applicationIsInitializing; - #maxBatchWait; + #maxBufferSize; + #throttleRate; #firstBatchReceived; + #lastBatchReceived; + #peakBufferSize = Number.NEGATIVE_INFINITY; + /** + * @param {import('openmct.js').OpenMCT} openmct + */ constructor(openmct) { super(); // Install worker, register listeners etc. @@ -74,9 +92,8 @@ class BatchingWebSocket extends EventTarget { this.#worker = new Worker(workerUrl); this.#openmct = openmct; this.#showingRateLimitNotification = false; - this.#maxBatchSize = Number.POSITIVE_INFINITY; - this.#maxBatchWait = ONE_SECOND; - this.#applicationIsInitializing = true; + this.#maxBufferSize = Number.POSITIVE_INFINITY; + this.#throttleRate = ONE_SECOND; this.#firstBatchReceived = false; const routeMessageToHandler = this.#routeMessageToHandler.bind(this); @@ -89,20 +106,6 @@ class BatchingWebSocket extends EventTarget { }, { once: true } ); - - openmct.once('start', () => { - // An idle callback is a pretty good indication that a complex display is done loading. At that point set the batch size more conservatively. - // Force it after 5 seconds if it hasn't happened yet. - requestIdleCallback( - () => { - this.#applicationIsInitializing = false; - this.setMaxBatchSize(this.#maxBatchSize); - }, - { - timeout: FIVE_SECONDS - } - ); - }); } /** @@ -137,57 +140,48 @@ class BatchingWebSocket extends EventTarget { } /** - * Set the strategy used to both decide which raw messages to batch, and how to group - * them. - * @param {BatchingStrategy} strategy The batching strategy to use when evaluating - * raw messages from the WebSocket. - */ - setBatchingStrategy(strategy) { - const serializedStrategy = { - shouldBatchMessage: strategy.shouldBatchMessage.toString(), - getBatchIdFromMessage: strategy.getBatchIdFromMessage.toString() - }; - - this.#worker.postMessage({ - type: 'setBatchingStrategy', - serializedStrategy - }); - } - - /** - * @param {number} maxBatchSize the maximum length of a batch of messages. For example, - * the maximum number of telemetry values to batch before dropping them + * @param {number} maxBufferSize the maximum length of the receive buffer in characters. * Note that this is a fail-safe that is only invoked if performance drops to the * point where Open MCT cannot keep up with the amount of telemetry it is receiving. * In this event it will sacrifice the oldest telemetry in the batch in favor of the * most recent telemetry. The user will be informed that telemetry has been dropped. * - * This should be set appropriately for the expected data rate. eg. If telemetry - * is received at 10Hz for each telemetry point, then a minimal combination of batch - * size and rate is 10 and 1000 respectively. Ideally you would add some margin, so - * 15 would probably be a better batch size. + * This should be set appropriately for the expected data rate. eg. If typical usage + * sees 2000 messages arriving at a client per second, with an average message size + * of 500 bytes, then 2000 * 500 = 1000000 characters will be right on the limit. + * In this scenario, a buffer size of 1500000 character might be more appropriate + * to allow some overhead for bursty telemetry, and temporary UI load during page + * load. + * + * The PerformanceIndicator plugin (openmct.plugins.PerformanceIndicator) gives + * statistics on buffer utilization. It can be used to scale the buffer appropriately. */ - setMaxBatchSize(maxBatchSize) { - this.#maxBatchSize = maxBatchSize; - if (!this.#applicationIsInitializing) { - this.#sendMaxBatchSizeToWorker(this.#maxBatchSize); - } + setMaxBufferSize(maxBatchSize) { + this.#maxBufferSize = maxBatchSize; + this.#sendMaxBufferSizeToWorker(this.#maxBufferSize); } - setMaxBatchWait(wait) { - this.#maxBatchWait = wait; - this.#sendBatchWaitToWorker(this.#maxBatchWait); + setThrottleRate(throttleRate) { + this.#throttleRate = throttleRate; + this.#sendThrottleRateToWorker(this.#throttleRate); } - #sendMaxBatchSizeToWorker(maxBatchSize) { + setThrottleMessagePattern(throttleMessagePattern) { this.#worker.postMessage({ - type: 'setMaxBatchSize', - maxBatchSize + type: 'setThrottleMessagePattern', + throttleMessagePattern }); } - #sendBatchWaitToWorker(maxBatchWait) { + #sendMaxBufferSizeToWorker(maxBufferSize) { this.#worker.postMessage({ - type: 'setMaxBatchWait', - maxBatchWait + type: 'setMaxBufferSize', + maxBufferSize + }); + } + + #sendThrottleRateToWorker(throttleRate) { + this.#worker.postMessage({ + type: 'setThrottleRate', + throttleRate }); } @@ -203,9 +197,38 @@ class BatchingWebSocket extends EventTarget { #routeMessageToHandler(message) { if (message.data.type === 'batch') { - this.start = Date.now(); const batch = message.data.batch; - if (batch.dropped === true && !this.#showingRateLimitNotification) { + const now = performance.now(); + + let currentBufferLength = message.data.currentBufferLength; + let maxBufferSize = message.data.maxBufferSize; + let parameterCount = batch.length; + if (this.#peakBufferSize < currentBufferLength) { + this.#peakBufferSize = currentBufferLength; + } + + if (this.#openmct.performance !== undefined) { + if (!isNaN(this.#lastBatchReceived)) { + const elapsed = (now - this.#lastBatchReceived) / 1000; + this.#lastBatchReceived = now; + this.#openmct.performance.measurements.set( + 'Parameters/s', + Math.floor(parameterCount / elapsed) + ); + } + this.#openmct.performance.measurements.set( + 'Buff. Util. (bytes)', + `${currentBufferLength} / ${maxBufferSize}` + ); + this.#openmct.performance.measurements.set( + 'Peak Buff. Util. (bytes)', + `${this.#peakBufferSize} / ${maxBufferSize}` + ); + } + + this.start = Date.now(); + const dropped = message.data.dropped; + if (dropped === true && !this.#showingRateLimitNotification) { const notification = this.#openmct.notifications.alert( 'Telemetry dropped due to client rate limiting.', { hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' } @@ -240,18 +263,16 @@ class BatchingWebSocket extends EventTarget { console.warn(`Event loop is too busy to process batch.`); this.#waitUntilIdleAndRequestNextBatch(batch); } else { - // After ingesting a telemetry batch, wait until the event loop is idle again before - // informing the worker we are ready for another batch. this.#readyForNextBatch(); } } else { - if (waitedFor > ONE_SECOND) { + if (waitedFor > this.#throttleRate) { console.warn(`Warning, batch processing took ${waitedFor}ms`); } this.#readyForNextBatch(); } }, - { timeout: ONE_SECOND } + { timeout: this.#throttleRate } ); } } diff --git a/src/api/telemetry/WebSocketWorker.js b/src/api/telemetry/WebSocketWorker.js index a745ff0ceb..f2791577e9 100644 --- a/src/api/telemetry/WebSocketWorker.js +++ b/src/api/telemetry/WebSocketWorker.js @@ -24,10 +24,6 @@ export default function installWorker() { const ONE_SECOND = 1000; const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000]; - /** - * @typedef {import('./BatchingWebSocket').BatchingStrategy} BatchingStrategy - */ - /** * Provides a WebSocket connection that is resilient to errors and dropouts. * On an error or dropout, will automatically reconnect. @@ -215,17 +211,17 @@ export default function installWorker() { case 'message': this.#websocket.enqueueMessage(message.data.message); break; - case 'setBatchingStrategy': - this.setBatchingStrategy(message); - break; case 'readyForNextBatch': this.#messageBatcher.readyForNextBatch(); break; - case 'setMaxBatchSize': - this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize); + case 'setMaxBufferSize': + this.#messageBatcher.setMaxBufferSize(message.data.maxBufferSize); break; - case 'setMaxBatchWait': - this.#messageBatcher.setMaxBatchWait(message.data.maxBatchWait); + case 'setThrottleRate': + this.#messageBatcher.setThrottleRate(message.data.throttleRate); + break; + case 'setThrottleMessagePattern': + this.#messageBatcher.setThrottleMessagePattern(message.data.throttleMessagePattern); break; default: throw new Error(`Unknown message type: ${type}`); @@ -238,122 +234,69 @@ export default function installWorker() { disconnect() { this.#websocket.disconnect(); } - setBatchingStrategy(message) { - const { serializedStrategy } = message.data; - const batchingStrategy = { - // eslint-disable-next-line no-new-func - shouldBatchMessage: new Function(`return ${serializedStrategy.shouldBatchMessage}`)(), - // eslint-disable-next-line no-new-func - getBatchIdFromMessage: new Function(`return ${serializedStrategy.getBatchIdFromMessage}`)() - // Will also include maximum batch length here - }; - this.#messageBatcher.setBatchingStrategy(batchingStrategy); - } } /** - * Received messages from the WebSocket, and passes them along to the - * Worker interface and back to the main thread. + * Responsible for buffering messages */ - class WebSocketToWorkerMessageBroker { - #worker; - #messageBatcher; - - constructor(messageBatcher, worker) { - this.#messageBatcher = messageBatcher; - this.#worker = worker; - } - - routeMessageToHandler(data) { - if (this.#messageBatcher.shouldBatchMessage(data)) { - this.#messageBatcher.addMessageToBatch(data); - } else { - this.#worker.postMessage({ - type: 'message', - message: data - }); - } - } - } - - /** - * Responsible for batching messages according to the defined batching strategy. - */ - class MessageBatcher { - #batch; - #batchingStrategy; - #hasBatch = false; - #maxBatchSize; + class MessageBuffer { + #buffer; + #currentBufferLength; + #dropped; + #maxBufferSize; #readyForNextBatch; #worker; #throttledSendNextBatch; + #throttleMessagePattern; constructor(worker) { // No dropping telemetry unless we're explicitly told to. - this.#maxBatchSize = Number.POSITIVE_INFINITY; + this.#maxBufferSize = Number.POSITIVE_INFINITY; this.#readyForNextBatch = false; this.#worker = worker; this.#resetBatch(); - this.setMaxBatchWait(ONE_SECOND); + this.setThrottleRate(ONE_SECOND); } #resetBatch() { - this.#batch = {}; - this.#hasBatch = false; + //this.#batch = {}; + this.#buffer = []; + this.#currentBufferLength = 0; + this.#dropped = false; } - /** - * @param {BatchingStrategy} strategy - */ - setBatchingStrategy(strategy) { - this.#batchingStrategy = strategy; - } - /** - * Applies the `shouldBatchMessage` function from the supplied batching strategy - * to each message to determine if it should be added to a batch. If not batched, - * the message is immediately sent over the worker to the main thread. - * @param {any} message the message received from the WebSocket. See the WebSocket - * documentation for more details - - * https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data - * @returns - */ - shouldBatchMessage(message) { - return ( - this.#batchingStrategy.shouldBatchMessage && - this.#batchingStrategy.shouldBatchMessage(message) - ); - } - /** - * Adds the given message to a batch. The batch group that the message is added - * to will be determined by the value returned by `getBatchIdFromMessage`. - * @param {any} message the message received from the WebSocket. See the WebSocket - * documentation for more details - - * https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data - */ - addMessageToBatch(message) { - const batchId = this.#batchingStrategy.getBatchIdFromMessage(message); - let batch = this.#batch[batchId]; - if (batch === undefined) { - this.#hasBatch = true; - batch = this.#batch[batchId] = [message]; - } else { - batch.push(message); - } - if (batch.length > this.#maxBatchSize) { - console.warn( - `Exceeded max batch size of ${this.#maxBatchSize} for ${batchId}. Dropping value.` - ); - batch.shift(); - this.#batch.dropped = true; + + addMessageToBuffer(message) { + this.#buffer.push(message); + this.#currentBufferLength += message.length; + + for ( + let i = 0; + this.#currentBufferLength > this.#maxBufferSize && i < this.#buffer.length; + i++ + ) { + const messageToConsider = this.#buffer[i]; + if (this.#shouldThrottle(messageToConsider)) { + this.#buffer.splice(i, 1); + this.#currentBufferLength -= messageToConsider.length; + this.#dropped = true; + } } if (this.#readyForNextBatch) { this.#throttledSendNextBatch(); } } - setMaxBatchSize(maxBatchSize) { - this.#maxBatchSize = maxBatchSize; + + #shouldThrottle(message) { + return ( + this.#throttleMessagePattern !== undefined && this.#throttleMessagePattern.test(message) + ); } - setMaxBatchWait(maxBatchWait) { - this.#throttledSendNextBatch = throttle(this.#sendNextBatch.bind(this), maxBatchWait); + + setMaxBufferSize(maxBufferSize) { + this.#maxBufferSize = maxBufferSize; + } + setThrottleRate(throttleRate) { + this.#throttledSendNextBatch = throttle(this.#sendNextBatch.bind(this), throttleRate); } /** * Indicates that client code is ready to receive the next batch of @@ -362,21 +305,33 @@ export default function installWorker() { * any new data is available. */ readyForNextBatch() { - if (this.#hasBatch) { + if (this.#hasData()) { this.#throttledSendNextBatch(); } else { this.#readyForNextBatch = true; } } #sendNextBatch() { - const batch = this.#batch; + const buffer = this.#buffer; + const dropped = this.#dropped; + const currentBufferLength = this.#currentBufferLength; + this.#resetBatch(); this.#worker.postMessage({ type: 'batch', - batch + dropped, + currentBufferLength: currentBufferLength, + maxBufferSize: this.#maxBufferSize, + batch: buffer }); + this.#readyForNextBatch = false; - this.#hasBatch = false; + } + #hasData() { + return this.#currentBufferLength > 0; + } + setThrottleMessagePattern(priorityMessagePattern) { + this.#throttleMessagePattern = new RegExp(priorityMessagePattern, 'm'); } } @@ -408,15 +363,14 @@ export default function installWorker() { } const websocket = new ResilientWebSocket(self); - const messageBatcher = new MessageBatcher(self); - const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher); - const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self); + const messageBuffer = new MessageBuffer(self); + const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBuffer); self.addEventListener('message', (message) => { workerBroker.routeMessageToHandler(message); }); websocket.registerMessageCallback((data) => { - websocketBroker.routeMessageToHandler(data); + messageBuffer.addMessageToBuffer(data); }); self.websocketInstance = websocket; diff --git a/src/plugins/performanceIndicator/plugin.js b/src/plugins/performanceIndicator/plugin.js index 389cbf6ca1..6fab67a80b 100644 --- a/src/plugins/performanceIndicator/plugin.js +++ b/src/plugins/performanceIndicator/plugin.js @@ -19,14 +19,23 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ +const PERFORMANCE_OVERLAY_RENDER_INTERVAL = 1000; + export default function PerformanceIndicator() { return function install(openmct) { let frames = 0; let lastCalculated = performance.now(); - const indicator = openmct.indicators.simpleIndicator(); + openmct.performance = { + measurements: new Map() + }; + const indicator = openmct.indicators.simpleIndicator(); + indicator.key = 'performance-indicator'; indicator.text('~ fps'); + indicator.description('Performance Indicator'); indicator.statusClass('s-status-info'); + indicator.on('click', showOverlay); + openmct.indicators.add(indicator); let rafHandle = requestAnimationFrame(incrementFrames); @@ -58,5 +67,58 @@ export default function PerformanceIndicator() { indicator.statusClass('s-status-error'); } } + + function showOverlay() { + const overlayStylesText = ` + #c-performance-indicator--overlay { + background-color:rgba(0,0,0,0.5); + position: absolute; + width: 300px; + left: calc(50% - 300px); + } + `; + const overlayMarkup = ` +
+ + +
+
+ `; + const overlayTemplate = document.createElement('div'); + overlayTemplate.innerHTML = overlayMarkup; + const overlay = overlayTemplate.cloneNode(true); + overlay.querySelector('.c-performance-indicator--row').remove(); + const overlayStyles = document.createElement('style'); + overlayStyles.appendChild(document.createTextNode(overlayStylesText)); + + document.head.appendChild(overlayStyles); + document.body.appendChild(overlay); + + indicator.off('click', showOverlay); + + const interval = setInterval(() => { + overlay.querySelector('#c-performance-indicator--table').innerHTML = ''; + + for (const [name, value] of openmct.performance.measurements.entries()) { + const newRow = overlayTemplate + .querySelector('.c-performance-indicator--row') + .cloneNode(true); + newRow.querySelector('.c-performance-indicator--measurement-name').innerText = name; + newRow.querySelector('.c-performance-indicator--measurement-value').innerText = value; + overlay.querySelector('#c-performance-indicator--table').appendChild(newRow); + } + }, PERFORMANCE_OVERLAY_RENDER_INTERVAL); + + indicator.on( + 'click', + () => { + overlayStyles.remove(); + overlay.remove(); + indicator.on('click', showOverlay); + clearInterval(interval); + }, + { once: true, capture: true } + ); + } }; }