mirror of
https://github.com/grafana/grafana.git
synced 2025-08-03 03:13:49 +08:00
Live: move centrifuge service to a web worker (#41090)
* Fix: make webpack pickup workers written in TS * Add comlink to dependencies * Temporary fix: copy paste `toDataQueryError` from @grafana/runtime to avoid web dependencies * Implemented comlink-based centrifuge worker & worker proxy * Temporary fix: implement comlink transferHandlers for subscriptions and streamingdataframes * Move liveTimer filtering from CentrifugeService into GrafanaLiveService * Switch from CentrifugeService to CentrifugeServiceWorkerProxy in GrafanaLive * Naming fix * Refactor: move liveTimer-based data filtering from GrafanaLiveService to CentrifugeServiceWorker * observe dataStream on an async scheduler * Fix: - Unsubscribe is now propagated from the main thread to the worker, - improve worker&workerProxy types * Fix: Prettify types * Fix: Add error & complete observers * Docs: Add comment explaining the `subscriberTransferHandler` * Fix: Replace `StreamingDataFrameHandler` with explicitly converting StreamingDataFrame to a DataFrameDTO * Refactor: move liveTimer filtering to service.ts to make it easy to implement a `live-service-web-worker` feature flag * Feat: add `live-service-web-worker` feature flag * Fix: extract toDataQueryError.ts to a separate file within `@grafana-runtime` to avoid having a dependency from webworker to the whole package (@grafana-runtime/index.ts) * Update public/app/features/dashboard/dashgrid/liveTimer.ts Co-authored-by: Leon Sorokin <leeoniya@gmail.com> * Fix: fixed default import class in worker file * Fix: cast worker as Endpoint * Migrate from worker-loader to webpack native worker support v1 - broken prod build * Fix: Use custom path in HtmlWebpackPlugin * Fix: Loading workers from CDNs * Fix: Avoid issues with jest ESM support by mocking `createWorker` files * Fix: move the custom mockWorker rendering layout to `test/mocks` Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
@ -225,8 +225,7 @@
|
|||||||
"webpack-cleanup-plugin": "0.5.1",
|
"webpack-cleanup-plugin": "0.5.1",
|
||||||
"webpack-cli": "4.9.0",
|
"webpack-cli": "4.9.0",
|
||||||
"webpack-dev-server": "4.3.1",
|
"webpack-dev-server": "4.3.1",
|
||||||
"webpack-merge": "5.8.0",
|
"webpack-merge": "5.8.0"
|
||||||
"worker-loader": "^3.0.8"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/css": "11.1.3",
|
"@emotion/css": "11.1.3",
|
||||||
@ -269,6 +268,7 @@
|
|||||||
"centrifuge": "2.7.5",
|
"centrifuge": "2.7.5",
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
"clipboard": "2.0.4",
|
"clipboard": "2.0.4",
|
||||||
|
"comlink": "4.3.1",
|
||||||
"common-tags": "^1.8.0",
|
"common-tags": "^1.8.0",
|
||||||
"core-js": "3.10.0",
|
"core-js": "3.10.0",
|
||||||
"d3": "5.15.0",
|
"d3": "5.15.0",
|
||||||
|
@ -17,12 +17,12 @@ export {
|
|||||||
StreamOptionsProvider,
|
StreamOptionsProvider,
|
||||||
} from './utils/DataSourceWithBackend';
|
} from './utils/DataSourceWithBackend';
|
||||||
export {
|
export {
|
||||||
toDataQueryError,
|
|
||||||
toDataQueryResponse,
|
toDataQueryResponse,
|
||||||
frameToMetricFindValue,
|
frameToMetricFindValue,
|
||||||
BackendDataSourceResponse,
|
BackendDataSourceResponse,
|
||||||
DataResponse,
|
DataResponse,
|
||||||
} from './utils/queryResponse';
|
} from './utils/queryResponse';
|
||||||
|
export { toDataQueryError } from './utils/toDataQueryError';
|
||||||
export { PanelRenderer, PanelRendererProps, PanelRendererType, setPanelRenderer } from './components/PanelRenderer';
|
export { PanelRenderer, PanelRendererProps, PanelRendererType, setPanelRenderer } from './components/PanelRenderer';
|
||||||
export { setQueryRunnerFactory, createQueryRunner, QueryRunnerFactory } from './services/QueryRunner';
|
export { setQueryRunnerFactory, createQueryRunner, QueryRunnerFactory } from './services/QueryRunner';
|
||||||
export { DataSourcePicker, DataSourcePickerProps, DataSourcePickerState } from './components/DataSourcePicker';
|
export { DataSourcePicker, DataSourcePickerProps, DataSourcePickerState } from './components/DataSourcePicker';
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
dataFrameFromJSON,
|
dataFrameFromJSON,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { FetchError, FetchResponse } from '../services';
|
import { FetchError, FetchResponse } from '../services';
|
||||||
|
import { toDataQueryError } from './toDataQueryError';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single response object from a backend data source. Properties are optional but response should contain at least
|
* Single response object from a backend data source. Properties are optional but response should contain at least
|
||||||
@ -159,36 +160,6 @@ export function toTestingStatus(err: FetchError): any {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an object into a DataQueryError -- if this is an HTTP response,
|
|
||||||
* it will put the correct values in the error field
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
export function toDataQueryError(err: DataQueryError | string | Object): DataQueryError {
|
|
||||||
const error = (err || {}) as DataQueryError;
|
|
||||||
|
|
||||||
if (!error.message) {
|
|
||||||
if (typeof err === 'string' || err instanceof String) {
|
|
||||||
return { message: err } as DataQueryError;
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = 'Query error';
|
|
||||||
if (error.message) {
|
|
||||||
message = error.message;
|
|
||||||
} else if (error.data && error.data.message) {
|
|
||||||
message = error.data.message;
|
|
||||||
} else if (error.data && error.data.error) {
|
|
||||||
message = error.data.error;
|
|
||||||
} else if (error.status) {
|
|
||||||
message = `Query error: ${error.status} ${error.statusText}`;
|
|
||||||
}
|
|
||||||
error.message = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the first string or non-time field as the value
|
* Return the first string or non-time field as the value
|
||||||
*
|
*
|
||||||
|
31
packages/grafana-runtime/src/utils/toDataQueryError.ts
Normal file
31
packages/grafana-runtime/src/utils/toDataQueryError.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { DataQueryError } from '@grafana/data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an object into a DataQueryError -- if this is an HTTP response,
|
||||||
|
* it will put the correct values in the error field
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function toDataQueryError(err: DataQueryError | string | Object): DataQueryError {
|
||||||
|
const error = (err || {}) as DataQueryError;
|
||||||
|
|
||||||
|
if (!error.message) {
|
||||||
|
if (typeof err === 'string' || err instanceof String) {
|
||||||
|
return { message: err } as DataQueryError;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = 'Query error';
|
||||||
|
if (error.message) {
|
||||||
|
message = error.message;
|
||||||
|
} else if (error.data && error.data.message) {
|
||||||
|
message = error.data.message;
|
||||||
|
} else if (error.data && error.data.error) {
|
||||||
|
message = error.data.error;
|
||||||
|
} else if (error.status) {
|
||||||
|
message = `Query error: ${error.status} ${error.statusText}`;
|
||||||
|
}
|
||||||
|
error.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
23
public/app/core/utils/CorsWorker.ts
Normal file
23
public/app/core/utils/CorsWorker.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// works with webpack plugin: scripts/webpack/plugins/CorsWorkerPlugin.js
|
||||||
|
export class CorsWorker extends window.Worker {
|
||||||
|
constructor(url: URL, options?: WorkerOptions) {
|
||||||
|
// by default, worker inherits HTML document's location and pathname which leads to wrong public path value
|
||||||
|
// the CorsWorkerPlugin will override it with the value based on the initial worker chunk, ie.
|
||||||
|
// initial worker chunk: http://host.com/cdn/scripts/worker-123.js
|
||||||
|
// resulting public path: http://host.com/cdn/scripts
|
||||||
|
|
||||||
|
const scriptUrl = url.toString();
|
||||||
|
const urlParts = scriptUrl.split('/');
|
||||||
|
urlParts.pop();
|
||||||
|
const scriptsBasePathUrl = `${urlParts.join('/')}/`;
|
||||||
|
|
||||||
|
const importScripts = `importScripts('${scriptUrl}');`;
|
||||||
|
const objectURL = URL.createObjectURL(
|
||||||
|
new Blob([`__webpack_worker_public_path__ = '${scriptsBasePathUrl}'; ${importScripts}`], {
|
||||||
|
type: 'application/javascript',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
super(objectURL, options);
|
||||||
|
URL.revokeObjectURL(objectURL);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { dateMath, dateTime, TimeRange } from '@grafana/data';
|
import { dateMath, dateTime, TimeRange } from '@grafana/data';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
import { PanelChrome } from './PanelChrome';
|
import { PanelChrome } from './PanelChrome';
|
||||||
|
|
||||||
// target is 20hz (50ms), but we poll at 100ms to smooth out jitter
|
// target is 20hz (50ms), but we poll at 100ms to smooth out jitter
|
||||||
@ -15,7 +16,7 @@ class LiveTimer {
|
|||||||
|
|
||||||
budget = 1;
|
budget = 1;
|
||||||
threshold = 1.5; // trial and error appears about right
|
threshold = 1.5; // trial and error appears about right
|
||||||
ok = true;
|
ok = new BehaviorSubject(true);
|
||||||
lastUpdate = Date.now();
|
lastUpdate = Date.now();
|
||||||
|
|
||||||
isLive = false; // the dashboard time range ends in "now"
|
isLive = false; // the dashboard time range ends in "now"
|
||||||
@ -69,11 +70,16 @@ class LiveTimer {
|
|||||||
measure = () => {
|
measure = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.budget = (now - this.lastUpdate) / interval;
|
this.budget = (now - this.lastUpdate) / interval;
|
||||||
this.ok = this.budget <= this.threshold;
|
|
||||||
|
const oldOk = this.ok.getValue();
|
||||||
|
const newOk = this.budget <= this.threshold;
|
||||||
|
if (oldOk !== newOk) {
|
||||||
|
this.ok.next(newOk);
|
||||||
|
}
|
||||||
this.lastUpdate = now;
|
this.lastUpdate = now;
|
||||||
|
|
||||||
// For live dashboards, listen to changes
|
// For live dashboards, listen to changes
|
||||||
if (this.ok && this.isLive && this.timeRange) {
|
if (this.isLive && this.ok.getValue() && this.timeRange) {
|
||||||
// when the time-range is relative fire events
|
// when the time-range is relative fire events
|
||||||
let tr: TimeRange | undefined = undefined;
|
let tr: TimeRange | undefined = undefined;
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
|
@ -3,7 +3,6 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import { UnconnectedNodeGraphContainer } from './NodeGraphContainer';
|
import { UnconnectedNodeGraphContainer } from './NodeGraphContainer';
|
||||||
import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data';
|
import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data';
|
||||||
import { ExploreId } from '../../types';
|
import { ExploreId } from '../../types';
|
||||||
jest.mock('../../plugins/panel/nodeGraph/layout.worker.js');
|
|
||||||
|
|
||||||
describe('NodeGraphContainer', () => {
|
describe('NodeGraphContainer', () => {
|
||||||
it('is collapsed if shown with traces', () => {
|
it('is collapsed if shown with traces', () => {
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
import { CorsWorker as Worker } from 'app/core/utils/CorsWorker';
|
||||||
|
|
||||||
|
export const createWorker = () => new Worker(new URL('./service.worker.ts', import.meta.url));
|
33
public/app/features/live/centrifuge/remoteObservable.ts
Normal file
33
public/app/features/live/centrifuge/remoteObservable.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import * as comlink from 'comlink';
|
||||||
|
import { from, Observable, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
export const remoteObservableAsObservable = <T>(remoteObs: comlink.RemoteObject<Observable<T>>): Observable<T> =>
|
||||||
|
new Observable((subscriber) => {
|
||||||
|
// Passing the callbacks as 3 separate arguments is deprecated, but it's the only option for now
|
||||||
|
//
|
||||||
|
// RxJS recreates the functions via `Function.bind` https://github.com/ReactiveX/rxjs/blob/62aca850a37f598b5db6085661e0594b81ec4281/src/internal/Subscriber.ts#L169
|
||||||
|
// and thus erases the ProxyMarker created via comlink.proxy(fN) when the callbacks
|
||||||
|
// are grouped together in a Observer object (ie. { next: (v) => ..., error: (err) => ..., complete: () => ... })
|
||||||
|
//
|
||||||
|
// solution: TBD (autoproxy all functions?)
|
||||||
|
const remoteSubPromise = remoteObs.subscribe(
|
||||||
|
comlink.proxy((nextValueInRemoteObs: T) => {
|
||||||
|
subscriber.next(nextValueInRemoteObs);
|
||||||
|
}),
|
||||||
|
comlink.proxy((err) => {
|
||||||
|
subscriber.error(err);
|
||||||
|
}),
|
||||||
|
comlink.proxy(() => {
|
||||||
|
subscriber.complete();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
remoteSubPromise.then((remoteSub) => remoteSub.unsubscribe());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promiseWithRemoteObservableAsObservable = <T>(
|
||||||
|
promiseWithProxyObservable: Promise<comlink.RemoteObject<Observable<T>>>
|
||||||
|
): Observable<T> => from(promiseWithProxyObservable).pipe(switchMap((val) => remoteObservableAsObservable(val)));
|
@ -1,5 +1,6 @@
|
|||||||
import Centrifuge from 'centrifuge/dist/centrifuge';
|
import Centrifuge from 'centrifuge/dist/centrifuge';
|
||||||
import { LiveDataStreamOptions, toDataQueryError } from '@grafana/runtime';
|
import { LiveDataStreamOptions } from '@grafana/runtime';
|
||||||
|
import { toDataQueryError } from '@grafana/runtime/src/utils/toDataQueryError';
|
||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@ -15,25 +16,52 @@ import {
|
|||||||
LiveChannelPresenceStatus,
|
LiveChannelPresenceStatus,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
StreamingDataFrame,
|
StreamingDataFrame,
|
||||||
|
toDataFrameDTO,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { CentrifugeLiveChannel } from './channel';
|
import { CentrifugeLiveChannel } from './channel';
|
||||||
import { liveTimer } from 'app/features/dashboard/dashgrid/liveTimer';
|
|
||||||
|
|
||||||
type CentrifugeSrvDeps = {
|
export type CentrifugeSrvDeps = {
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
orgId: number;
|
orgId: number;
|
||||||
orgRole: string;
|
orgRole: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
liveEnabled: boolean;
|
liveEnabled: boolean;
|
||||||
|
dataStreamSubscriberReadiness: Observable<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CentrifugeSrv {
|
export interface CentrifugeSrv {
|
||||||
|
/**
|
||||||
|
* Listen for changes to the connection state
|
||||||
|
*/
|
||||||
|
getConnectionState(): Observable<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for messages in a channel
|
||||||
|
*/
|
||||||
|
getStream<T>(address: LiveChannelAddress, config: LiveChannelConfig): Observable<LiveChannelEvent<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to a channel and return results as DataFrames
|
||||||
|
*/
|
||||||
|
getDataStream(options: LiveDataStreamOptions, config: LiveChannelConfig): Observable<DataQueryResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For channels that support presence, this will request the current state from the server.
|
||||||
|
*
|
||||||
|
* Join and leave messages will be sent to the open stream
|
||||||
|
*/
|
||||||
|
getPresence(address: LiveChannelAddress, config: LiveChannelConfig): Promise<LiveChannelPresenceStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CentrifugeService implements CentrifugeSrv {
|
||||||
readonly open = new Map<string, CentrifugeLiveChannel>();
|
readonly open = new Map<string, CentrifugeLiveChannel>();
|
||||||
readonly centrifuge: Centrifuge;
|
readonly centrifuge: Centrifuge;
|
||||||
readonly connectionState: BehaviorSubject<boolean>;
|
readonly connectionState: BehaviorSubject<boolean>;
|
||||||
readonly connectionBlocker: Promise<void>;
|
readonly connectionBlocker: Promise<void>;
|
||||||
|
private dataStreamSubscriberReady = true;
|
||||||
|
|
||||||
constructor(private deps: CentrifugeSrvDeps) {
|
constructor(private deps: CentrifugeSrvDeps) {
|
||||||
|
deps.dataStreamSubscriberReadiness.subscribe((next) => (this.dataStreamSubscriberReady = next));
|
||||||
const liveUrl = `${deps.appUrl.replace(/^http/, 'ws')}/api/live/ws`;
|
const liveUrl = `${deps.appUrl.replace(/^http/, 'ws')}/api/live/ws`;
|
||||||
this.centrifuge = new Centrifuge(liveUrl, {});
|
this.centrifuge = new Centrifuge(liveUrl, {});
|
||||||
this.centrifuge.setConnectData({
|
this.centrifuge.setConnectData({
|
||||||
@ -66,15 +94,15 @@ export class CentrifugeSrv {
|
|||||||
// Internal functions
|
// Internal functions
|
||||||
//----------------------------------------------------------
|
//----------------------------------------------------------
|
||||||
|
|
||||||
onConnect = (context: any) => {
|
private onConnect = (context: any) => {
|
||||||
this.connectionState.next(true);
|
this.connectionState.next(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
onDisconnect = (context: any) => {
|
private onDisconnect = (context: any) => {
|
||||||
this.connectionState.next(false);
|
this.connectionState.next(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
onServerSideMessage = (context: any) => {
|
private onServerSideMessage = (context: any) => {
|
||||||
console.log('Publication from server-side channel', context);
|
console.log('Publication from server-side channel', context);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,7 +110,7 @@ export class CentrifugeSrv {
|
|||||||
* Get a channel. If the scope, namespace, or path is invalid, a shutdown
|
* Get a channel. If the scope, namespace, or path is invalid, a shutdown
|
||||||
* channel will be returned with an error state indicated in its status
|
* channel will be returned with an error state indicated in its status
|
||||||
*/
|
*/
|
||||||
getChannel<TMessage>(addr: LiveChannelAddress, config: LiveChannelConfig): CentrifugeLiveChannel<TMessage> {
|
private getChannel<TMessage>(addr: LiveChannelAddress, config: LiveChannelConfig): CentrifugeLiveChannel<TMessage> {
|
||||||
const id = `${this.deps.orgId}/${addr.scope}/${addr.namespace}/${addr.path}`;
|
const id = `${this.deps.orgId}/${addr.scope}/${addr.namespace}/${addr.path}`;
|
||||||
let channel = this.open.get(id);
|
let channel = this.open.get(id);
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
@ -145,7 +173,6 @@ export class CentrifugeSrv {
|
|||||||
let data: StreamingDataFrame | undefined = undefined;
|
let data: StreamingDataFrame | undefined = undefined;
|
||||||
let filtered: DataFrame | undefined = undefined;
|
let filtered: DataFrame | undefined = undefined;
|
||||||
let state = LoadingState.Streaming;
|
let state = LoadingState.Streaming;
|
||||||
let last = liveTimer.lastUpdate;
|
|
||||||
let lastWidth = -1;
|
let lastWidth = -1;
|
||||||
|
|
||||||
const process = (msg: DataFrameJSON) => {
|
const process = (msg: DataFrameJSON) => {
|
||||||
@ -172,11 +199,18 @@ export class CentrifugeSrv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = liveTimer.lastUpdate - last;
|
if (this.dataStreamSubscriberReady) {
|
||||||
if (elapsed > 1000 || liveTimer.ok) {
|
|
||||||
filtered.length = data.length; // make sure they stay up-to-date
|
filtered.length = data.length; // make sure they stay up-to-date
|
||||||
subscriber.next({ state, data: [filtered], key });
|
subscriber.next({
|
||||||
last = liveTimer.lastUpdate;
|
state,
|
||||||
|
data: [
|
||||||
|
// workaround for serializing issues when sending DataFrame from web worker to the main thread
|
||||||
|
// DataFrame is making use of ArrayVectors which are es6 classes and thus not cloneable out of the box
|
||||||
|
// `toDataFrameDTO` converts ArrayVectors into native arrays.
|
||||||
|
toDataFrameDTO(filtered),
|
||||||
|
],
|
||||||
|
key,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
52
public/app/features/live/centrifuge/service.worker.ts
Normal file
52
public/app/features/live/centrifuge/service.worker.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { CentrifugeService, CentrifugeSrvDeps } from './service';
|
||||||
|
import * as comlink from 'comlink';
|
||||||
|
import './transferHandlers';
|
||||||
|
import { remoteObservableAsObservable } from './remoteObservable';
|
||||||
|
import { LiveChannelAddress, LiveChannelConfig } from '@grafana/data';
|
||||||
|
import { LiveDataStreamOptions } from '@grafana/runtime';
|
||||||
|
|
||||||
|
let centrifuge: CentrifugeService;
|
||||||
|
|
||||||
|
const initialize = (
|
||||||
|
deps: CentrifugeSrvDeps,
|
||||||
|
remoteDataStreamSubscriberReadiness: comlink.RemoteObject<
|
||||||
|
CentrifugeSrvDeps['dataStreamSubscriberReadiness'] & comlink.ProxyMarked
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
centrifuge = new CentrifugeService({
|
||||||
|
...deps,
|
||||||
|
dataStreamSubscriberReadiness: remoteObservableAsObservable(remoteDataStreamSubscriberReadiness),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConnectionState = () => {
|
||||||
|
return comlink.proxy(centrifuge.getConnectionState());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataStream = (options: LiveDataStreamOptions, config: LiveChannelConfig) => {
|
||||||
|
return comlink.proxy(centrifuge.getDataStream(options, config));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStream = (address: LiveChannelAddress, config: LiveChannelConfig) => {
|
||||||
|
return comlink.proxy(centrifuge.getStream(address, config));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPresence = async (address: LiveChannelAddress, config: LiveChannelConfig) => {
|
||||||
|
return await centrifuge.getPresence(address, config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const workObj = {
|
||||||
|
initialize,
|
||||||
|
getConnectionState,
|
||||||
|
getDataStream,
|
||||||
|
getStream,
|
||||||
|
getPresence,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoteCentrifugeService = typeof workObj;
|
||||||
|
|
||||||
|
comlink.expose(workObj);
|
||||||
|
|
||||||
|
export default class {
|
||||||
|
constructor() {}
|
||||||
|
}
|
40
public/app/features/live/centrifuge/serviceWorkerProxy.ts
Normal file
40
public/app/features/live/centrifuge/serviceWorkerProxy.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { CentrifugeSrv, CentrifugeSrvDeps } from './service';
|
||||||
|
import { RemoteCentrifugeService } from './service.worker';
|
||||||
|
import './transferHandlers';
|
||||||
|
|
||||||
|
import * as comlink from 'comlink';
|
||||||
|
import { asyncScheduler, Observable, observeOn } from 'rxjs';
|
||||||
|
import { LiveChannelAddress, LiveChannelConfig, LiveChannelEvent } from '@grafana/data';
|
||||||
|
import { promiseWithRemoteObservableAsObservable } from './remoteObservable';
|
||||||
|
import { createWorker } from './createCentrifugeServiceWorker';
|
||||||
|
|
||||||
|
export class CentrifugeServiceWorkerProxy implements CentrifugeSrv {
|
||||||
|
private centrifugeWorker;
|
||||||
|
|
||||||
|
constructor(deps: CentrifugeSrvDeps) {
|
||||||
|
this.centrifugeWorker = comlink.wrap<RemoteCentrifugeService>(createWorker() as comlink.Endpoint);
|
||||||
|
this.centrifugeWorker.initialize(deps, comlink.proxy(deps.dataStreamSubscriberReadiness));
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionState: CentrifugeSrv['getConnectionState'] = () => {
|
||||||
|
return promiseWithRemoteObservableAsObservable(this.centrifugeWorker.getConnectionState());
|
||||||
|
};
|
||||||
|
|
||||||
|
getDataStream: CentrifugeSrv['getDataStream'] = (options, config) => {
|
||||||
|
return promiseWithRemoteObservableAsObservable(this.centrifugeWorker.getDataStream(options, config)).pipe(
|
||||||
|
// async scheduler splits the synchronous task of deserializing data from web worker and
|
||||||
|
// consuming the message (ie. updating react component) into two to avoid blocking the event loop
|
||||||
|
observeOn(asyncScheduler)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
getPresence: CentrifugeSrv['getPresence'] = (address, config) => {
|
||||||
|
return this.centrifugeWorker.getPresence(address, config);
|
||||||
|
};
|
||||||
|
|
||||||
|
getStream: CentrifugeSrv['getStream'] = <T>(address: LiveChannelAddress, config: LiveChannelConfig) => {
|
||||||
|
return promiseWithRemoteObservableAsObservable(
|
||||||
|
this.centrifugeWorker.getStream(address, config) as Promise<comlink.Remote<Observable<LiveChannelEvent<T>>>>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
27
public/app/features/live/centrifuge/transferHandlers.ts
Normal file
27
public/app/features/live/centrifuge/transferHandlers.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as comlink from 'comlink';
|
||||||
|
import { Subscriber } from 'rxjs';
|
||||||
|
|
||||||
|
// Observers, ie. functions passed to `observable.subscribe(...)`, are converted to a subclass of `Subscriber` before they are sent to the source Observable.
|
||||||
|
// The conversion happens internally in the RxJS library - this transfer handler is catches them and wraps them with a proxy
|
||||||
|
const subscriberTransferHandler: any = {
|
||||||
|
canHandle(value: any): boolean {
|
||||||
|
return value && value instanceof Subscriber;
|
||||||
|
},
|
||||||
|
|
||||||
|
serialize(value: Function): [MessagePort, Transferable[]] {
|
||||||
|
const obj = comlink.proxy(value);
|
||||||
|
|
||||||
|
const { port1, port2 } = new MessageChannel();
|
||||||
|
|
||||||
|
comlink.expose(obj, port1);
|
||||||
|
|
||||||
|
return [port2, [port2]];
|
||||||
|
},
|
||||||
|
|
||||||
|
deserialize(value: MessagePort): comlink.Remote<MessagePort> {
|
||||||
|
value.start();
|
||||||
|
|
||||||
|
return comlink.wrap<MessagePort>(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
comlink.transferHandlers.set('SubscriberHandler', subscriberTransferHandler);
|
@ -1,10 +1,12 @@
|
|||||||
import { config, getBackendSrv, getGrafanaLiveSrv, setGrafanaLiveSrv } from '@grafana/runtime';
|
import { config, getBackendSrv, getGrafanaLiveSrv, setGrafanaLiveSrv } from '@grafana/runtime';
|
||||||
import { CentrifugeSrv } from './centrifuge/service';
|
|
||||||
import { registerLiveFeatures } from './features';
|
import { registerLiveFeatures } from './features';
|
||||||
import { GrafanaLiveService } from './live';
|
import { GrafanaLiveService } from './live';
|
||||||
import { GrafanaLiveChannelConfigService } from './channel-config';
|
import { GrafanaLiveChannelConfigService } from './channel-config';
|
||||||
import { GrafanaLiveChannelConfigSrv } from './channel-config/types';
|
import { GrafanaLiveChannelConfigSrv } from './channel-config/types';
|
||||||
import { contextSrv } from '../../core/services/context_srv';
|
import { contextSrv } from '../../core/services/context_srv';
|
||||||
|
import { CentrifugeServiceWorkerProxy } from './centrifuge/serviceWorkerProxy';
|
||||||
|
import { CentrifugeService } from './centrifuge/service';
|
||||||
|
import { liveTimer } from 'app/features/dashboard/dashgrid/liveTimer';
|
||||||
|
|
||||||
const grafanaLiveScopesSingleton = new GrafanaLiveChannelConfigService();
|
const grafanaLiveScopesSingleton = new GrafanaLiveChannelConfigService();
|
||||||
|
|
||||||
@ -18,13 +20,19 @@ export const sessionId =
|
|||||||
Math.random().toString(36).substring(2, 15);
|
Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
export function initGrafanaLive() {
|
export function initGrafanaLive() {
|
||||||
const centrifugeSrv = new CentrifugeSrv({
|
const centrifugeServiceDeps = {
|
||||||
appUrl: `${window.location.origin}${config.appSubUrl}`,
|
appUrl: `${window.location.origin}${config.appSubUrl}`,
|
||||||
orgId: contextSrv.user.orgId,
|
orgId: contextSrv.user.orgId,
|
||||||
orgRole: contextSrv.user.orgRole,
|
orgRole: contextSrv.user.orgRole,
|
||||||
liveEnabled: config.liveEnabled,
|
liveEnabled: config.liveEnabled,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
dataStreamSubscriberReadiness: liveTimer.ok.asObservable(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const centrifugeSrv = config.featureToggles['live-service-web-worker']
|
||||||
|
? new CentrifugeServiceWorkerProxy(centrifugeServiceDeps)
|
||||||
|
: new CentrifugeService(centrifugeServiceDeps);
|
||||||
|
|
||||||
setGrafanaLiveSrv(
|
setGrafanaLiveSrv(
|
||||||
new GrafanaLiveService({
|
new GrafanaLiveService({
|
||||||
scopes: getGrafanaLiveScopes(),
|
scopes: getGrafanaLiveScopes(),
|
||||||
|
@ -3,7 +3,6 @@ import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { NodeGraph } from './NodeGraph';
|
import { NodeGraph } from './NodeGraph';
|
||||||
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
|
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
|
||||||
jest.mock('./layout.worker.js');
|
|
||||||
|
|
||||||
jest.mock('react-use/lib/useMeasure', () => {
|
jest.mock('react-use/lib/useMeasure', () => {
|
||||||
return {
|
return {
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
const { layout } = jest.requireActual('../layout.worker.js');
|
|
||||||
|
|
||||||
export default class TestWorker {
|
|
||||||
constructor() {}
|
|
||||||
postMessage(data) {
|
|
||||||
const { nodes, edges, config } = data;
|
|
||||||
setTimeout(() => {
|
|
||||||
layout(nodes, edges, config);
|
|
||||||
this.onmessage({ data: { nodes, edges } });
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
}
|
|
3
public/app/plugins/panel/nodeGraph/createLayoutWorker.ts
Normal file
3
public/app/plugins/panel/nodeGraph/createLayoutWorker.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { CorsWorker as Worker } from 'app/core/utils/CorsWorker';
|
||||||
|
|
||||||
|
export const createWorker = () => new Worker(new URL('./layout.worker.js', import.meta.url));
|
@ -4,8 +4,7 @@ import { Field } from '@grafana/data';
|
|||||||
import { useNodeLimit } from './useNodeLimit';
|
import { useNodeLimit } from './useNodeLimit';
|
||||||
import useMountedState from 'react-use/lib/useMountedState';
|
import useMountedState from 'react-use/lib/useMountedState';
|
||||||
import { graphBounds } from './utils';
|
import { graphBounds } from './utils';
|
||||||
// @ts-ignore
|
import { createWorker } from './createLayoutWorker';
|
||||||
import LayoutWorker from './layout.worker.js';
|
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
linkDistance: number;
|
linkDistance: number;
|
||||||
@ -135,7 +134,7 @@ function defaultLayout(
|
|||||||
edges: EdgeDatum[],
|
edges: EdgeDatum[],
|
||||||
done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void
|
done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void
|
||||||
) {
|
) {
|
||||||
const worker = new LayoutWorker();
|
const worker = createWorker();
|
||||||
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => {
|
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => {
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
// These stats needs to be Field class but the data is stringified over the worker boundary
|
// These stats needs to be Field class but the data is stringified over the worker boundary
|
||||||
|
@ -3,6 +3,7 @@ import { EventBusSrv } from '@grafana/data';
|
|||||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import 'mutationobserver-shim';
|
import 'mutationobserver-shim';
|
||||||
|
import './mocks/workers';
|
||||||
|
|
||||||
const testAppEvents = new EventBusSrv();
|
const testAppEvents = new EventBusSrv();
|
||||||
const global = window as any;
|
const global = window as any;
|
||||||
|
27
public/test/mocks/workers.ts
Normal file
27
public/test/mocks/workers.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const { layout } = jest.requireActual('../../app/plugins/panel/nodeGraph/layout.worker.js');
|
||||||
|
|
||||||
|
class LayoutMockWorker {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
postMessage(data: any) {
|
||||||
|
const { nodes, edges, config } = data;
|
||||||
|
setTimeout(() => {
|
||||||
|
layout(nodes, edges, config);
|
||||||
|
// @ts-ignore
|
||||||
|
this.onmessage({ data: { nodes, edges } });
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock('../../app/plugins/panel/nodeGraph/createLayoutWorker', () => ({
|
||||||
|
createWorker: () => new LayoutMockWorker(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class BasicMockWorker {
|
||||||
|
postMessage() {}
|
||||||
|
}
|
||||||
|
const mockCreateWorker = {
|
||||||
|
createWorker: () => new BasicMockWorker(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../app/features/live/centrifuge/createCentrifugeServiceWorker', () => mockCreateWorker);
|
64
scripts/webpack/plugins/CorsWorkerPlugin.js
Normal file
64
scripts/webpack/plugins/CorsWorkerPlugin.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const { RuntimeGlobals, RuntimeModule } = require('webpack');
|
||||||
|
|
||||||
|
class CorsWorkerPublicPathRuntimeModule extends RuntimeModule {
|
||||||
|
constructor(publicPath) {
|
||||||
|
super('publicPath', RuntimeModule.STAGE_BASIC);
|
||||||
|
this.publicPath = publicPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string} runtime code
|
||||||
|
*/
|
||||||
|
generate() {
|
||||||
|
const { compilation, publicPath } = this;
|
||||||
|
|
||||||
|
const publicPathValue = compilation.getPath(publicPath || '', {
|
||||||
|
hash: compilation.hash || 'XXXX',
|
||||||
|
});
|
||||||
|
return `${RuntimeGlobals.publicPath} = __webpack_worker_public_path__ || '${publicPathValue}';`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/webpack/webpack/discussions/14648#discussioncomment-1604202
|
||||||
|
// by @ https://github.com/piotr-oles
|
||||||
|
class CorsWorkerPlugin {
|
||||||
|
/**
|
||||||
|
* @param {import('webpack').Compiler} compiler
|
||||||
|
*/
|
||||||
|
apply(compiler) {
|
||||||
|
compiler.hooks.compilation.tap(
|
||||||
|
'CorsWorkerPlugin',
|
||||||
|
/**
|
||||||
|
* @param {import('webpack').Compilation} compilation
|
||||||
|
*/
|
||||||
|
(compilation) => {
|
||||||
|
const getChunkLoading = (chunk) => {
|
||||||
|
const entryOptions = chunk.getEntryOptions();
|
||||||
|
return entryOptions && entryOptions.chunkLoading !== undefined
|
||||||
|
? entryOptions.chunkLoading
|
||||||
|
: compilation.outputOptions.chunkLoading;
|
||||||
|
};
|
||||||
|
const getChunkPublicPath = (chunk) => {
|
||||||
|
const entryOptions = chunk.getEntryOptions();
|
||||||
|
return entryOptions && entryOptions.publicPath !== undefined
|
||||||
|
? entryOptions.publicPath
|
||||||
|
: compilation.outputOptions.publicPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
compilation.hooks.runtimeRequirementInTree.for(RuntimeGlobals.publicPath).tap('CorsWorkerPlugin', (chunk) => {
|
||||||
|
if (getChunkLoading(chunk) === 'import-scripts') {
|
||||||
|
const publicPath = getChunkPublicPath(chunk);
|
||||||
|
|
||||||
|
if (publicPath !== 'auto') {
|
||||||
|
const module = new CorsWorkerPublicPathRuntimeModule(publicPath);
|
||||||
|
compilation.addRuntimeModule(chunk, module);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CorsWorkerPlugin;
|
@ -1,7 +1,7 @@
|
|||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
|
const CorsWorkerPlugin = require('./plugins/CorsWorkerPlugin');
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||||
|
|
||||||
class CopyUniconsPlugin {
|
class CopyUniconsPlugin {
|
||||||
@ -58,6 +58,7 @@ module.exports = {
|
|||||||
source: false,
|
source: false,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
new CorsWorkerPlugin(),
|
||||||
new webpack.ProvidePlugin({
|
new webpack.ProvidePlugin({
|
||||||
Buffer: ['buffer', 'Buffer'],
|
Buffer: ['buffer', 'Buffer'],
|
||||||
}),
|
}),
|
||||||
@ -124,15 +125,6 @@ module.exports = {
|
|||||||
loader: 'file-loader',
|
loader: 'file-loader',
|
||||||
options: { name: 'static/img/[name].[hash:8].[ext]' },
|
options: { name: 'static/img/[name].[hash:8].[ext]' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
test: /\.worker\.js$/,
|
|
||||||
use: {
|
|
||||||
loader: 'worker-loader',
|
|
||||||
options: {
|
|
||||||
inline: 'fallback',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3
|
// https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3
|
||||||
|
21
yarn.lock
21
yarn.lock
@ -12355,6 +12355,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"comlink@npm:4.3.1":
|
||||||
|
version: 4.3.1
|
||||||
|
resolution: "comlink@npm:4.3.1"
|
||||||
|
checksum: 557360a6558708c55aff74a25f834bfb9bfca8a42444682c4d5aead57681534a0206202be2a2760b4de124c3ba6d485b08978b6d5469cb3d26bf1438ee28a4f1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"comma-separated-tokens@npm:^1.0.0":
|
"comma-separated-tokens@npm:^1.0.0":
|
||||||
version: 1.0.8
|
version: 1.0.8
|
||||||
resolution: "comma-separated-tokens@npm:1.0.8"
|
resolution: "comma-separated-tokens@npm:1.0.8"
|
||||||
@ -17829,6 +17836,7 @@ __metadata:
|
|||||||
centrifuge: 2.7.5
|
centrifuge: 2.7.5
|
||||||
classnames: 2.2.6
|
classnames: 2.2.6
|
||||||
clipboard: 2.0.4
|
clipboard: 2.0.4
|
||||||
|
comlink: 4.3.1
|
||||||
common-tags: ^1.8.0
|
common-tags: ^1.8.0
|
||||||
copy-webpack-plugin: 9.0.1
|
copy-webpack-plugin: 9.0.1
|
||||||
core-js: 3.10.0
|
core-js: 3.10.0
|
||||||
@ -17994,7 +18002,6 @@ __metadata:
|
|||||||
webpack-dev-server: 4.3.1
|
webpack-dev-server: 4.3.1
|
||||||
webpack-merge: 5.8.0
|
webpack-merge: 5.8.0
|
||||||
whatwg-fetch: 3.1.0
|
whatwg-fetch: 3.1.0
|
||||||
worker-loader: ^3.0.8
|
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
@ -33911,18 +33918,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"worker-loader@npm:^3.0.8":
|
|
||||||
version: 3.0.8
|
|
||||||
resolution: "worker-loader@npm:3.0.8"
|
|
||||||
dependencies:
|
|
||||||
loader-utils: ^2.0.0
|
|
||||||
schema-utils: ^3.0.0
|
|
||||||
peerDependencies:
|
|
||||||
webpack: ^4.0.0 || ^5.0.0
|
|
||||||
checksum: 84f4a7eeb2a1d8b9704425837e017c91eedfae67ac89e0b866a2dcf283323c1dcabe0258196278b7d5fd0041392da895c8a0c59ddf3a94f1b2e003df68ddfec3
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"worker-rpc@npm:^0.1.0":
|
"worker-rpc@npm:^0.1.0":
|
||||||
version: 0.1.1
|
version: 0.1.1
|
||||||
resolution: "worker-rpc@npm:0.1.1"
|
resolution: "worker-rpc@npm:0.1.1"
|
||||||
|
Reference in New Issue
Block a user