mirror of
https://github.com/nasa/openmct.git
synced 2025-02-06 11:09:21 +00:00
Request batch when idle (#7526)
See https://github.com/nasa/openmct/pull/7526 for details
This commit is contained in:
parent
8c2558bfe0
commit
5f0bd10c61
@ -272,7 +272,7 @@ test.describe('Display Layout', () => {
|
|||||||
expect(trimmedDisplayValue).toBe(formattedTelemetryValue);
|
expect(trimmedDisplayValue).toBe(formattedTelemetryValue);
|
||||||
|
|
||||||
// ensure we can right click on the alpha-numeric widget and view historical data
|
// ensure we can right click on the alpha-numeric widget and view historical data
|
||||||
await page.getByLabel('Sine', { exact: true }).click({
|
await page.getByLabel(/Alpha-numeric telemetry value of.*/).click({
|
||||||
button: 'right'
|
button: 'right'
|
||||||
});
|
});
|
||||||
await page.getByLabel('View Historical Data').click();
|
await page.getByLabel('View Historical Data').click();
|
||||||
|
@ -54,7 +54,9 @@ test.describe('Plots work in Previews', () => {
|
|||||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||||
|
|
||||||
// right click on the plot and select view large
|
// right click on the plot and select view large
|
||||||
await page.getByLabel('Sine', { exact: true }).click({ button: 'right' });
|
await page.getByLabel(/Alpha-numeric telemetry value of.*/).click({
|
||||||
|
button: 'right'
|
||||||
|
});
|
||||||
await page.getByLabel('View Historical Data').click();
|
await page.getByLabel('View Historical Data').click();
|
||||||
await expect(page.getByLabel('Preview Container').getByLabel('Plot Canvas')).toBeVisible();
|
await expect(page.getByLabel('Preview Container').getByLabel('Plot Canvas')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
@ -109,7 +109,7 @@ test.describe('Verify tooltips', () => {
|
|||||||
|
|
||||||
async function getToolTip(object) {
|
async function getToolTip(object) {
|
||||||
await page.locator('.c-create-button').hover();
|
await page.locator('.c-create-button').hover();
|
||||||
await page.getByRole('cell', { name: object.name }).hover();
|
await page.getByLabel('lad name').getByText(object.name).hover();
|
||||||
let tooltipText = await page.locator('.c-tooltip').textContent();
|
let tooltipText = await page.locator('.c-tooltip').textContent();
|
||||||
return tooltipText.replace('\n', '').trim();
|
return tooltipText.replace('\n', '').trim();
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,6 @@
|
|||||||
* at runtime from the About dialog for additional information.
|
* at runtime from the About dialog for additional information.
|
||||||
*****************************************************************************/
|
*****************************************************************************/
|
||||||
import installWorker from './WebSocketWorker.js';
|
import installWorker from './WebSocketWorker.js';
|
||||||
const DEFAULT_RATE_MS = 1000;
|
|
||||||
/**
|
/**
|
||||||
* Describes the strategy to be used when batching WebSocket messages
|
* Describes the strategy to be used when batching WebSocket messages
|
||||||
*
|
*
|
||||||
@ -51,11 +50,21 @@ const DEFAULT_RATE_MS = 1000;
|
|||||||
*
|
*
|
||||||
* @memberof module:openmct.telemetry
|
* @memberof module:openmct.telemetry
|
||||||
*/
|
*/
|
||||||
|
// Shim for Internet Explorer, I mean Safari. It doesn't support requestIdleCallback, but it's in a tech preview, so it will be dropping soon.
|
||||||
|
const requestIdleCallback =
|
||||||
|
// eslint-disable-next-line compat/compat
|
||||||
|
window.requestIdleCallback ?? ((fn, { timeout }) => setTimeout(fn, timeout));
|
||||||
|
const ONE_SECOND = 1000;
|
||||||
|
const FIVE_SECONDS = 5 * ONE_SECOND;
|
||||||
|
|
||||||
class BatchingWebSocket extends EventTarget {
|
class BatchingWebSocket extends EventTarget {
|
||||||
#worker;
|
#worker;
|
||||||
#openmct;
|
#openmct;
|
||||||
#showingRateLimitNotification;
|
#showingRateLimitNotification;
|
||||||
#rate;
|
#maxBatchSize;
|
||||||
|
#applicationIsInitializing;
|
||||||
|
#maxBatchWait;
|
||||||
|
#firstBatchReceived;
|
||||||
|
|
||||||
constructor(openmct) {
|
constructor(openmct) {
|
||||||
super();
|
super();
|
||||||
@ -66,7 +75,10 @@ class BatchingWebSocket extends EventTarget {
|
|||||||
this.#worker = new Worker(workerUrl);
|
this.#worker = new Worker(workerUrl);
|
||||||
this.#openmct = openmct;
|
this.#openmct = openmct;
|
||||||
this.#showingRateLimitNotification = false;
|
this.#showingRateLimitNotification = false;
|
||||||
this.#rate = DEFAULT_RATE_MS;
|
this.#maxBatchSize = Number.POSITIVE_INFINITY;
|
||||||
|
this.#maxBatchWait = ONE_SECOND;
|
||||||
|
this.#applicationIsInitializing = true;
|
||||||
|
this.#firstBatchReceived = false;
|
||||||
|
|
||||||
const routeMessageToHandler = this.#routeMessageToHandler.bind(this);
|
const routeMessageToHandler = this.#routeMessageToHandler.bind(this);
|
||||||
this.#worker.addEventListener('message', routeMessageToHandler);
|
this.#worker.addEventListener('message', routeMessageToHandler);
|
||||||
@ -78,6 +90,20 @@ class BatchingWebSocket extends EventTarget {
|
|||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
openmct.once('start', () => {
|
||||||
|
// An idle callback is a pretty good indication that a complex display is done loading. At that point set the batch size more conservatively.
|
||||||
|
// Force it after 5 seconds if it hasn't happened yet.
|
||||||
|
requestIdleCallback(
|
||||||
|
() => {
|
||||||
|
this.#applicationIsInitializing = false;
|
||||||
|
this.setMaxBatchSize(this.#maxBatchSize);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: FIVE_SECONDS
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -129,14 +155,6 @@ class BatchingWebSocket extends EventTarget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
* @param {Number} maxBatchSize the maximum length of a batch of messages. For example,
|
||||||
* the maximum number of telemetry values to batch before dropping them
|
* the maximum number of telemetry values to batch before dropping them
|
||||||
@ -151,12 +169,29 @@ class BatchingWebSocket extends EventTarget {
|
|||||||
* 15 would probably be a better batch size.
|
* 15 would probably be a better batch size.
|
||||||
*/
|
*/
|
||||||
setMaxBatchSize(maxBatchSize) {
|
setMaxBatchSize(maxBatchSize) {
|
||||||
|
this.#maxBatchSize = maxBatchSize;
|
||||||
|
if (!this.#applicationIsInitializing) {
|
||||||
|
this.#sendMaxBatchSizeToWorker(this.#maxBatchSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMaxBatchWait(wait) {
|
||||||
|
this.#maxBatchWait = wait;
|
||||||
|
this.#sendBatchWaitToWorker(this.#maxBatchWait);
|
||||||
|
}
|
||||||
|
#sendMaxBatchSizeToWorker(maxBatchSize) {
|
||||||
this.#worker.postMessage({
|
this.#worker.postMessage({
|
||||||
type: 'setMaxBatchSize',
|
type: 'setMaxBatchSize',
|
||||||
maxBatchSize
|
maxBatchSize
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sendBatchWaitToWorker(maxBatchWait) {
|
||||||
|
this.#worker.postMessage({
|
||||||
|
type: 'setMaxBatchWait',
|
||||||
|
maxBatchWait
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect the associated WebSocket. Generally speaking there is no need to call
|
* Disconnect the associated WebSocket. Generally speaking there is no need to call
|
||||||
* this manually.
|
* this manually.
|
||||||
@ -169,7 +204,9 @@ class BatchingWebSocket extends EventTarget {
|
|||||||
|
|
||||||
#routeMessageToHandler(message) {
|
#routeMessageToHandler(message) {
|
||||||
if (message.data.type === 'batch') {
|
if (message.data.type === 'batch') {
|
||||||
if (message.data.batch.dropped === true && !this.#showingRateLimitNotification) {
|
this.start = Date.now();
|
||||||
|
const batch = message.data.batch;
|
||||||
|
if (batch.dropped === true && !this.#showingRateLimitNotification) {
|
||||||
const notification = this.#openmct.notifications.alert(
|
const notification = this.#openmct.notifications.alert(
|
||||||
'Telemetry dropped due to client rate limiting.',
|
'Telemetry dropped due to client rate limiting.',
|
||||||
{ hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' }
|
{ hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' }
|
||||||
@ -179,16 +216,45 @@ class BatchingWebSocket extends EventTarget {
|
|||||||
this.#showingRateLimitNotification = false;
|
this.#showingRateLimitNotification = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.dispatchEvent(new CustomEvent('batch', { detail: message.data.batch }));
|
|
||||||
setTimeout(() => {
|
this.dispatchEvent(new CustomEvent('batch', { detail: batch }));
|
||||||
this.#readyForNextBatch();
|
this.#waitUntilIdleAndRequestNextBatch(batch);
|
||||||
}, this.#rate);
|
|
||||||
} else if (message.data.type === 'message') {
|
} else if (message.data.type === 'message') {
|
||||||
this.dispatchEvent(new CustomEvent('message', { detail: message.data.message }));
|
this.dispatchEvent(new CustomEvent('message', { detail: message.data.message }));
|
||||||
|
} else if (message.data.type === 'reconnected') {
|
||||||
|
this.dispatchEvent(new CustomEvent('reconnected'));
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown message type: ${message.data.type}`);
|
throw new Error(`Unknown message type: ${message.data.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#waitUntilIdleAndRequestNextBatch(batch) {
|
||||||
|
requestIdleCallback(
|
||||||
|
(state) => {
|
||||||
|
if (this.#firstBatchReceived === false) {
|
||||||
|
this.#firstBatchReceived = true;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const waitedFor = now - this.start;
|
||||||
|
if (state.didTimeout === true) {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
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) {
|
||||||
|
console.warn(`Warning, batch processing took ${waitedFor}ms`);
|
||||||
|
}
|
||||||
|
this.#readyForNextBatch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: ONE_SECOND }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BatchingWebSocket;
|
export default BatchingWebSocket;
|
||||||
|
@ -85,6 +85,7 @@ const SUBSCRIBE_STRATEGY = {
|
|||||||
export default class TelemetryAPI {
|
export default class TelemetryAPI {
|
||||||
#isGreedyLAD;
|
#isGreedyLAD;
|
||||||
#subscribeCache;
|
#subscribeCache;
|
||||||
|
#hasReturnedFirstData;
|
||||||
|
|
||||||
get SUBSCRIBE_STRATEGY() {
|
get SUBSCRIBE_STRATEGY() {
|
||||||
return SUBSCRIBE_STRATEGY;
|
return SUBSCRIBE_STRATEGY;
|
||||||
@ -108,6 +109,7 @@ export default class TelemetryAPI {
|
|||||||
this.#isGreedyLAD = true;
|
this.#isGreedyLAD = true;
|
||||||
this.BatchingWebSocket = BatchingWebSocket;
|
this.BatchingWebSocket = BatchingWebSocket;
|
||||||
this.#subscribeCache = {};
|
this.#subscribeCache = {};
|
||||||
|
this.#hasReturnedFirstData = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
abortAllRequests() {
|
abortAllRequests() {
|
||||||
@ -383,7 +385,10 @@ export default class TelemetryAPI {
|
|||||||
arguments[1] = await this.applyRequestInterceptors(domainObject, arguments[1]);
|
arguments[1] = await this.applyRequestInterceptors(domainObject, arguments[1]);
|
||||||
try {
|
try {
|
||||||
const telemetry = await provider.request(...arguments);
|
const telemetry = await provider.request(...arguments);
|
||||||
|
if (!this.#hasReturnedFirstData) {
|
||||||
|
this.#hasReturnedFirstData = true;
|
||||||
|
performance.mark('firstHistoricalDataReturned');
|
||||||
|
}
|
||||||
return telemetry;
|
return telemetry;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== 'AbortError') {
|
if (error.name !== 'AbortError') {
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
*****************************************************************************/
|
*****************************************************************************/
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
export default function installWorker() {
|
export default function installWorker() {
|
||||||
|
const ONE_SECOND = 1000;
|
||||||
const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000];
|
const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,6 +45,13 @@ export default function installWorker() {
|
|||||||
#currentWaitIndex = 0;
|
#currentWaitIndex = 0;
|
||||||
#messageCallbacks = [];
|
#messageCallbacks = [];
|
||||||
#wsUrl;
|
#wsUrl;
|
||||||
|
#reconnecting = false;
|
||||||
|
#worker;
|
||||||
|
|
||||||
|
constructor(worker) {
|
||||||
|
super();
|
||||||
|
this.#worker = worker;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Establish a new WebSocket connection to the given URL
|
* Establish a new WebSocket connection to the given URL
|
||||||
@ -62,6 +70,9 @@ export default function installWorker() {
|
|||||||
this.#isConnecting = true;
|
this.#isConnecting = true;
|
||||||
|
|
||||||
this.#webSocket = new WebSocket(url);
|
this.#webSocket = new WebSocket(url);
|
||||||
|
//Exposed to e2e tests so that the websocket can be manipulated during tests. Cannot find any other way to do this.
|
||||||
|
// Playwright does not support forcing websocket state changes.
|
||||||
|
this.#worker.currentWebSocket = this.#webSocket;
|
||||||
|
|
||||||
const boundConnected = this.#connected.bind(this);
|
const boundConnected = this.#connected.bind(this);
|
||||||
this.#webSocket.addEventListener('open', boundConnected);
|
this.#webSocket.addEventListener('open', boundConnected);
|
||||||
@ -100,12 +111,17 @@ export default function installWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#connected() {
|
#connected() {
|
||||||
console.debug('Websocket connected.');
|
console.info('Websocket connected.');
|
||||||
this.#isConnected = true;
|
this.#isConnected = true;
|
||||||
this.#isConnecting = false;
|
this.#isConnecting = false;
|
||||||
this.#currentWaitIndex = 0;
|
this.#currentWaitIndex = 0;
|
||||||
|
|
||||||
this.dispatchEvent(new Event('connected'));
|
if (this.#reconnecting) {
|
||||||
|
this.#worker.postMessage({
|
||||||
|
type: 'reconnected'
|
||||||
|
});
|
||||||
|
this.#reconnecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.#flushQueue();
|
this.#flushQueue();
|
||||||
}
|
}
|
||||||
@ -138,6 +154,7 @@ export default function installWorker() {
|
|||||||
if (this.#reconnectTimeoutHandle) {
|
if (this.#reconnectTimeoutHandle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.#reconnecting = true;
|
||||||
|
|
||||||
this.#reconnectTimeoutHandle = setTimeout(() => {
|
this.#reconnectTimeoutHandle = setTimeout(() => {
|
||||||
this.connect(this.#wsUrl);
|
this.connect(this.#wsUrl);
|
||||||
@ -207,6 +224,9 @@ export default function installWorker() {
|
|||||||
case 'setMaxBatchSize':
|
case 'setMaxBatchSize':
|
||||||
this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize);
|
this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize);
|
||||||
break;
|
break;
|
||||||
|
case 'setMaxBatchWait':
|
||||||
|
this.#messageBatcher.setMaxBatchWait(message.data.maxBatchWait);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown message type: ${type}`);
|
throw new Error(`Unknown message type: ${type}`);
|
||||||
}
|
}
|
||||||
@ -245,7 +265,6 @@ export default function installWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
routeMessageToHandler(data) {
|
routeMessageToHandler(data) {
|
||||||
//Implement batching here
|
|
||||||
if (this.#messageBatcher.shouldBatchMessage(data)) {
|
if (this.#messageBatcher.shouldBatchMessage(data)) {
|
||||||
this.#messageBatcher.addMessageToBatch(data);
|
this.#messageBatcher.addMessageToBatch(data);
|
||||||
} else {
|
} else {
|
||||||
@ -267,12 +286,15 @@ export default function installWorker() {
|
|||||||
#maxBatchSize;
|
#maxBatchSize;
|
||||||
#readyForNextBatch;
|
#readyForNextBatch;
|
||||||
#worker;
|
#worker;
|
||||||
|
#throttledSendNextBatch;
|
||||||
|
|
||||||
constructor(worker) {
|
constructor(worker) {
|
||||||
this.#maxBatchSize = 10;
|
// No dropping telemetry unless we're explicitly told to.
|
||||||
|
this.#maxBatchSize = Number.POSITIVE_INFINITY;
|
||||||
this.#readyForNextBatch = false;
|
this.#readyForNextBatch = false;
|
||||||
this.#worker = worker;
|
this.#worker = worker;
|
||||||
this.#resetBatch();
|
this.#resetBatch();
|
||||||
|
this.setMaxBatchWait(ONE_SECOND);
|
||||||
}
|
}
|
||||||
#resetBatch() {
|
#resetBatch() {
|
||||||
this.#batch = {};
|
this.#batch = {};
|
||||||
@ -310,23 +332,29 @@ export default function installWorker() {
|
|||||||
const batchId = this.#batchingStrategy.getBatchIdFromMessage(message);
|
const batchId = this.#batchingStrategy.getBatchIdFromMessage(message);
|
||||||
let batch = this.#batch[batchId];
|
let batch = this.#batch[batchId];
|
||||||
if (batch === undefined) {
|
if (batch === undefined) {
|
||||||
|
this.#hasBatch = true;
|
||||||
batch = this.#batch[batchId] = [message];
|
batch = this.#batch[batchId] = [message];
|
||||||
} else {
|
} else {
|
||||||
batch.push(message);
|
batch.push(message);
|
||||||
}
|
}
|
||||||
if (batch.length > this.#maxBatchSize) {
|
if (batch.length > this.#maxBatchSize) {
|
||||||
|
console.warn(
|
||||||
|
`Exceeded max batch size of ${this.#maxBatchSize} for ${batchId}. Dropping value.`
|
||||||
|
);
|
||||||
batch.shift();
|
batch.shift();
|
||||||
this.#batch.dropped = this.#batch.dropped || true;
|
this.#batch.dropped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.#readyForNextBatch) {
|
if (this.#readyForNextBatch) {
|
||||||
this.#sendNextBatch();
|
this.#throttledSendNextBatch();
|
||||||
} else {
|
|
||||||
this.#hasBatch = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMaxBatchSize(maxBatchSize) {
|
setMaxBatchSize(maxBatchSize) {
|
||||||
this.#maxBatchSize = maxBatchSize;
|
this.#maxBatchSize = maxBatchSize;
|
||||||
}
|
}
|
||||||
|
setMaxBatchWait(maxBatchWait) {
|
||||||
|
this.#throttledSendNextBatch = throttle(this.#sendNextBatch.bind(this), maxBatchWait);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Indicates that client code is ready to receive the next batch of
|
* Indicates that client code is ready to receive the next batch of
|
||||||
* messages. If a batch is available, it will be immediately sent.
|
* messages. If a batch is available, it will be immediately sent.
|
||||||
@ -335,7 +363,7 @@ export default function installWorker() {
|
|||||||
*/
|
*/
|
||||||
readyForNextBatch() {
|
readyForNextBatch() {
|
||||||
if (this.#hasBatch) {
|
if (this.#hasBatch) {
|
||||||
this.#sendNextBatch();
|
this.#throttledSendNextBatch();
|
||||||
} else {
|
} else {
|
||||||
this.#readyForNextBatch = true;
|
this.#readyForNextBatch = true;
|
||||||
}
|
}
|
||||||
@ -352,7 +380,34 @@ export default function installWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const websocket = new ResilientWebSocket();
|
function throttle(callback, wait) {
|
||||||
|
let last = 0;
|
||||||
|
let throttling = false;
|
||||||
|
|
||||||
|
return function (...args) {
|
||||||
|
if (throttling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
const timeSinceLast = now - last;
|
||||||
|
|
||||||
|
if (timeSinceLast >= wait) {
|
||||||
|
last = now;
|
||||||
|
callback(...args);
|
||||||
|
} else if (!throttling) {
|
||||||
|
throttling = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
last = performance.now();
|
||||||
|
throttling = false;
|
||||||
|
callback(...args);
|
||||||
|
}, wait - timeSinceLast);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const websocket = new ResilientWebSocket(self);
|
||||||
const messageBatcher = new MessageBatcher(self);
|
const messageBatcher = new MessageBatcher(self);
|
||||||
const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher);
|
const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher);
|
||||||
const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self);
|
const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self);
|
||||||
@ -363,4 +418,6 @@ export default function installWorker() {
|
|||||||
websocket.registerMessageCallback((data) => {
|
websocket.registerMessageCallback((data) => {
|
||||||
websocketBroker.routeMessageToHandler(data);
|
websocketBroker.routeMessageToHandler(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.websocketInstance = websocket;
|
||||||
}
|
}
|
||||||
|
@ -24,11 +24,13 @@
|
|||||||
<tr
|
<tr
|
||||||
ref="tableRow"
|
ref="tableRow"
|
||||||
class="js-lad-table__body__row c-table__selectable-row"
|
class="js-lad-table__body__row c-table__selectable-row"
|
||||||
|
aria-label="lad row"
|
||||||
@click="clickedRow"
|
@click="clickedRow"
|
||||||
@contextmenu.prevent="showContextMenu"
|
@contextmenu.prevent="showContextMenu"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
ref="tableCell"
|
ref="tableCell"
|
||||||
|
aria-label="lad name"
|
||||||
class="js-first-data"
|
class="js-first-data"
|
||||||
@mouseover.ctrl="showToolTip"
|
@mouseover.ctrl="showToolTip"
|
||||||
@mouseleave="hideToolTip"
|
@mouseleave="hideToolTip"
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="c-lad-table-wrapper u-style-receiver js-style-receiver" :class="staleClass">
|
<div class="c-lad-table-wrapper u-style-receiver js-style-receiver" :class="staleClass">
|
||||||
<table class="c-table c-lad-table" :class="applyLayoutClass">
|
<table aria-label="lad table" class="c-table c-lad-table" :class="applyLayoutClass">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
aria-label="sub object frame"
|
||||||
class="l-layout__frame c-frame"
|
class="l-layout__frame c-frame"
|
||||||
:class="{
|
:class="{
|
||||||
'no-frame': !item.hasFrame,
|
'no-frame': !item.hasFrame,
|
||||||
|
@ -37,24 +37,24 @@
|
|||||||
:style="styleObject"
|
:style="styleObject"
|
||||||
:data-font-size="item.fontSize"
|
:data-font-size="item.fontSize"
|
||||||
:data-font="item.font"
|
:data-font="item.font"
|
||||||
|
aria-label="Alpha-numeric telemetry"
|
||||||
@contextmenu.prevent="showContextMenu"
|
@contextmenu.prevent="showContextMenu"
|
||||||
@mouseover.ctrl="showToolTip"
|
@mouseover.ctrl="showToolTip"
|
||||||
@mouseleave="hideToolTip"
|
@mouseleave="hideToolTip"
|
||||||
>
|
>
|
||||||
<div
|
<div class="is-status__indicator"></div>
|
||||||
class="is-status__indicator"
|
|
||||||
:aria-label="`This item is ${status}`"
|
|
||||||
:title="`This item is ${status}`"
|
|
||||||
></div>
|
|
||||||
<div v-if="showLabel" class="c-telemetry-view__label">
|
<div v-if="showLabel" class="c-telemetry-view__label">
|
||||||
<div class="c-telemetry-view__label-text">
|
<div
|
||||||
|
class="c-telemetry-view__label-text"
|
||||||
|
:aria-label="`Alpha-numeric telemetry name for ${domainObject.name}`"
|
||||||
|
>
|
||||||
{{ domainObject.name }}
|
{{ domainObject.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showValue"
|
v-if="showValue"
|
||||||
:aria-label="fieldName"
|
:aria-label="`Alpha-numeric telemetry value of ${telemetryValue}`"
|
||||||
:title="fieldName"
|
:title="fieldName"
|
||||||
class="c-telemetry-view__value"
|
class="c-telemetry-view__value"
|
||||||
:class="[telemetryClass]"
|
:class="[telemetryClass]"
|
||||||
|
@ -220,6 +220,7 @@
|
|||||||
lengthAdjust="spacing"
|
lengthAdjust="spacing"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
dominant-baseline="middle"
|
dominant-baseline="middle"
|
||||||
|
:aria-label="`gauge value of ${curVal}`"
|
||||||
x="50%"
|
x="50%"
|
||||||
y="50%"
|
y="50%"
|
||||||
>
|
>
|
||||||
|
@ -51,6 +51,6 @@ export default class OpenInNewTab {
|
|||||||
*/
|
*/
|
||||||
invoke(objectPath, _view, customUrlParams) {
|
invoke(objectPath, _view, customUrlParams) {
|
||||||
const url = objectPathToUrl(this._openmct, objectPath, customUrlParams);
|
const url = objectPathToUrl(this._openmct, objectPath, customUrlParams);
|
||||||
window.open(url);
|
window.open(url, undefined, 'noopener');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -287,7 +287,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { toRaw } from 'vue';
|
import { onMounted, ref, toRaw } from 'vue';
|
||||||
|
|
||||||
import stalenessMixin from '@/ui/mixins/staleness-mixin';
|
import stalenessMixin from '@/ui/mixins/staleness-mixin';
|
||||||
|
|
||||||
@ -295,7 +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 { useResizeObserver } from '../../../ui/composables/resize.js';
|
||||||
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';
|
||||||
@ -303,7 +303,6 @@ 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 AUTO_SCROLL_TRIGGER_HEIGHT = ROW_HEIGHT * 3;
|
const AUTO_SCROLL_TRIGGER_HEIGHT = ROW_HEIGHT * 3;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -354,6 +353,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['marked-rows-updated', 'filter'],
|
emits: ['marked-rows-updated', 'filter'],
|
||||||
|
setup() {
|
||||||
|
const root = ref(null);
|
||||||
|
const { size: containerSize, startObserving } = useResizeObserver();
|
||||||
|
onMounted(() => {
|
||||||
|
startObserving(root.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { containerSize, root };
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
let configuration = this.table.configuration.getConfiguration();
|
let configuration = this.table.configuration.getConfiguration();
|
||||||
|
|
||||||
@ -441,6 +449,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
//This should be refactored so that it doesn't require an explicit watch. Should be doable.
|
||||||
|
containerSize: {
|
||||||
|
handler() {
|
||||||
|
this.debouncedRescaleToContainer();
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
},
|
||||||
loading: {
|
loading: {
|
||||||
handler(isLoading) {
|
handler(isLoading) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -500,9 +515,10 @@ export default {
|
|||||||
this.filterTelemetry = _.debounce(this.filterTelemetry, 500);
|
this.filterTelemetry = _.debounce(this.filterTelemetry, 500);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.throttledUpdateVisibleRows = _.throttle(this.updateVisibleRows, 1000, { leading: true });
|
||||||
|
this.debouncedRescaleToContainer = _.debounce(this.rescaleToContainer, 300);
|
||||||
|
|
||||||
this.csvExporter = new CSVExporter();
|
this.csvExporter = new CSVExporter();
|
||||||
this.rowsAdded = _.throttle(this.rowsAdded, 200);
|
|
||||||
this.rowsRemoved = _.throttle(this.rowsRemoved, 200);
|
|
||||||
this.scroll = _.throttle(this.scroll, 100);
|
this.scroll = _.throttle(this.scroll, 100);
|
||||||
|
|
||||||
if (!this.marking.useAlternateControlBar && !this.enableLegacyToolbar) {
|
if (!this.marking.useAlternateControlBar && !this.enableLegacyToolbar) {
|
||||||
@ -515,8 +531,6 @@ 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);
|
||||||
@ -526,8 +540,8 @@ export default {
|
|||||||
|
|
||||||
this.table.tableRows.on('add', this.rowsAdded);
|
this.table.tableRows.on('add', this.rowsAdded);
|
||||||
this.table.tableRows.on('remove', this.rowsRemoved);
|
this.table.tableRows.on('remove', this.rowsRemoved);
|
||||||
this.table.tableRows.on('sort', this.updateVisibleRows);
|
this.table.tableRows.on('sort', this.throttledUpdateVisibleRows);
|
||||||
this.table.tableRows.on('filter', this.updateVisibleRows);
|
this.table.tableRows.on('filter', this.throttledUpdateVisibleRows);
|
||||||
|
|
||||||
this.openmct.time.on('bounds', this.boundsChanged);
|
this.openmct.time.on('bounds', this.boundsChanged);
|
||||||
|
|
||||||
@ -540,10 +554,10 @@ export default {
|
|||||||
this.table.configuration.on('change', this.updateConfiguration);
|
this.table.configuration.on('change', this.updateConfiguration);
|
||||||
|
|
||||||
this.calculateTableSize();
|
this.calculateTableSize();
|
||||||
this.pollForResize();
|
|
||||||
this.calculateScrollbarWidth();
|
this.calculateScrollbarWidth();
|
||||||
|
|
||||||
this.table.initialize();
|
this.table.initialize();
|
||||||
|
this.rescaleToContainer();
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.table.off('object-added', this.addObject);
|
this.table.off('object-added', this.addObject);
|
||||||
@ -555,15 +569,13 @@ export default {
|
|||||||
|
|
||||||
this.table.tableRows.off('add', this.rowsAdded);
|
this.table.tableRows.off('add', this.rowsAdded);
|
||||||
this.table.tableRows.off('remove', this.rowsRemoved);
|
this.table.tableRows.off('remove', this.rowsRemoved);
|
||||||
this.table.tableRows.off('sort', this.updateVisibleRows);
|
this.table.tableRows.off('sort', this.throttledUpdateVisibleRows);
|
||||||
this.table.tableRows.off('filter', this.updateVisibleRows);
|
this.table.tableRows.off('filter', this.throttledUpdateVisibleRows);
|
||||||
|
|
||||||
this.table.configuration.off('change', this.updateConfiguration);
|
this.table.configuration.off('change', this.updateConfiguration);
|
||||||
|
|
||||||
this.openmct.time.off('bounds', this.boundsChanged);
|
this.openmct.time.off('bounds', this.boundsChanged);
|
||||||
|
|
||||||
clearInterval(this.resizePollHandle);
|
|
||||||
|
|
||||||
this.table.configuration.destroy();
|
this.table.configuration.destroy();
|
||||||
|
|
||||||
this.table.destroy();
|
this.table.destroy();
|
||||||
@ -684,7 +696,7 @@ export default {
|
|||||||
this.table.sortBy(this.sortOptions);
|
this.table.sortBy(this.sortOptions);
|
||||||
},
|
},
|
||||||
scroll() {
|
scroll() {
|
||||||
this.updateVisibleRows();
|
this.throttledUpdateVisibleRows();
|
||||||
this.synchronizeScrollX();
|
this.synchronizeScrollX();
|
||||||
|
|
||||||
if (this.shouldAutoScroll()) {
|
if (this.shouldAutoScroll()) {
|
||||||
@ -757,11 +769,11 @@ export default {
|
|||||||
this.initiateAutoScroll();
|
this.initiateAutoScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateVisibleRows();
|
this.throttledUpdateVisibleRows();
|
||||||
},
|
},
|
||||||
rowsRemoved(rows) {
|
rowsRemoved(rows) {
|
||||||
this.setHeight();
|
this.setHeight();
|
||||||
this.updateVisibleRows();
|
this.throttledUpdateVisibleRows();
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Calculates height based on total number of rows, and sets table height.
|
* Calculates height based on total number of rows, and sets table height.
|
||||||
@ -880,15 +892,11 @@ export default {
|
|||||||
dropTargetActive(isActive) {
|
dropTargetActive(isActive) {
|
||||||
this.isDropTargetActive = isActive;
|
this.isDropTargetActive = isActive;
|
||||||
},
|
},
|
||||||
pollForResize() {
|
rescaleToContainer() {
|
||||||
let el = this.$refs.root;
|
|
||||||
let width = el.clientWidth;
|
|
||||||
let height = el.clientHeight;
|
|
||||||
let scrollTop = this.scrollable.scrollTop;
|
let scrollTop = this.scrollable.scrollTop;
|
||||||
|
|
||||||
this.resizePollHandle = setInterval(() => {
|
|
||||||
this.renderWhenVisible(() => {
|
this.renderWhenVisible(() => {
|
||||||
if ((el.clientWidth !== width || el.clientHeight !== height) && this.isAutosizeEnabled) {
|
if (this.isAutosizeEnabled) {
|
||||||
this.calculateTableSize();
|
this.calculateTableSize();
|
||||||
// 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.
|
||||||
@ -897,18 +905,14 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.scrollable.scrollTop = scrollTop;
|
this.scrollable.scrollTop = scrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
width = el.clientWidth;
|
|
||||||
height = el.clientHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollTop = this.scrollable.scrollTop;
|
scrollTop = this.scrollable.scrollTop;
|
||||||
});
|
});
|
||||||
}, RESIZE_POLL_INTERVAL);
|
|
||||||
},
|
},
|
||||||
clearRowsAndRerender() {
|
clearRowsAndRerender() {
|
||||||
this.visibleRows = [];
|
this.visibleRows = [];
|
||||||
this.$nextTick().then(this.updateVisibleRows);
|
this.$nextTick().then(this.throttledUpdateVisibleRows);
|
||||||
},
|
},
|
||||||
pause(byButton) {
|
pause(byButton) {
|
||||||
if (byButton) {
|
if (byButton) {
|
||||||
|
@ -35,7 +35,6 @@ import utcMultiTimeFormat from './utcMultiTimeFormat.js';
|
|||||||
|
|
||||||
const PADDING = 1;
|
const PADDING = 1;
|
||||||
const DEFAULT_DURATION_FORMATTER = 'duration';
|
const DEFAULT_DURATION_FORMATTER = 'duration';
|
||||||
const RESIZE_POLL_INTERVAL = 200;
|
|
||||||
const PIXELS_PER_TICK = 100;
|
const PIXELS_PER_TICK = 100;
|
||||||
const PIXELS_PER_TICK_WIDE = 200;
|
const PIXELS_PER_TICK_WIDE = 200;
|
||||||
|
|
||||||
@ -92,7 +91,6 @@ export default {
|
|||||||
|
|
||||||
//Respond to changes in conductor
|
//Respond to changes in conductor
|
||||||
this.openmct.time.on(TIME_CONTEXT_EVENTS.timeSystemChanged, this.setViewFromTimeSystem);
|
this.openmct.time.on(TIME_CONTEXT_EVENTS.timeSystemChanged, this.setViewFromTimeSystem);
|
||||||
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
|
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
clearInterval(this.resizeTimer);
|
clearInterval(this.resizeTimer);
|
||||||
|
@ -148,7 +148,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.handleNewBounds = _.throttle(this.handleNewBounds, 300);
|
this.handleNewBounds = _.throttle(this.handleNewBounds, 300, {
|
||||||
|
leading: true,
|
||||||
|
trailing: false
|
||||||
|
});
|
||||||
this.setTimeSystem(this.copy(this.openmct.time.getTimeSystem()));
|
this.setTimeSystem(this.copy(this.openmct.time.getTimeSystem()));
|
||||||
this.openmct.time.on(TIME_CONTEXT_EVENTS.timeSystemChanged, this.setTimeSystem);
|
this.openmct.time.on(TIME_CONTEXT_EVENTS.timeSystemChanged, this.setTimeSystem);
|
||||||
this.setTimeContext();
|
this.setTimeContext();
|
||||||
|
@ -46,6 +46,8 @@
|
|||||||
<div
|
<div
|
||||||
ref="objectName"
|
ref="objectName"
|
||||||
class="c-object-label__name"
|
class="c-object-label__name"
|
||||||
|
aria-label="object name"
|
||||||
|
:title="domainObject && domainObject.name"
|
||||||
@mouseover.ctrl="showToolTip"
|
@mouseover.ctrl="showToolTip"
|
||||||
@mouseleave="hideToolTip"
|
@mouseleave="hideToolTip"
|
||||||
>
|
>
|
||||||
|
@ -105,7 +105,9 @@ export default {
|
|||||||
return this.status ? `is-status--${this.status}` : '';
|
return this.status ? `is-status--${this.status}` : '';
|
||||||
},
|
},
|
||||||
ariaLabel() {
|
ariaLabel() {
|
||||||
return `${this.isEditing ? 'Preview' : 'Navigate to'} ${this.domainObject.name} ${this.domainObject.type} Object`;
|
return `${this.isEditing ? 'Preview' : 'Navigate to'} ${this.domainObject.name} ${
|
||||||
|
this.domainObject.type
|
||||||
|
} Object`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -29,12 +29,14 @@
|
|||||||
import { axisTop } from 'd3-axis';
|
import { axisTop } from 'd3-axis';
|
||||||
import { scaleLinear, scaleUtc } from 'd3-scale';
|
import { scaleLinear, scaleUtc } from 'd3-scale';
|
||||||
import { select } from 'd3-selection';
|
import { select } from 'd3-selection';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import utcMultiTimeFormat from '@/plugins/timeConductor/utcMultiTimeFormat';
|
import utcMultiTimeFormat from '@/plugins/timeConductor/utcMultiTimeFormat';
|
||||||
|
|
||||||
|
import { useResizeObserver } from '../composables/resize';
|
||||||
|
|
||||||
//TODO: UI direction needed for the following property values
|
//TODO: UI direction needed for the following property values
|
||||||
const PADDING = 1;
|
const PADDING = 1;
|
||||||
const RESIZE_POLL_INTERVAL = 200;
|
|
||||||
const PIXELS_PER_TICK = 100;
|
const PIXELS_PER_TICK = 100;
|
||||||
const PIXELS_PER_TICK_WIDE = 200;
|
const PIXELS_PER_TICK_WIDE = 200;
|
||||||
//This offset needs to be re-considered
|
//This offset needs to be re-considered
|
||||||
@ -73,6 +75,16 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const axisHolder = ref(null);
|
||||||
|
const { size, startObserving } = useResizeObserver();
|
||||||
|
onMounted(() => {
|
||||||
|
startObserving(axisHolder.value);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
containerSize: size
|
||||||
|
};
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
bounds(newBounds) {
|
bounds(newBounds) {
|
||||||
this.drawAxis(newBounds, this.timeSystem);
|
this.drawAxis(newBounds, this.timeSystem);
|
||||||
@ -82,6 +94,9 @@ export default {
|
|||||||
},
|
},
|
||||||
contentHeight() {
|
contentHeight() {
|
||||||
this.updateNowMarker();
|
this.updateNowMarker();
|
||||||
|
},
|
||||||
|
containerSize() {
|
||||||
|
this.resize();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@ -100,7 +115,7 @@ export default {
|
|||||||
|
|
||||||
this.setDimensions();
|
this.setDimensions();
|
||||||
this.drawAxis(this.bounds, this.timeSystem);
|
this.drawAxis(this.bounds, this.timeSystem);
|
||||||
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
|
this.resize();
|
||||||
},
|
},
|
||||||
unmounted() {
|
unmounted() {
|
||||||
clearInterval(this.resizeTimer);
|
clearInterval(this.resizeTimer);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user