From 207b3f5da3eada1190928aa5114f482b21fa03c1 Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Wed, 5 Jun 2024 11:03:26 +0100 Subject: [PATCH] SceneQueryRunner: decouple time range comparisons (#587) --- .../src/components/SceneTimeRangeCompare.tsx | 79 ++++++++++- packages/scenes/src/index.ts | 1 + .../scenes/src/querying/ExtraQueryProvider.ts | 47 +++++++ .../src/querying/SceneQueryRunner.test.ts | 115 +++++++++++++++- .../scenes/src/querying/SceneQueryRunner.ts | 128 ++++++++++-------- .../querying/extraQueryProcessingOperator.ts | 32 +++++ .../timeShiftQueryResponseOperator.ts | 46 ------- 7 files changed, 341 insertions(+), 107 deletions(-) create mode 100644 packages/scenes/src/querying/ExtraQueryProvider.ts create mode 100644 packages/scenes/src/querying/extraQueryProcessingOperator.ts delete mode 100644 packages/scenes/src/querying/timeShiftQueryResponseOperator.ts diff --git a/packages/scenes/src/components/SceneTimeRangeCompare.tsx b/packages/scenes/src/components/SceneTimeRangeCompare.tsx index 1c80f71cd..b47b1186f 100644 --- a/packages/scenes/src/components/SceneTimeRangeCompare.tsx +++ b/packages/scenes/src/components/SceneTimeRangeCompare.tsx @@ -1,16 +1,17 @@ -import { DateTime, dateTime, GrafanaTheme2, rangeUtil, TimeRange } from '@grafana/data'; +import { DataQueryRequest, DateTime, dateTime, FieldType, GrafanaTheme2, rangeUtil, TimeRange } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { ButtonGroup, ButtonSelect, Checkbox, ToolbarButton, useStyles2 } from '@grafana/ui'; import React from 'react'; import { sceneGraph } from '../core/sceneGraph'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneComponentProps, SceneObjectState, SceneObjectUrlValues } from '../core/types'; +import { DataQueryExtended } from '../querying/SceneQueryRunner'; +import { ExtraQueryDescriptor, ExtraQueryDataProcessor, ExtraQueryProvider } from '../querying/ExtraQueryProvider'; import { SceneObjectUrlSyncConfig } from '../services/SceneObjectUrlSyncConfig'; +import { getCompareSeriesRefId } from '../utils/getCompareSeriesRefId'; import { parseUrlParam } from '../utils/parseUrlParam'; import { css } from '@emotion/css'; - -export interface TimeRangeCompareProvider { - getCompareTimeRange(timeRange: TimeRange): TimeRange | undefined; -} +import { of } from 'rxjs'; interface SceneTimeRangeCompareState extends SceneObjectState { compareWith?: string; @@ -38,8 +39,8 @@ export const DEFAULT_COMPARE_OPTIONS = [ export class SceneTimeRangeCompare extends SceneObjectBase - implements TimeRangeCompareProvider -{ + implements ExtraQueryProvider { + static Component = SceneTimeRangeCompareRenderer; protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['compareWith'] }); @@ -94,6 +95,33 @@ export class SceneTimeRangeCompare this.setState({ compareWith: undefined }); }; + // Get a time shifted request to compare with the primary request. + public getExtraQueries(request: DataQueryRequest): ExtraQueryDescriptor[] { + const extraQueries: ExtraQueryDescriptor[] = []; + const compareRange = this.getCompareTimeRange(request.range); + if (!compareRange) { + return extraQueries; + } + + const targets = request.targets.filter((query: DataQueryExtended) => query.timeRangeCompare !== false); + if (targets.length) { + extraQueries.push({ + req: { + ...request, + targets, + range: compareRange, + }, + processor: timeShiftAlignmentProcessor, + }); + } + return extraQueries; + } + + // The query runner should rerun the comparison query if the compareWith value has changed. + public shouldRerun(prev: SceneTimeRangeCompareState, next: SceneTimeRangeCompareState): boolean { + return prev.compareWith !== next.compareWith; + } + public getCompareTimeRange(timeRange: TimeRange): TimeRange | undefined { let compareFrom: DateTime; let compareTo: DateTime; @@ -149,6 +177,43 @@ export class SceneTimeRangeCompare } } +// Processor function for use with time shifted comparison series. +// This aligns the secondary series with the primary and adds custom +// metadata and config to the secondary series' fields so that it is +// rendered appropriately. +const timeShiftAlignmentProcessor: ExtraQueryDataProcessor = (primary, secondary) => { + const diff = secondary.timeRange.from.diff(primary.timeRange.from); + secondary.series.forEach((series) => { + series.refId = getCompareSeriesRefId(series.refId || ''); + series.meta = { + ...series.meta, + // @ts-ignore Remove when https://github.com/grafana/grafana/pull/71129 is released + timeCompare: { + diffMs: diff, + isTimeShiftQuery: true, + }, + }; + series.fields.forEach((field) => { + // Align compare series time stamps with reference series + if (field.type === FieldType.time) { + field.values = field.values.map((v) => { + return diff < 0 ? v - diff : v + diff; + }); + } + + field.config = { + ...field.config, + color: { + mode: 'fixed', + fixedColor: config.theme.palette.gray60, + }, + }; + return field; + }); + }); + return of(secondary); +} + function SceneTimeRangeCompareRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); const { compareWith, compareOptions } = model.useState(); diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 77b57f341..a3a8cbfa0 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -28,6 +28,7 @@ export { SceneTimeRange } from './core/SceneTimeRange'; export { SceneTimeZoneOverride } from './core/SceneTimeZoneOverride'; export { SceneQueryRunner, type QueryRunnerState } from './querying/SceneQueryRunner'; +export { type ExtraQueryDescriptor, type ExtraQueryProvider, type ExtraQueryDataProcessor } from './querying/ExtraQueryProvider'; export { SceneDataLayerSet, SceneDataLayerSetBase } from './querying/SceneDataLayerSet'; export { SceneDataLayerBase } from './querying/layers/SceneDataLayerBase'; export { SceneDataLayerControls } from './querying/layers/SceneDataLayerControls'; diff --git a/packages/scenes/src/querying/ExtraQueryProvider.ts b/packages/scenes/src/querying/ExtraQueryProvider.ts new file mode 100644 index 000000000..7637c57a3 --- /dev/null +++ b/packages/scenes/src/querying/ExtraQueryProvider.ts @@ -0,0 +1,47 @@ +import { DataQueryRequest, PanelData } from "@grafana/data"; +import { Observable } from "rxjs"; + +import { SceneObjectBase } from "../core/SceneObjectBase"; +import { SceneObjectState } from "../core/types"; + +// A processor function called by the query runner with responses +// to any extra requests. +// +// A processor function should accept two arguments: the data returned by the +// _primary_ query, and the data returned by the `ExtraQueryProvider`'s +// _secondary_ query. It should return a new `PanelData` representing the processed output. +// It should _not_ modify the primary PanelData. +// +// Examples of valid processing include alignment of data between primary and secondary +// (see the `timeShiftAlignmentProcessor` returned by `SceneTimeRangeCompare`), or doing +// some more advanced processing such as fitting a time series model on the secondary data. +// +// See the docs for `extraQueryProcessingOperator` for more information. +export type ExtraQueryDataProcessor = (primary: PanelData, secondary: PanelData) => Observable; + +// An extra request that should be run by a query runner, and an optional +// processor that should be called with the response data. +export interface ExtraQueryDescriptor { + // The extra request to add. + req: DataQueryRequest; + // An optional function used to process the data before passing it + // to any transformations or visualizations. + processor?: ExtraQueryDataProcessor; +} + +// Indicates that this type wants to add extra requests, along with +// optional processing functions, to a query runner. +export interface ExtraQueryProvider extends SceneObjectBase { + // Get any extra requests and their required processors. + getExtraQueries(request: DataQueryRequest): ExtraQueryDescriptor[]; + // Determine whether a query should be rerun. + // + // When the provider's state changes this function will be passed both the previous and the + // next state. The implementation can use this to determine whether the change should trigger + // a rerun of the query or not. + shouldRerun(prev: T, next: T): boolean; +} + +export function isExtraQueryProvider(obj: any): obj is ExtraQueryProvider { + return typeof obj === 'object' && 'getExtraQueries' in obj; +} diff --git a/packages/scenes/src/querying/SceneQueryRunner.test.ts b/packages/scenes/src/querying/SceneQueryRunner.test.ts index 61a230771..ed3a11621 100644 --- a/packages/scenes/src/querying/SceneQueryRunner.test.ts +++ b/packages/scenes/src/querying/SceneQueryRunner.test.ts @@ -32,8 +32,10 @@ import { emptyPanelData } from '../core/SceneDataNode'; import { GroupByVariable } from '../variables/groupby/GroupByVariable'; import { SceneQueryController, SceneQueryStateControllerState } from '../behaviors/SceneQueryController'; import { activateFullSceneTree } from '../utils/test/activateFullSceneTree'; -import { SceneDeactivationHandler } from '../core/types'; +import { SceneDeactivationHandler, SceneObjectState } from '../core/types'; import { LocalValueVariable } from '../variables/variants/LocalValueVariable'; +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { ExtraQueryDescriptor, ExtraQueryProvider } from './ExtraQueryProvider'; const getDataSourceMock = jest.fn().mockReturnValue({ uid: 'test-uid', @@ -97,6 +99,7 @@ const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: state: LoadingState.Loading, series: [], annotations: [], + request, timeRange: request.range, }; @@ -1081,6 +1084,88 @@ describe('SceneQueryRunner', () => { }); }); + describe('extra requests', () => { + test('should run and rerun extra requests', async () => { + const timeRange = new SceneTimeRange({ + from: '2023-08-24T05:00:00.000Z', + to: '2023-08-24T07:00:00.000Z', + }); + + const queryRunner = new SceneQueryRunner({ + queries: [{ refId: 'A' }], + }); + const provider = new TestExtraQueryProvider({ foo: 1 }, true); + const scene = new EmbeddedScene({ + $timeRange: timeRange, + $data: queryRunner, + controls: [provider], + body: new SceneCanvasText({ text: 'hello' }), + }); + + // activate the scene, which will also activate the provider + // and the provider will run the extra request + scene.activate(); + await new Promise((r) => setTimeout(r, 1)); + + expect(runRequestMock.mock.calls.length).toEqual(2); + let runRequestCall = runRequestMock.mock.calls[0]; + let extraRunRequestCall = runRequestMock.mock.calls[1]; + expect(runRequestCall[1].targets[0].refId).toEqual('A'); + expect(extraRunRequestCall[1].targets[0].refId).toEqual('Extra'); + expect(extraRunRequestCall[1].targets[0].foo).toEqual(1); + + // change the state of the provider, which will trigger the activation + // handler to run the extra request again. + provider.setState({ foo: 2 }); + await new Promise((r) => setTimeout(r, 1)); + + expect(runRequestMock.mock.calls.length).toEqual(4); + runRequestCall = runRequestMock.mock.calls[2]; + extraRunRequestCall = runRequestMock.mock.calls[3]; + expect(runRequestCall[1].targets[0].refId).toEqual('A'); + expect(extraRunRequestCall[1].targets[0].refId).toEqual('Extra'); + expect(extraRunRequestCall[1].targets[0].foo).toEqual(2); + }); + + test('should not rerun extra requests when providers say not to', async () => { + const timeRange = new SceneTimeRange({ + from: '2023-08-24T05:00:00.000Z', + to: '2023-08-24T07:00:00.000Z', + }); + + const queryRunner = new SceneQueryRunner({ + queries: [{ refId: 'A' }], + }); + const provider = new TestExtraQueryProvider({ foo: 1 }, false); + const scene = new EmbeddedScene({ + $timeRange: timeRange, + $data: queryRunner, + controls: [provider], + body: new SceneCanvasText({ text: 'hello' }), + }); + + // activate the scene, which will also activate the provider + // and the provider will run the extra request + scene.activate(); + await new Promise((r) => setTimeout(r, 1)); + + expect(runRequestMock.mock.calls.length).toEqual(2); + let runRequestCall = runRequestMock.mock.calls[0]; + let extraRunRequestCall = runRequestMock.mock.calls[1]; + expect(runRequestCall[1].targets[0].refId).toEqual('A'); + expect(extraRunRequestCall[1].targets[0].refId).toEqual('Extra'); + expect(extraRunRequestCall[1].targets[0].foo).toEqual(1); + + // change the state of the provider, which will trigger the activation + // handler to run the extra request again. The provider will + // return false from shouldRun, so we should not see any more queries. + provider.setState({ foo: 2 }); + await new Promise((r) => setTimeout(r, 1)); + + expect(runRequestMock.mock.calls.length).toEqual(2); + }); + }); + describe('time frame comparison', () => { test('should run query with time range comparison', async () => { const timeRange = new SceneTimeRange({ @@ -2190,3 +2275,31 @@ class CustomDataSource extends RuntimeDataSource { return of({ data: [{ refId: 'A', fields: [{ name: 'time', type: FieldType.time, values: [123] }] }] }); } } + +interface TestExtraQueryProviderState extends SceneObjectState { + foo: number; +} + +class TestExtraQueryProvider extends SceneObjectBase implements ExtraQueryProvider<{}> { + private _shouldRerun: boolean; + + public constructor(state: { foo: number; }, shouldRerun: boolean) { + super(state); + this._shouldRerun = shouldRerun; + } + + public getExtraQueries(): ExtraQueryDescriptor[] { + return [{ + req: { + targets: [ + // @ts-expect-error + { refId: 'Extra', foo: this.state.foo }, + ], + }, + processor: (primary, secondary) => of({ ...primary, ...secondary }), + }]; + } + public shouldRerun(prev: {}, next: {}): boolean { + return this._shouldRerun; + } +} diff --git a/packages/scenes/src/querying/SceneQueryRunner.ts b/packages/scenes/src/querying/SceneQueryRunner.ts index a47297f28..9e3f6699d 100644 --- a/packages/scenes/src/querying/SceneQueryRunner.ts +++ b/packages/scenes/src/querying/SceneQueryRunner.ts @@ -33,9 +33,9 @@ import { VariableDependencyConfig } from '../variables/VariableDependencyConfig' import { writeSceneLog } from '../utils/writeSceneLog'; import { VariableValueRecorder } from '../variables/VariableValueRecorder'; import { emptyPanelData } from '../core/SceneDataNode'; -import { SceneTimeRangeCompare } from '../components/SceneTimeRangeCompare'; import { getClosest } from '../core/sceneGraph/utils'; -import { timeShiftQueryResponseOperator } from './timeShiftQueryResponseOperator'; +import { isExtraQueryProvider, ExtraQueryDataProcessor, ExtraQueryProvider } from './ExtraQueryProvider'; +import { passthroughProcessor, extraQueryProcessingOperator } from './extraQueryProcessingOperator'; import { filterAnnotations } from './layers/annotations/filterAnnotations'; import { getEnrichedDataRequest } from './getEnrichedDataRequest'; import { findActiveAdHocFilterVariableByUid } from '../variables/adhoc/patchGetAdhocFilters'; @@ -75,6 +75,28 @@ export interface DataQueryExtended extends DataQuery { timeRangeCompare?: boolean; } +// The requests that will be run by the query runner. +// +// Generally the query runner will run a single primary request. +// If the scene graph contains implementations of +// `ExtraQueryProvider`, the requests created by these +// implementations will be added to the list of secondary requests, +// and these will be executed at the same time as the primary request. +// +// The results of each secondary request will be passed to an associated +// processor function (along with the results of the primary request), +// which can transform the results as desired. +interface PreparedRequests { + // The primary request to run. + primary: DataQueryRequest; + // A possibly empty list of secondary requests to run alongside + // the primary request. + secondaries: DataQueryRequest[]; + // A map from `requestId` of secondary requests to processors + // for those requests. Provided by the `ExtraQueryProvider`. + processors: Map; +} + export class SceneQueryRunner extends SceneObjectBase implements SceneDataProvider { private _querySub?: Unsubscribable; private _dataLayersSub?: Unsubscribable; @@ -109,16 +131,18 @@ export class SceneQueryRunner extends SceneObjectBase implemen private _onActivate() { const timeRange = sceneGraph.getTimeRange(this); - const comparer = this.getTimeCompare(); - if (comparer) { + // Add subscriptions to any extra providers so that they rerun queries + // when their state changes and they should rerun. + const providers = this.getClosestExtraQueryProviders(); + for (const provider of providers) { this._subs.add( - comparer.subscribeToState((n, p) => { - if (n.compareWith !== p.compareWith) { + provider.subscribeToState((n, p) => { + if (provider.shouldRerun(p, n)) { this.runQueries(); } }) - ); + ) } this.subscribeToTimeRangeChanges(timeRange); @@ -408,21 +432,27 @@ export class SceneQueryRunner extends SceneObjectBase implemen this.findAndSubscribeToAdHocFilters(datasource?.uid); const runRequest = getRunRequest(); - const [request, secondaryRequest] = this.prepareRequests(timeRange, ds); + const { primary, secondaries, processors } = this.prepareRequests(timeRange, ds); writeSceneLog('SceneQueryRunner', 'Starting runRequest', this.state.key); - let stream = runRequest(ds, request); - - if (secondaryRequest) { - // change subscribe callback below to pipe operator - stream = forkJoin([stream, runRequest(ds, secondaryRequest)]).pipe(timeShiftQueryResponseOperator); + let stream = runRequest(ds, primary); + + if (secondaries.length > 0) { + // Submit all secondary requests in parallel. + const secondaryStreams = secondaries.map((r) => runRequest(ds, r)); + // Create the rxjs operator which will combine the primary and secondary responses + // by calling the correct processor functions provided by the + // extra request providers. + const op = extraQueryProcessingOperator(processors); + // Combine the primary and secondary streams into a single stream, and apply the operator. + stream = forkJoin([stream, ...secondaryStreams]).pipe(op); } stream = stream.pipe( registerQueryWithController({ type: 'data', - request, + request: primary, origin: this, cancel: () => this.cancelQuery(), }) @@ -459,15 +489,9 @@ export class SceneQueryRunner extends SceneObjectBase implemen return clone; } - private prepareRequests = ( - timeRange: SceneTimeRangeLike, - ds: DataSourceApi - ): [DataQueryRequest, DataQueryRequest | undefined] => { - const comparer = this.getTimeCompare(); + private prepareRequests(timeRange: SceneTimeRangeLike, ds: DataSourceApi): PreparedRequests { const { minInterval, queries } = this.state; - let secondaryRequest: DataQueryRequest | undefined; - let request: DataQueryRequest = { app: 'scenes', requestId: getNextRequestId(), @@ -529,29 +553,21 @@ export class SceneQueryRunner extends SceneObjectBase implemen request.interval = norm.interval; request.intervalMs = norm.intervalMs; + // If there are any extra request providers, we need to add a new request for each + // and map the request's ID to the processor function given by the provider, to ensure that + // the processor is called with the correct response data. const primaryTimeRange = timeRange.state.value; - if (comparer) { - const secondaryTimeRange = comparer.getCompareTimeRange(primaryTimeRange); - if (secondaryTimeRange) { - const secondaryTargets = request.targets.filter((query: DataQueryExtended) => query.timeRangeCompare !== false); - - if (secondaryTargets.length) { - secondaryRequest = { - ...request, - targets: secondaryTargets, - range: secondaryTimeRange, - requestId: getNextRequestId(), - }; - } - - request = { - ...request, - range: primaryTimeRange, - }; + let secondaryRequests: DataQueryRequest[] = []; + let secondaryProcessors = new Map(); + for (const provider of this.getClosestExtraQueryProviders() ?? []) { + for (const { req, processor } of provider.getExtraQueries(request)) { + const requestId = getNextRequestId(); + secondaryRequests.push({ ...req, requestId }) + secondaryProcessors.set(requestId, processor ?? passthroughProcessor); } } - - return [request, secondaryRequest]; + request.range = primaryTimeRange; + return { primary: request, secondaries: secondaryRequests, processors: secondaryProcessors }; }; private onDataReceived = (data: PanelData) => { @@ -593,26 +609,32 @@ export class SceneQueryRunner extends SceneObjectBase implemen } /** - * Will walk up the scene graph and find the closest time range compare object - * It performs buttom-up search, including shallow search across object children for supporting controls/header actions + * Walk up the scene graph and find any ExtraQueryProviders. + * + * This will return an array of the closest provider of each type. */ - private getTimeCompare() { + private getClosestExtraQueryProviders(): Array> { + // Maintain a map from provider constructor to provider object. The constructor + // is used as a unique key for each class, to ensure we have no more than one + // type of each type of provider. + const found = new Map(); if (!this.parent) { - return null; + return []; } - return getClosest(this.parent, (s) => { - let found = null; - if (s instanceof SceneTimeRangeCompare) { - return s; + getClosest(this.parent, (s) => { + if (isExtraQueryProvider(s) && !found.has(s.constructor)) { + found.set(s.constructor, s); } s.forEachChild((child) => { - if (child instanceof SceneTimeRangeCompare) { - found = child; + if (isExtraQueryProvider(child) && !found.has(child.constructor)) { + found.set(child.constructor, child); } }); - - return found; + // Always return null so that the search continues to the top of + // the scene graph. + return null; }); + return Array.from(found.values()); } /** diff --git a/packages/scenes/src/querying/extraQueryProcessingOperator.ts b/packages/scenes/src/querying/extraQueryProcessingOperator.ts new file mode 100644 index 000000000..a6ec9608b --- /dev/null +++ b/packages/scenes/src/querying/extraQueryProcessingOperator.ts @@ -0,0 +1,32 @@ +import { PanelData } from '@grafana/data'; +import { forkJoin, of, map, mergeMap, Observable } from 'rxjs'; +import { ExtraQueryDataProcessor } from './ExtraQueryProvider'; + +// Passthrough processor for use with ExtraQuerys. +export const passthroughProcessor: ExtraQueryDataProcessor = (_, secondary) => of(secondary); + +// Factory function which takes a map from request ID to processor functions and +// returns an rxjs operator which operates on an array of panel data responses. +// +// Each secondary response is transformed according to the processor function +// identified by it's request ID. The processor function is passed the primary +// response and the secondary response to be processed. +// +// The output is a single frame with the primary series and all processed +// secondary series combined. +export const extraQueryProcessingOperator = (processors: Map) => + (data: Observable<[PanelData, ...PanelData[]]>) => { + return data.pipe( + mergeMap(([primary, ...secondaries]) => { + const processedSecondaries = secondaries.flatMap((s) => { + return processors.get(s.request!.requestId)?.(primary, s) ?? of(s); + }); + return forkJoin([of(primary), ...processedSecondaries]); + }), + map(([primary, ...processedSecondaries]) => ({ + ...primary, + series: [...primary.series, ...processedSecondaries.flatMap((s) => s.series)], + annotations: [...(primary.annotations ?? []), ...processedSecondaries.flatMap((s) => s.annotations ?? [])], + })) + ); + } diff --git a/packages/scenes/src/querying/timeShiftQueryResponseOperator.ts b/packages/scenes/src/querying/timeShiftQueryResponseOperator.ts deleted file mode 100644 index fcc17d1f6..000000000 --- a/packages/scenes/src/querying/timeShiftQueryResponseOperator.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { FieldType, PanelData } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { map, Observable } from 'rxjs'; -import { getCompareSeriesRefId } from '../utils/getCompareSeriesRefId'; - -export function timeShiftQueryResponseOperator(data: Observable<[PanelData, PanelData]>) { - return data.pipe( - map(([p, s]) => { - const diff = s.timeRange.from.diff(p.timeRange.from); - s.series.forEach((series) => { - series.refId = getCompareSeriesRefId(series.refId || ''); - series.meta = { - ...series.meta, - // @ts-ignore Remove when https://github.com/grafana/grafana/pull/71129 is released - timeCompare: { - diffMs: diff, - isTimeShiftQuery: true, - }, - }; - series.fields.forEach((field) => { - // Align compare series time stamps with reference series - if (field.type === FieldType.time) { - field.values = field.values.map((v) => { - return diff < 0 ? v - diff : v + diff; - }); - } - - field.config = { - ...field.config, - color: { - mode: 'fixed', - fixedColor: config.theme.palette.gray60, - }, - }; - - return field; - }); - }); - - return { - ...p, - series: [...p.series, ...s.series], - }; - }) - ); -}