diff --git a/projects/common/src/navigation/navigation.service.ts b/projects/common/src/navigation/navigation.service.ts index 02ebc6441..e80c263ee 100644 --- a/projects/common/src/navigation/navigation.service.ts +++ b/projects/common/src/navigation/navigation.service.ts @@ -376,7 +376,7 @@ export class NavigationService { } export interface QueryParamObject extends Params { - [key: string]: string | string[] | number | number[] | undefined; + [key: string]: string | string[] | boolean | boolean[] | number | number[] | undefined; } export type NavigationPath = string | (string | Dictionary)[]; diff --git a/projects/observability/src/pages/explorer/explorer.component.test.ts b/projects/observability/src/pages/explorer/explorer.component.test.ts index 1cd0db703..dcab7cb6d 100644 --- a/projects/observability/src/pages/explorer/explorer.component.test.ts +++ b/projects/observability/src/pages/explorer/explorer.component.test.ts @@ -1,5 +1,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { discardPeriodicTasks, fakeAsync } from '@angular/core/testing'; +import { Provider } from '@angular/core'; +import { fakeAsync } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { IconLibraryTestingModule } from '@hypertrace/assets-library'; @@ -16,7 +17,8 @@ import { FilterAttributeType, FilterBarComponent, FilterBuilderLookupService, - FilterOperator + FilterOperator, + ToggleGroupComponent } from '@hypertrace/components'; import { GraphQlRequestService } from '@hypertrace/graphql-client'; import { getMockFlexLayoutProviders, patchRouterNavigateForTest } from '@hypertrace/test-utils'; @@ -25,6 +27,10 @@ import { EMPTY, NEVER, of } from 'rxjs'; import { startWith } from 'rxjs/operators'; import { CartesianSeriesVisualizationType } from '../../shared/components/cartesian/chart'; import { ExploreQueryEditorComponent } from '../../shared/components/explore-query-editor/explore-query-editor.component'; +import { ExploreQueryGroupByEditorComponent } from '../../shared/components/explore-query-editor/group-by/explore-query-group-by-editor.component'; +import { ExploreQueryIntervalEditorComponent } from '../../shared/components/explore-query-editor/interval/explore-query-interval-editor.component'; +import { ExploreQueryLimitEditorComponent } from '../../shared/components/explore-query-editor/limit/explore-query-limit-editor.component'; +import { ExploreQuerySeriesEditorComponent } from '../../shared/components/explore-query-editor/series/explore-query-series-editor.component'; import { MetricAggregationType } from '../../shared/graphql/model/metrics/metric-aggregation'; import { GraphQlFieldFilter } from '../../shared/graphql/model/schema/filter/field/graphql-field-filter'; import { GraphQlOperatorType } from '../../shared/graphql/model/schema/filter/graphql-filter'; @@ -109,13 +115,24 @@ describe('Explorer component', () => { const detectQueryChange = () => { spectator.detectChanges(); // Detect whatever caused the change - spectator.tick(200); // Query emits async, tick here triggers building the DOM for the query - discardPeriodicTasks(); // Some of the newly instantiated components also uses async, need to wait for them to settle - spectator.tick(200); + spectator.tick(50); // Query emits async, tick here triggers building the DOM for the query + // Break up the ticks into multiple to account for various async handoffs + spectator.tick(); + spectator.tick(100); }; - const init = (...params: Parameters) => { - spectator = createComponent(...params); + const init = (...mockProviders: Provider[]) => { + spectator = createComponent({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of(convertToParamMap({})) + } + }, + ...mockProviders + ] + }); spectator.tick(); patchRouterNavigateForTest(spectator); detectQueryChange(); @@ -123,13 +140,12 @@ describe('Explorer component', () => { }; test('fires query on init for traces', fakeAsync(() => { - init({ - providers: [ - mockProvider(GraphQlRequestService, { - query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) - }) - ] - }); + init( + mockProvider(GraphQlRequestService, { + query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) + }) + ); + // Traces tab is auto selected expect(querySpy).toHaveBeenNthCalledWith( 2, @@ -142,8 +158,10 @@ describe('Explorer component', () => { expect.objectContaining({}) ); - expect(querySpy).toHaveBeenNthCalledWith( - 3, + // RunFakeRxjs(({ expectObservable }) => { + // ExpectObservable(spectator.component.resultsDashboard$).toBe('x', { x: undefined }); + // }); + expect(querySpy).toHaveBeenCalledWith( expect.objectContaining({ requestType: TRACES_GQL_REQUEST, filters: [], @@ -154,13 +172,11 @@ describe('Explorer component', () => { })); test('fires query on filter change for traces', fakeAsync(() => { - init({ - providers: [ - mockProvider(GraphQlRequestService, { - query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) - }) - ] - }); + init( + mockProvider(GraphQlRequestService, { + query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) + }) + ); const filterBar = spectator.query(FilterBarComponent)!; // tslint:disable-next-line: no-object-literal-type-assertion @@ -202,13 +218,11 @@ describe('Explorer component', () => { })); test('fires query on init for spans', fakeAsync(() => { - init({ - providers: [ - mockProvider(GraphQlRequestService, { - query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) - }) - ] - }); + init( + mockProvider(GraphQlRequestService, { + query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) + }) + ); querySpy.mockClear(); // Select Spans tab @@ -238,13 +252,11 @@ describe('Explorer component', () => { })); test('fires query on init for traces', fakeAsync(() => { - init({ - providers: [ - mockProvider(GraphQlRequestService, { - query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) - }) - ] - }); + init( + mockProvider(GraphQlRequestService, { + query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) + }) + ); // Select traces tab spectator.click(spectator.queryAll('ht-toggle-item')[1]); detectQueryChange(); @@ -291,13 +303,11 @@ describe('Explorer component', () => { })); test('traces table fires query on series change', fakeAsync(() => { - init({ - providers: [ - mockProvider(GraphQlRequestService, { - query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) - }) - ] - }); + init( + mockProvider(GraphQlRequestService, { + query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) + }) + ); spectator.query(ExploreQueryEditorComponent)!.setSeries([buildSeries('second', MetricAggregationType.Average)]); detectQueryChange(); @@ -315,13 +325,11 @@ describe('Explorer component', () => { })); test('visualization fires query on series change', fakeAsync(() => { - init({ - providers: [ - mockProvider(GraphQlRequestService, { - query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) - }) - ] - }); + init( + mockProvider(GraphQlRequestService, { + query: jest.fn().mockReturnValueOnce(of(mockAttributes)).mockReturnValue(EMPTY) + }) + ); querySpy.mockClear(); spectator.query(ExploreQueryEditorComponent)!.setSeries([buildSeries('second', MetricAggregationType.Average)]); @@ -340,46 +348,60 @@ describe('Explorer component', () => { ); })); - test('updates URL with query param when context toggled', fakeAsync(() => { + test('updates URL with query param when query updated', fakeAsync(() => { init(); const queryParamChangeSpy = spyOn(spectator.inject(NavigationService), 'addQueryParametersToUrl'); - // Select Spans tab spectator.click(spectator.queryAll('ht-toggle-item')[1]); + spectator.query(ExploreQueryEditorComponent)!.setSeries([buildSeries('second', MetricAggregationType.Average)]); + spectator.query(ExploreQueryEditorComponent)!.setInterval(new TimeDuration(30, TimeUnit.Second)); + spectator.query(ExploreQueryEditorComponent)!.updateGroupByKey( + { + keys: ['apiName'], + limit: 6, + includeRest: true + }, + 'apiName' + ); detectQueryChange(); - expect(queryParamChangeSpy).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'spans' })); - - // Select Endpoint traces tab - spectator.click(spectator.queryAll('ht-toggle-item')[0]); - detectQueryChange(); - expect(queryParamChangeSpy).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'endpoint-traces' })); - })); - - test('selects tab based on url', fakeAsync(() => { - init({ - providers: [ - { - provide: ActivatedRoute, - useValue: { - queryParamMap: of(convertToParamMap({ scope: 'spans' })) - } - } - ] + expect(queryParamChangeSpy).toHaveBeenLastCalledWith({ + scope: 'spans', + series: ['column:avg(second)'], + group: 'apiName', + limit: 6, + other: true, + interval: '30s' }); - expect(spectator.component.context).toBe(SPAN_SCOPE); })); - test('defaults to endpoints and sets url', fakeAsync(() => { + test('sets state based on url', fakeAsync(() => { init({ - providers: [ - { - provide: ActivatedRoute, - useValue: { - queryParamMap: of(convertToParamMap({})) - } - } - ] + provide: ActivatedRoute, + useValue: { + queryParamMap: of( + convertToParamMap({ + scope: 'spans', + series: 'line:distinct_count(apiName)', + group: 'apiName', + limit: '6', + other: 'true', + interval: '30s' + }) + ) + } }); - expect(spectator.component.context).toBe(ObservabilityTraceType.Api); - expect(spectator.inject(NavigationService).getQueryParameter('scope', 'unset')).toEqual('endpoint-traces'); + expect(spectator.query(ToggleGroupComponent)?.activeItem?.label).toBe('Spans'); + expect(spectator.query(ExploreQueryGroupByEditorComponent)?.groupByKey).toBe('apiName'); + expect(spectator.query(ExploreQueryLimitEditorComponent)?.limit).toBe(6); + expect(spectator.query(ExploreQueryLimitEditorComponent)?.includeRest).toBe(true); + expect(spectator.query(ExploreQuerySeriesEditorComponent)?.series).toEqual({ + specification: expect.objectContaining({ + aggregation: MetricAggregationType.DistinctCount, + name: 'apiName' + }), + visualizationOptions: { type: CartesianSeriesVisualizationType.Line } + }); + expect(spectator.query(ExploreQueryIntervalEditorComponent)?.interval).toEqual( + new TimeDuration(30, TimeUnit.Second) + ); })); }); diff --git a/projects/observability/src/pages/explorer/explorer.component.ts b/projects/observability/src/pages/explorer/explorer.component.ts index 08a095eb7..9e77dea9e 100644 --- a/projects/observability/src/pages/explorer/explorer.component.ts +++ b/projects/observability/src/pages/explorer/explorer.component.ts @@ -1,13 +1,29 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { NavigationService } from '@hypertrace/common'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { + assertUnreachable, + NavigationService, + QueryParamObject, + TimeDuration, + TimeDurationService +} from '@hypertrace/common'; import { Filter, ToggleItem } from '@hypertrace/components'; -import { Observable, of } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; -import { ExploreVisualizationRequest } from '../../shared/components/explore-query-editor/explore-visualization-builder'; +import { isNil } from 'lodash-es'; +import { concat, EMPTY, Observable, Subject } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { CartesianSeriesVisualizationType } from '../../shared/components/cartesian/chart'; +import { + ExploreRequestState, + ExploreSeries, + ExploreVisualizationRequest +} from '../../shared/components/explore-query-editor/explore-visualization-builder'; +import { IntervalValue } from '../../shared/components/interval-select/interval-select.component'; import { AttributeMetadata } from '../../shared/graphql/model/metadata/attribute-metadata'; +import { MetricAggregationType } from '../../shared/graphql/model/metrics/metric-aggregation'; +import { GraphQlGroupBy } from '../../shared/graphql/model/schema/groupby/graphql-group-by'; import { ObservabilityTraceType } from '../../shared/graphql/model/schema/observability-traces'; import { SPAN_SCOPE } from '../../shared/graphql/model/schema/span'; +import { ExploreSpecificationBuilder } from '../../shared/graphql/request/builders/specification/explore/explore-specification-builder'; import { MetadataService } from '../../shared/services/metadata/metadata.service'; import { ExplorerDashboardBuilder, @@ -21,12 +37,12 @@ import { styleUrls: ['./explorer.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+
@@ -47,9 +63,12 @@ import {
; public readonly vizDashboard$: Observable; + public readonly initialState$: Observable; + public readonly currentContext$: Observable; + public attributes$: Observable = EMPTY; - public contextItems: ContextToggleItem[] = [ + public readonly contextItems: ContextToggleItem[] = [ { - label: ExplorerComponent.API_TRACES, + label: 'Endpoint Traces', value: { dashboardContext: ObservabilityTraceType.Api, scopeQueryParam: ScopeQueryParam.EndpointTraces } }, { - label: ExplorerComponent.SPANS, + label: 'Spans', value: { dashboardContext: SPAN_SCOPE, scopeQueryParam: ScopeQueryParam.Spans } } ]; - public activeContextItem$: Observable; - public attributes$: Observable = of([]); - public context?: ExplorerGeneratedDashboardContext; public filters: Filter[] = []; public visualizationExpanded: boolean = true; public resultsExpanded: boolean = true; + private readonly contextChangeSubject: Subject = new Subject(); + public constructor( private readonly metadataService: MetadataService, private readonly navigationService: NavigationService, + private readonly timeDurationService: TimeDurationService, @Inject(EXPLORER_DASHBOARD_BUILDER_FACTORY) explorerDashboardBuilderFactory: ExplorerDashboardBuilderFactory, activatedRoute: ActivatedRoute ) { this.explorerDashboardBuilder = explorerDashboardBuilderFactory.build(); this.resultsDashboard$ = this.explorerDashboardBuilder.resultsDashboard$; this.vizDashboard$ = this.explorerDashboardBuilder.visualizationDashboard$; - this.activeContextItem$ = activatedRoute.queryParamMap.pipe( - map(paramMap => paramMap.get(ExplorerComponent.SCOPE_QUERY_PARAM)), - map(queryParam => this.getContextItemFromValue(queryParam as ScopeQueryParam)), - tap(toggleItem => this.onContextUpdated(toggleItem?.value)) + this.initialState$ = activatedRoute.queryParamMap.pipe( + take(1), + map(paramMap => this.mapToInitialState(paramMap)) + ); + this.currentContext$ = concat( + this.initialState$.pipe(map(value => value.contextToggle.value.dashboardContext)), + this.contextChangeSubject ); } - public updateExplorer(request: ExploreVisualizationRequest): void { - this.explorerDashboardBuilder.updateForRequest(request); + public onVisualizationRequestUpdated(newRequest: ExploreVisualizationRequest): void { + this.explorerDashboardBuilder.updateForRequest(newRequest); + this.updateUrlWithVisualizationData(newRequest); } public onFiltersUpdated(newFilters: Filter[]): void { this.filters = [...newFilters]; } - private getContextItemFromValue(value: ScopeQueryParam): ContextToggleItem | undefined { - return this.contextItems.find(item => value === item.value.scopeQueryParam); + private getOrDefaultContextItemFromQueryParam(value?: ScopeQueryParam): ContextToggleItem { + return this.contextItems.find(item => value === item.value.scopeQueryParam) || this.contextItems[0]; } - public onContextUpdated(value: ExplorerContextScope = this.contextItems[0].value): void { - if (this.context !== value.dashboardContext) { - this.context = value.dashboardContext; - this.attributes$ = this.metadataService.getFilterAttributes(this.context); + private getQueryParamFromContext(context: ExplorerGeneratedDashboardContext): ScopeQueryParam { + switch (context) { + case ObservabilityTraceType.Api: + return ScopeQueryParam.EndpointTraces; + case 'SPAN': + return ScopeQueryParam.Spans; + default: + return assertUnreachable(context); } + } - // Set query param async to allow any initiating route change to complete - setTimeout(() => - this.navigationService.addQueryParametersToUrl({ - [ExplorerComponent.SCOPE_QUERY_PARAM]: value.scopeQueryParam - }) - ); + public onContextUpdated(contextWrapper: ExplorerContextScope): void { + this.attributes$ = this.metadataService.getFilterAttributes(contextWrapper.dashboardContext); + this.contextChangeSubject.next(contextWrapper.dashboardContext); } -} -interface ContextToggleItem extends ToggleItem { + private updateUrlWithVisualizationData(request: ExploreRequestState): void { + this.navigationService.addQueryParametersToUrl({ + [ExplorerQueryParam.Scope]: this.getQueryParamFromContext(request.context as ExplorerGeneratedDashboardContext), + [ExplorerQueryParam.Interval]: this.encodeInterval(request.interval), + [ExplorerQueryParam.Series]: request.series.map(series => this.encodeExploreSeries(series)), + ...this.getGroupByQueryParams(request.groupBy) + }); + } + + private getGroupByQueryParams(groupBy?: GraphQlGroupBy): QueryParamObject { + const key = groupBy?.keys[0]; + if (key === undefined) { + return {}; + } + + return { + [ExplorerQueryParam.Group]: key, + [ExplorerQueryParam.OtherGroup]: groupBy?.includeRest || undefined, // No param needed for false + [ExplorerQueryParam.GroupLimit]: groupBy?.limit + }; + } + + private mapToInitialState(param: ParamMap): InitialExplorerState { + return { + contextToggle: this.getOrDefaultContextItemFromQueryParam(param.get(ExplorerQueryParam.Scope) as ScopeQueryParam), + groupBy: param.has(ExplorerQueryParam.Group) + ? { + keys: param.getAll(ExplorerQueryParam.Group), + includeRest: param.get(ExplorerQueryParam.OtherGroup) === 'true', + // tslint:disable-next-line: strict-boolean-expressions + limit: parseInt(param.get(ExplorerQueryParam.GroupLimit)!) || 5 + } + : undefined, + interval: this.decodeInterval(param.get(ExplorerQueryParam.Interval)), + series: param.getAll(ExplorerQueryParam.Series).flatMap(series => this.tryDecodeExploreSeries(series)) + }; + } + + private encodeInterval(interval?: TimeDuration | 'AUTO'): string | undefined { + if (!interval) { + return 'NONE'; + } + if (interval === 'AUTO') { + return undefined; + } + + return interval.toString(); + } + + private decodeInterval(durationString: string | null): IntervalValue { + if (isNil(durationString)) { + return 'AUTO'; + } + if (durationString === 'NONE') { + return durationString; + } + + return this.timeDurationService.durationFromString(durationString) ?? 'AUTO'; + } + + private encodeExploreSeries(series: ExploreSeries): string { + return `${series.visualizationOptions.type}:${series.specification.aggregation}(${series.specification.name})`; + } + + private tryDecodeExploreSeries(seriesString: string): [ExploreSeries] | [] { + const matches = seriesString.match(/(\w+):(\w+)\((\w+)\)/); + if (matches?.length !== 4) { + return []; + } + + const visualizationType = matches[1] as CartesianSeriesVisualizationType; + const aggregation = matches[2] as MetricAggregationType; + const key = matches[3]; + + return [ + { + specification: new ExploreSpecificationBuilder().exploreSpecificationForKey(key, aggregation), + visualizationOptions: { + type: visualizationType + } + } + ]; + } +} +interface ContextToggleItem extends ToggleItem { value: ExplorerContextScope; } +interface InitialExplorerState { + contextToggle: ContextToggleItem; + series: ExploreSeries[]; + interval?: IntervalValue; + groupBy?: GraphQlGroupBy; +} + interface ExplorerContextScope { dashboardContext: ExplorerGeneratedDashboardContext; scopeQueryParam: ScopeQueryParam; @@ -173,3 +287,11 @@ export const enum ScopeQueryParam { EndpointTraces = 'endpoint-traces', Spans = 'spans' } +const enum ExplorerQueryParam { + Scope = 'scope', + Interval = 'interval', + Group = 'group', + OtherGroup = 'other', + GroupLimit = 'limit', + Series = 'series' +} diff --git a/projects/observability/src/pages/explorer/explorer.module.ts b/projects/observability/src/pages/explorer/explorer.module.ts index 8240ca254..820cd468d 100644 --- a/projects/observability/src/pages/explorer/explorer.module.ts +++ b/projects/observability/src/pages/explorer/explorer.module.ts @@ -1,6 +1,12 @@ import { CommonModule } from '@angular/common'; import { FactorySansProvider, ModuleWithProviders, NgModule } from '@angular/core'; -import { FilterBarModule, PageHeaderModule, PanelModule, ToggleGroupModule } from '@hypertrace/components'; +import { + FilterBarModule, + LetAsyncModule, + PageHeaderModule, + PanelModule, + ToggleGroupModule +} from '@hypertrace/components'; import { ExploreQueryEditorModule } from '../../shared/components/explore-query-editor/explore-query-editor.module'; import { ObservabilityDashboardModule } from '../../shared/dashboard/observability-dashboard.module'; import { EXPLORER_DASHBOARD_BUILDER_FACTORY } from './explorer-dashboard-builder'; @@ -14,7 +20,8 @@ import { ExplorerComponent } from './explorer.component'; ExploreQueryEditorModule, PanelModule, PageHeaderModule, - ToggleGroupModule + ToggleGroupModule, + LetAsyncModule ], declarations: [ExplorerComponent] }) diff --git a/projects/observability/src/shared/components/cartesian/chart.ts b/projects/observability/src/shared/components/cartesian/chart.ts index c2b554112..6331dcc27 100644 --- a/projects/observability/src/shared/components/cartesian/chart.ts +++ b/projects/observability/src/shared/components/cartesian/chart.ts @@ -61,11 +61,11 @@ export const enum RenderingStrategy { } export const enum CartesianSeriesVisualizationType { - Column, - Line, - DashedLine, - Scatter, - Area + Column = 'column', + Line = 'line', + DashedLine = 'dashed-line', + Scatter = 'scatter', + Area = 'area' } export const enum AxisType { diff --git a/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.test.ts b/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.test.ts index b02c58a69..984ed8188 100644 --- a/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.test.ts +++ b/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.test.ts @@ -104,7 +104,7 @@ describe('Explore query editor', () => { const expectedDefaultQuery = (): ExploreVisualizationRequest => expect.objectContaining({ - interval: new TimeDuration(15, TimeUnit.Second), + interval: 'AUTO', series: [defaultSeries] }); @@ -120,7 +120,7 @@ describe('Explore query editor', () => { } ); - spectator.tick(); + spectator.tick(10); expect(onRequestChange).toHaveBeenCalledWith(expectedDefaultQuery()); @@ -140,16 +140,16 @@ describe('Explore query editor', () => { } } ); - spectator.tick(); - + spectator.tick(10); spectator.click('.add-series-button'); - spectator.tick(); + spectator.tick(10); expect(onRequestChange).toHaveBeenCalledWith( expect.objectContaining({ series: [defaultSeries, defaultSeries] }) ); + discardPeriodicTasks(); })); test('emits changes to the query on group by change', fakeAsync(() => { @@ -164,13 +164,13 @@ describe('Explore query editor', () => { } } ); - spectator.tick(); + spectator.tick(10); spectator.click(spectator.query('.group-by .trigger-content')!); const options = spectator.queryAll('.select-option', { root: true }); spectator.click(options[1]); - spectator.tick(); + spectator.tick(10); expect(onRequestChange).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -197,19 +197,19 @@ describe('Explore query editor', () => { } } ); - spectator.tick(); + spectator.tick(10); // First pick a group by to enable limit selection spectator.click(spectator.query('.group-by .trigger-content')!); const options = spectator.queryAll('.select-option', { root: true }); spectator.click(options[1]); - spectator.tick(); + spectator.tick(10); const limitInputEl = spectator.query('ht-explore-query-limit-editor input') as HTMLInputElement; limitInputEl.value = '6'; spectator.dispatchFakeEvent(limitInputEl, 'input'); - spectator.tick(); + spectator.tick(10); expect(onQueryChange).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -235,13 +235,13 @@ describe('Explore query editor', () => { } } ); - spectator.tick(); + spectator.tick(10); spectator.click(spectator.query('.interval .trigger-content')!); const options = spectator.queryAll('.select-option', { root: true }); spectator.click(options[0]); - spectator.tick(); + spectator.tick(10); expect(onRequestChange).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -264,17 +264,19 @@ describe('Explore query editor', () => { } } ); - spectator.tick(); + spectator.tick(10); // First pick a group by to enable limit selection spectator.click(spectator.query('.group-by .trigger-content')!); const options = spectator.queryAll('.select-option', { root: true }); spectator.click(options[1]); - spectator.tick(); + spectator.tick(10); spectator.click('.limit-include-rest-container input[type="checkbox"]'); + spectator.tick(10); + expect(onRequestChange).toHaveBeenLastCalledWith( expect.objectContaining({ groupBy: expect.objectContaining({ diff --git a/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.ts b/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.ts index 59a624c2e..cca51a6fd 100644 --- a/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.ts +++ b/projects/observability/src/shared/components/explore-query-editor/explore-query-editor.component.ts @@ -64,6 +64,15 @@ export class ExploreQueryEditorComponent implements OnChanges, OnInit { @Input() public context?: ExploreRequestContext; + @Input() + public series?: ExploreSeries[]; + + @Input() + public interval?: IntervalValue; + + @Input() + public groupBy?: GraphQlGroupBy; + @Output() public readonly visualizationRequestChange: EventEmitter = new EventEmitter(); @@ -85,6 +94,18 @@ export class ExploreQueryEditorComponent implements OnChanges, OnInit { if (changeObject.filters) { this.visualizationBuilder.filters(this.filters); } + + if (changeObject.series && this.series?.length) { + this.setSeries(this.series); + } + + if (changeObject.interval && this.interval) { + this.setInterval(this.interval); + } + + if (changeObject.groupBy && this.groupBy?.keys.length) { + this.updateGroupByKey(this.groupBy, this.groupBy.keys[0]); + } } public setSeries(series: ExploreSeries[]): void { diff --git a/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.test.ts b/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.test.ts index 993505a08..f71afa668 100644 --- a/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.test.ts +++ b/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.test.ts @@ -44,7 +44,7 @@ describe('Explore visualization builder', () => { const expectedQuery = (queryPartial: Partial = {}): ExploreVisualizationRequest => expect.objectContaining({ context: ObservabilityTraceType.Api, - interval: new TimeDuration(3, TimeUnit.Minute), + interval: 'AUTO', series: [matchSeriesWithName('calls')], ...queryPartial }); @@ -65,19 +65,19 @@ describe('Explore visualization builder', () => { test('defaults to single series query', () => { runFakeRxjs(({ expectObservable }) => { - expectObservable(spectator.service.visualizationRequest$).toBe('x', { x: expectedQuery() }); + expectObservable(spectator.service.visualizationRequest$).toBe('10ms x', { x: expectedQuery() }); }); }); test('plays back current query for late subscribers', fakeAsync(() => { runFakeRxjs(({ expectObservable }) => { - expectObservable(spectator.service.visualizationRequest$).toBe('x', { x: expectedQuery() }); + expectObservable(spectator.service.visualizationRequest$).toBe('10ms x', { x: expectedQuery() }); tick(10000); - expectObservable(spectator.service.visualizationRequest$).toBe('x', { x: expectedQuery() }); + expectObservable(spectator.service.visualizationRequest$).toBe('10ms x', { x: expectedQuery() }); }); })); - test('notifies on query change', () => { + test('debounces then notifies on query change', () => { runFakeRxjs(({ expectObservable }) => { const recordedRequests = recordObservable(spectator.service.visualizationRequest$); @@ -89,16 +89,8 @@ describe('Explore visualization builder', () => { }) .setSeries([buildSeries('test2')]); - expectObservable(recordedRequests).toBe('(abcd)', { - a: expectedQuery(), - b: expectedQuery({ - series: [matchSeriesWithName('test1')] - }), - c: expectedQuery({ - series: [matchSeriesWithName('test1')], - groupBy: { keys: ['testGroupBy'], limit: 15 } - }), - d: expectedQuery({ + expectObservable(recordedRequests).toBe('10ms x', { + x: expectedQuery({ series: [matchSeriesWithName('test2')], groupBy: { keys: ['testGroupBy'], limit: 15 } }) @@ -106,27 +98,13 @@ describe('Explore visualization builder', () => { }); }); - test('clears full query on empty', () => { - runFakeRxjs(({ expectObservable }) => { - const recordedRequests = recordObservable(spectator.service.visualizationRequest$); - spectator.service.setSeries([buildSeries('test1')]).empty(); - - expectObservable(recordedRequests).toBe('(xyx)', { - x: expectedQuery(), - y: expectedQuery({ series: [matchSeriesWithName('test1')] }) - }); - }); - }); - test('overwrites query with new state on reset', () => { runFakeRxjs(({ expectObservable }) => { const recordedRequests = recordObservable(spectator.service.visualizationRequest$); spectator.service.setSeries([buildSeries('test1')]).reset(); - expectObservable(recordedRequests).toBe('(xyz)', { - x: expectedQuery(), - y: expectedQuery({ series: [matchSeriesWithName('test1')] }), - z: expectedQuery() + expectObservable(recordedRequests).toBe('10ms x', { + x: expectedQuery() }); }); }); diff --git a/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.ts b/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.ts index 72629145e..d1e9ff789 100644 --- a/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.ts +++ b/projects/observability/src/shared/components/explore-query-editor/explore-visualization-builder.ts @@ -3,7 +3,7 @@ import { forkJoinSafeEmpty, IntervalDurationService, TimeDuration } from '@hyper import { Filter } from '@hypertrace/components'; import { uniqBy } from 'lodash-es'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; -import { defaultIfEmpty, map, takeUntil } from 'rxjs/operators'; +import { debounceTime, defaultIfEmpty, map, shareReplay, takeUntil } from 'rxjs/operators'; import { AttributeMetadata } from '../../graphql/model/metadata/attribute-metadata'; import { MetricAggregationType } from '../../graphql/model/metrics/metric-aggregation'; import { GraphQlGroupBy } from '../../graphql/model/schema/groupby/graphql-group-by'; @@ -45,8 +45,10 @@ export class ExploreVisualizationBuilder implements OnDestroy { this.queryStateSubject = new BehaviorSubject(this.buildDefaultRequest()); // Todo: Revisit first request without knowing the context this.visualizationRequest$ = this.queryStateSubject.pipe( + debounceTime(10), map(requestState => this.buildRequest(requestState)), - takeUntil(this.destroyed$) + takeUntil(this.destroyed$), + shareReplay(1) ); } @@ -55,10 +57,6 @@ export class ExploreVisualizationBuilder implements OnDestroy { this.destroyed$.complete(); } - public empty(): this { - return this.reset(); - } - public reset(): this { this.queryStateSubject.next(this.buildDefaultRequest()); @@ -117,7 +115,7 @@ export class ExploreVisualizationBuilder implements OnDestroy { resultLimit: state.resultLimit, series: [...state.series], filters: state.filters && [...state.filters], - interval: this.resolveInterval(state.interval), + interval: state.interval, groupBy: state.groupBy && { ...state.groupBy }, exploreQuery$: this.mapStateToExploreQuery(state), resultsQuery$: this.mapStateToResultsQuery(state)