diff --git a/src/Components/IndexScene/LevelsVariableScene.tsx b/src/Components/IndexScene/LevelsVariableScene.tsx index 0fb02464..41203d51 100644 --- a/src/Components/IndexScene/LevelsVariableScene.tsx +++ b/src/Components/IndexScene/LevelsVariableScene.tsx @@ -133,7 +133,7 @@ export class LevelsVariableScene extends SceneObjectBase v.selected)} options={options?.map((val) => ({ - value: val.text, + value: val.value, label: val.text, }))} /> diff --git a/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.test.tsx b/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.test.tsx index c6af80e2..50beb34b 100644 --- a/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.test.tsx +++ b/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.test.tsx @@ -183,7 +183,7 @@ describe('addToFilters and addAdHocFilter', () => { it('allows to clear a filter', () => { const lookupVariable = jest.spyOn(sceneGraph, 'lookupVariable').mockReturnValue(adHocVariable); jest.spyOn(sceneGraph, 'getAncestor').mockReturnValue(serviceScene); - addToFilters('existing', 'existingValue', 'clear', scene); + addToFilters('existing', 'existingValue', 'clear', scene, VAR_FIELDS); expect(lookupVariable).toHaveBeenCalledWith(VAR_FIELDS_AND_METADATA, expect.anything()); expect(adHocVariable.state.filters).toEqual([]); }); @@ -231,7 +231,7 @@ describe('addToFilters and addAdHocFilter', () => { it.each(['=', '!='])('allows to add an %s filter', (operator: string) => { const lookupVariable = jest.spyOn(sceneGraph, 'lookupVariable').mockReturnValue(adHocVariable); jest.spyOn(sceneGraph, 'getAncestor').mockReturnValue(serviceScene); - addAdHocFilter({ key: 'key', value: 'value', operator }, scene); + addAdHocFilter({ key: 'key', value: 'value', operator }, scene, VAR_FIELDS); expect(lookupVariable).toHaveBeenCalledWith(VAR_FIELDS_AND_METADATA, expect.anything()); expect(adHocVariable.state.filters).toEqual([ diff --git a/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.tsx b/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.tsx index b2bf683f..6677aab3 100644 --- a/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.tsx +++ b/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.tsx @@ -32,6 +32,7 @@ import { FilterOp, NumericFilterOp } from '../../../services/filterTypes'; import { addToFavorites } from '../../../services/favorites'; import { areArraysEqual } from '../../../services/comparison'; import { logger } from '../../../services/logger'; +import { isFilterMetadata } from '../../../services/filters'; export interface AddToFiltersButtonState extends SceneObjectState { frame: DataFrame; @@ -70,7 +71,7 @@ export type NumericFilterType = NumericFilterOp.gt | NumericFilterOp.gte | Numer */ export type FilterType = 'include' | 'clear' | 'exclude' | 'toggle'; -export function addAdHocFilter(filter: AdHocVariableFilter, scene: SceneObject, variableType?: InterpolatedFilterType) { +export function addAdHocFilter(filter: AdHocVariableFilter, scene: SceneObject, variableType: InterpolatedFilterType) { const type: FilterType = filter.operator === '=' ? 'include' : 'exclude'; addToFilters(filter.key, filter.value, type, scene, variableType); } @@ -82,18 +83,14 @@ export type AdHocFilterTypes = InterpolatedFilterType | typeof VAR_LABELS_REPLIC export function clearFilters( key: string, scene: SceneObject, - variableType?: InterpolatedFilterType, + variableType: InterpolatedFilterType, value?: string, operator?: FilterType ) { - if (!variableType) { - variableType = resolveVariableTypeForField(key, scene); - } - const variable = getUIAdHocVariable(variableType, key, scene); let filters = variable.state.filters.filter((filter) => { - const fieldValue = getValueFromAdHocVariableFilter(variable, filter); + const fieldValue = getValueFromAdHocVariableFilter(variableType, filter); if (value && operator) { return !(filter.key === key && fieldValue.value === value && filter.operator === operator); } @@ -200,12 +197,8 @@ export function addToFilters( value: string, operator: FilterType, scene: SceneObject, - variableType?: InterpolatedFilterType + variableType: InterpolatedFilterType ) { - if (!variableType) { - variableType = resolveVariableTypeForField(key, scene); - } - if (variableType === VAR_LABELS) { addToFavorites(key, value, scene); } @@ -213,16 +206,19 @@ export function addToFilters( const variable = getUIAdHocVariable(variableType, key, scene); let valueObject: string | undefined = undefined; + let valueLabel = value; if (variableType === VAR_FIELDS) { valueObject = JSON.stringify({ value, parser: getParserForField(key, scene), }); + } else if (variableType === VAR_LEVELS && operator === 'exclude') { + valueLabel = `!${value}`; } // If the filter exists, filter it let filters = variable.state.filters.filter((filter) => { - const fieldValue = getValueFromAdHocVariableFilter(variable, filter); + const fieldValue = getValueFromAdHocVariableFilter(variableType, filter); // if we're including, we want to remove all filters that have this key if (operator === 'include') { @@ -244,7 +240,7 @@ export function addToFilters( key, operator: operator === 'exclude' ? FilterOp.NotEqual : FilterOp.Equal, value: valueObject ? valueObject : value, - valueLabels: [value], + valueLabels: [valueLabel], }, ]; } @@ -326,7 +322,8 @@ export class AddToFiltersButton extends SceneObjectBase // Check if the filter is already there const filterInSelectedFilters = variable.state.filters.find((f) => { - const value = getValueFromAdHocVariableFilter(variable, f); + const isMetadata = isFilterMetadata(filter); + const value = getValueFromAdHocVariableFilter(isMetadata ? VAR_METADATA : VAR_FIELDS, f); return f.key === filter.name && value.value === filter.value; }); diff --git a/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx index 22e335ac..0f0e8877 100644 --- a/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx @@ -11,34 +11,47 @@ import { SceneObject, SceneObjectBase, SceneObjectState, + SceneQueryRunner, SceneReactObject, } from '@grafana/scenes'; -import { buildDataQuery } from '../../../services/query'; +import { buildDataQuery, renderLogQLFieldFilters, renderLogQLMetadataFilters } from '../../../services/query'; import { getSortByPreference } from '../../../services/store'; import { DataQueryError, LoadingState } from '@grafana/data'; import { LayoutSwitcher } from './LayoutSwitcher'; import { getQueryRunner } from '../../../services/panel'; import { ByFrameRepeater } from './ByFrameRepeater'; import { Alert, DrawStyle, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; -import { buildFieldsQueryString, getFilterBreakdownValueScene, getParserForField } from '../../../services/fields'; +import { + buildFieldsQueryString, + getFilterBreakdownValueScene, + getParserForField, + getParserFromFieldsFilters, +} from '../../../services/fields'; import { getLabelValue } from './SortByScene'; -import { VAR_FIELDS, VAR_METADATA } from '../../../services/variables'; +import { ParserType, VAR_FIELDS, VAR_METADATA } from '../../../services/variables'; import React from 'react'; import { FIELDS_BREAKDOWN_GRID_TEMPLATE_COLUMNS, FieldsBreakdownScene } from './FieldsBreakdownScene'; -import { AddFilterEvent } from './AddToFiltersButton'; -import { navigateToDrilldownPage } from '../../../services/navigate'; -import { PageSlugs } from '../../../services/routing'; -import { getDetectedFieldsFrame, ServiceScene } from '../ServiceScene'; +import { getDetectedFieldsFrame } from '../ServiceScene'; import { DEFAULT_SORT_BY } from '../../../services/sorting'; -import { getFieldGroupByVariable, getFieldsVariable } from '../../../services/variableGetters'; +import { + getFieldGroupByVariable, + getFieldsVariable, + getLabelsVariable, + getLevelsVariable, + getLineFiltersVariable, + getMetadataVariable, + getPatternsVariable, +} from '../../../services/variableGetters'; import { LokiQuery } from '../../../services/lokiQuery'; import { getPanelWrapperStyles, PanelMenu } from '../../Panels/PanelMenu'; import { ValueSummaryPanelScene } from './Panels/ValueSummary'; +import { areArraysEqual } from '../../../services/comparison'; +import { logger } from '../../../services/logger'; export interface FieldValuesBreakdownSceneState extends SceneObjectState { body?: (LayoutSwitcher & SceneObject) | (SceneReactObject & SceneObject); $data?: SceneDataProvider; - lastFilterEvent?: AddFilterEvent; + parser?: ParserType; } export class FieldValuesBreakdownScene extends SceneObjectBase { @@ -66,55 +79,240 @@ export class FieldValuesBreakdownScene extends SceneObjectBase; }; - onActivate() { + private getTagKey() { const groupByVariable = getFieldGroupByVariable(this); - const tagKey = String(groupByVariable.state.value); + return String(groupByVariable.state.value); + } - const fieldsVariable = getFieldsVariable(this); - const detectedFieldsFrame = getDetectedFieldsFrame(this); - const queryString = buildFieldsQueryString(tagKey, fieldsVariable, detectedFieldsFrame); - const query = buildDataQuery(queryString, { legendFormat: `{{${tagKey}}}`, refId: tagKey }); + onActivate() { + const query = this.buildQuery(); + const parser = this.getQueryParser(); + // Set query runner this.setState({ body: this.build(query), + parser, $data: new SceneDataTransformer({ - $data: getQueryRunner([query]), + $data: getQueryRunner([query], { runQueriesMode: 'manual' }), transformations: [], }), }); + // Subscribe to data query changes this._subs.add( - this.subscribeToEvent(AddFilterEvent, (event) => { - this.setState({ - lastFilterEvent: event, - }); + this.state.$data?.subscribeToState((newState) => { + this.onValuesDataQueryChange(newState, query); + }) + ); + + this.runQuery(); + this.setSubscriptions(); + } + + /** + * Builds the LokiQuery for the value breakdown + */ + private buildQuery() { + const tagKey = this.getTagKey(); + const fieldsVariable = getFieldsVariable(this); + const detectedFieldsFrame = getDetectedFieldsFrame(this); + const queryString = buildFieldsQueryString(tagKey, fieldsVariable, detectedFieldsFrame); + return buildDataQuery(queryString, { legendFormat: `{{${tagKey}}}`, refId: tagKey }); + } + + /** + * Sets activation subscriptions + */ + private setSubscriptions() { + // Subscribe to time range changes + this._subs.add( + sceneGraph.getTimeRange(this).subscribeToState(() => { + // Run query on time range change + this.runQuery(); }) ); + // VARIABLE SUBS + // Subscribe to line filter changes this._subs.add( - this.state.$data?.subscribeToState((newState) => { - this.onValuesDataQueryChange(newState, query); + getLineFiltersVariable(this).subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.runQuery(); + } }) ); + + // Subscribe to pattern filter changes + this._subs.add( + getPatternsVariable(this).subscribeToState((newState, prevState) => { + if (newState.value !== prevState.value) { + this.runQuery(); + } + }) + ); + + // Subscribe to labels variable changes + this._subs.add( + getLabelsVariable(this).subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.runQuery(); + } + }) + ); + + // Subscribe to levels variable changes + this._subs.add( + getLevelsVariable(this).subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.runQuery(); + } + }) + ); + + const { parser } = this.getParserForThisField(); + + if (parser !== 'structuredMetadata') { + this.setFieldParserSubscriptions(); + } else { + this.setMetadataParserSubscriptions(); + } } - private onValuesDataQueryChange(newState: SceneDataState, query: LokiQuery) { - if (newState.data?.state === LoadingState.Done) { - // No panels for the user to select, presumably because everything has been excluded - const event = this.state.lastFilterEvent; + /** + * Subscribe to variables for metadata breakdowns + */ + private setMetadataParserSubscriptions() { + // Subscribe to any fields change and run the query without change + this._subs.add( + getFieldsVariable(this).subscribeToState(async (newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.checkParser(); + this.runQuery(); + } + }) + ); + + getMetadataVariable(this).subscribeToState(async (newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + const key = this.getTagKey(); + // Check to see if excluding this label changes the query string before running + // If the filter change was for the label we're looking at, there's no need to re-run the query + const prevFilterExpression = renderLogQLMetadataFilters(prevState.filters, [key]); + const newFilterExpression = renderLogQLMetadataFilters(newState.filters, [key]); - // @todo discuss: Do we want to let users exclude all fields? Or should we redirect when excluding the penultimate panel? - if (newState.data?.state === LoadingState.Done && event) { - if (event.operator === 'exclude' && newState.data.series.length < 1) { - this.navigateToFields(); + if (newFilterExpression !== prevFilterExpression) { + this.checkParser(); + this.runQuery(); } + } + }); + } - // @todo discuss: wouldn't include always return in 1 result? Do we need to wait for the query to run or should we navigate on receiving the include event and cancel the ongoing query? - if (event.operator === 'include' && newState.data.series.length <= 1) { - this.navigateToFields(); + /** + * Subscribe to variables for field breakdowns + */ + private setFieldParserSubscriptions() { + // Subscribe to any metadata change and run the query without alteration + this._subs.add( + getMetadataVariable(this).subscribeToState(async (newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.checkParser(); + this.runQuery(); } + }) + ); + // Subscribe to fields variable, run the query if the change wasn't for this label + this._subs.add( + getFieldsVariable(this).subscribeToState(async (newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + const key = this.getTagKey(); + // Check to see if excluding this label changes the query string before running + // If the filter change was for the label we're looking at, there's no need to re-run the query + const prevFilterExpression = renderLogQLFieldFilters(prevState.filters, [key]); + const newFilterExpression = renderLogQLFieldFilters(newState.filters, [key]); + + if (newFilterExpression !== prevFilterExpression) { + this.checkParser(); + this.runQuery(); + } + } + }) + ); + } + + /** + * Check to see if the parser has changed + * If so update the query with the new parser and set the parser to state. + */ + private checkParser() { + const parser = this.getQueryParser(); + if (parser !== this.state.parser) { + const query = this.buildQuery(); + this.getSceneQueryRunner()?.setState({ + queries: [query], + }); + // Set the parser to state so we can update the query next time it changes + this.setState({ + parser, + }); + } + } + + /** + * Run the field values breakdown query. + * Generates the filterExpression excluding all filters with a key that matches the label. + */ + private runQuery() { + // Update the filters to exclude the current value so all options are displayed to the user + this.removeFieldLabelFromVariableInterpolation(); + const queryRunner = this.getSceneQueryRunner(); + queryRunner?.runQueries(); + } + + /** + * Returns the query runner + */ + private getSceneQueryRunner() { + if (this.state.$data) { + const queryRunners = sceneGraph.findDescendents(this.state.$data, SceneQueryRunner); + if (queryRunners.length !== 1) { + const error = new Error('Unable to find query runner in value breakdown!'); + logger.error(error, { msg: 'FieldValuesBreakdownScene: Unable to find query runner in value breakdown!' }); + throw error; } + return queryRunners[0]; + } + logger.warn('FieldValuesBreakdownScene: Query is attempting to execute, but query runner is undefined!'); + return undefined; + } + + /** + * Sets the expression builder to exclude the current field label + */ + private removeFieldLabelFromVariableInterpolation() { + const tagKey = this.getTagKey(); + + // We want the parser for this field, we only need to exclude keys for the variable type that matches this value breakdown + const parser = this.getQueryParser(); + if (parser === 'structuredMetadata') { + const metadataVar = getMetadataVariable(this); + metadataVar.setState({ + expressionBuilder: (f) => renderLogQLMetadataFilters(f, [tagKey]), + }); + } else { + const fieldsVar = getFieldsVariable(this); + fieldsVar.setState({ + expressionBuilder: (f) => renderLogQLFieldFilters(f, [tagKey]), + }); + } + } + + /** + * Actions to run when the value breakdown query response is received. + */ + private onValuesDataQueryChange(newState: SceneDataState, query: LokiQuery) { + if (newState.data?.state === LoadingState.Done) { if (this.state.body instanceof SceneReactObject) { this.setState({ body: this.build(query), @@ -126,6 +324,9 @@ export class FieldValuesBreakdownScene extends SceneObjectBase fieldsBreakdownScene.state.search.state.filter ?? ''; - const parserForThisField = getParserForField(optionValue, this); - return new LayoutSwitcher({ options: [ { value: 'single', label: 'Single' }, @@ -208,7 +400,7 @@ export class FieldValuesBreakdownScene extends SceneObjectBase, }), - new ValueSummaryPanelScene({ title: optionValue }), + new ValueSummaryPanelScene({ title: optionValue, type: 'field', tagKey: this.getTagKey() }), new SceneReactObject({ reactNode: , }), @@ -228,7 +420,7 @@ export class FieldValuesBreakdownScene extends SceneObjectBase, }), - new ValueSummaryPanelScene({ title: optionValue }), + new ValueSummaryPanelScene({ title: optionValue, type: 'field', tagKey: this.getTagKey() }), new SceneReactObject({ reactNode: , }), @@ -266,7 +458,7 @@ export class FieldValuesBreakdownScene extends SceneObjectBase { @@ -72,23 +75,30 @@ export class LabelValuesBreakdownScene extends SceneObjectBase { + if (!areArraysEqual(newState.filters, prevState.filters)) { + // Check to see if the new field filter changes the parser, if so rebuild the query + this.checkParser(); + this.runQuery(); + } + }) + ); + // Subscribe to fields variable changes this._subs.add( - getFieldsAndMetadataVariable(this).subscribeToState((newState, prevState) => { + getMetadataVariable(this).subscribeToState((newState, prevState) => { if (!areArraysEqual(newState.filters, prevState.filters)) { this.runQuery(); } @@ -194,6 +214,22 @@ export class LabelValuesBreakdownScene extends SceneObjectBase renderLevelsFilter(f, [tagKey]), }); } else { const labelsVar = getLabelsVariable(this); - filterExpression = renderLogQLLabelFilters(labelsVar.state.filters, [tagKey]); labelsVar.setState({ - filterExpression, + expressionBuilder: (f) => renderLogQLLabelFilters(f, [tagKey]), }); } @@ -260,6 +291,9 @@ export class LabelValuesBreakdownScene extends SceneObjectBase 0 && !(this.state.body instanceof LayoutSwitcher)) { @@ -309,6 +349,9 @@ export class LabelValuesBreakdownScene extends SceneObjectBase }), - new ValueSummaryPanelScene({ title: tagKey, levelColor: true, tagKey: this.getTagKey() }), + new ValueSummaryPanelScene({ title: tagKey, levelColor: true, tagKey: this.getTagKey(), type: 'label' }), new SceneReactObject({ reactNode: }), new ByFrameRepeater({ body: new SceneCSSGridLayout({ @@ -412,7 +461,7 @@ export class LabelValuesBreakdownScene extends SceneObjectBase }), - new ValueSummaryPanelScene({ title: tagKey, levelColor: true, tagKey: this.getTagKey() }), + new ValueSummaryPanelScene({ title: tagKey, levelColor: true, tagKey: this.getTagKey(), type: 'label' }), new SceneReactObject({ reactNode: }), new ByFrameRepeater({ body: new SceneCSSGridLayout({ diff --git a/src/Components/ServiceScene/Breakdowns/Panels/ValueSummary.tsx b/src/Components/ServiceScene/Breakdowns/Panels/ValueSummary.tsx index 4eddef7e..1d99aac8 100644 --- a/src/Components/ServiceScene/Breakdowns/Panels/ValueSummary.tsx +++ b/src/Components/ServiceScene/Breakdowns/Panels/ValueSummary.tsx @@ -1,4 +1,5 @@ import { + AdHocFiltersVariable, PanelBuilders, SceneComponentProps, SceneDataProvider, @@ -13,20 +14,25 @@ import { CollapsablePanelText, PanelMenu } from '../../../Panels/PanelMenu'; import { DrawStyle, PanelContext, SeriesVisibilityChangeMode, StackingMode } from '@grafana/ui'; import { setLevelColorOverrides, + syncFieldsValueSummaryVisibleSeries, syncLabelsValueSummaryVisibleSeries, syncLevelsVisibleSeries, } from '../../../../services/panel'; import { getPanelOption, setPanelOption } from '../../../../services/store'; import React from 'react'; -import { getLabelsVariable, getLevelsVariable } from '../../../../services/variableGetters'; +import { + getFieldsVariable, + getLabelsVariable, + getLevelsVariable, + getMetadataVariable, +} from '../../../../services/variableGetters'; import { toggleLevelFromFilter } from '../../../../services/levels'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../../../services/analytics'; import { DataFrame, LoadingState } from '@grafana/data'; -import { areArraysEqual } from '../../../../services/comparison'; import { LEVEL_VARIABLE_VALUE } from '../../../../services/variables'; import { logger } from '../../../../services/logger'; import { FilterType } from '../AddToFiltersButton'; -import { toggleLabelFromFilter } from '../../../../services/labels'; +import { toggleFieldFromFilter, toggleLabelFromFilter } from '../../../../services/labels'; const SUMMARY_PANEL_SERIES_LIMIT = 100; @@ -34,8 +40,8 @@ interface ValueSummaryPanelSceneState extends SceneObjectState { body?: SceneFlexLayout; title: string; levelColor?: boolean; - // @todo make required after adding field support - tagKey?: string; + tagKey: string; + type: 'field' | 'label'; } export class ValueSummaryPanelScene extends SceneObjectBase { constructor(state: ValueSummaryPanelSceneState) { @@ -63,13 +69,9 @@ export class ValueSummaryPanelScene extends SceneObjectBase this.extendTimeSeriesLegendBus(context, key), - }); - } + viz.setState({ + extendPanelContext: (_, context) => this.extendTimeSeriesLegendBus(context), + }); this.setState({ body: new SceneFlexLayout({ @@ -106,11 +108,10 @@ export class ValueSummaryPanelScene extends SceneObjectBase { + private extendTimeSeriesLegendBus = (context: PanelContext) => { const $data = sceneGraph.getData(this); const dataFrame = $data.state.data?.series; - // @todo after fields support - // const key = this.state.tagKey + const key = this.state.tagKey; const sceneFlexItem = this.state.body?.state.children[0]; if (!(sceneFlexItem instanceof SceneFlexItem)) { @@ -123,15 +124,26 @@ export class ValueSummaryPanelScene extends SceneObjectBase { let action: FilterType; - if (key === LEVEL_VARIABLE_VALUE) { - action = toggleLevelFromFilter(value, this); + if (this.state.type === 'label') { + if (key === LEVEL_VARIABLE_VALUE) { + action = toggleLevelFromFilter(value, this); + } else { + action = toggleLabelFromFilter(key, value, this); + } } else { - action = toggleLabelFromFilter(key, value, this); + action = toggleFieldFromFilter(key, value, this); } reportAppInteraction( @@ -150,10 +162,14 @@ export class ValueSummaryPanelScene extends SceneObjectBase) { if (dataFrame) { - if (key === LEVEL_VARIABLE_VALUE) { - syncLevelsVisibleSeries(panel, dataFrame, this); + if (this.state.type === 'label') { + if (key === LEVEL_VARIABLE_VALUE) { + syncLevelsVisibleSeries(panel, dataFrame, this); + } else { + syncLabelsValueSummaryVisibleSeries(key, panel, dataFrame, this); + } } else { - syncLabelsValueSummaryVisibleSeries(key, panel, dataFrame, this); + syncFieldsValueSummaryVisibleSeries(key, panel, dataFrame, this); } } } @@ -164,19 +180,44 @@ export class ValueSummaryPanelScene extends SceneObjectBase) { return $data.subscribeToState((newState, prevState) => { if (newState.data?.state === LoadingState.Done) { - if (!areArraysEqual(newState.data.series, prevState.data?.series)) { + if (this.state.type === 'label') { if (key === LEVEL_VARIABLE_VALUE) { syncLevelsVisibleSeries(panel, newState.data.series, this); } else { syncLabelsValueSummaryVisibleSeries(key, panel, newState.data.series, this); } + } else { + syncFieldsValueSummaryVisibleSeries(key, panel, newState.data.series, this); } } }); } + private getFieldsVariableLegendSyncSubscription(key: string, variable: AdHocFiltersVariable) { + return variable?.subscribeToState(() => { + const sceneFlexItem = this.state.body?.state.children[0]; + if (!(sceneFlexItem instanceof SceneFlexItem)) { + throw new Error('Cannot find sceneFlexItem'); + } + const panel = sceneFlexItem.state.body; + if (!(panel instanceof VizPanel)) { + throw new Error('ValueSummary - getFieldsVariableLegendSyncSubscription: Cannot find VizPanel'); + } + + const $data = sceneGraph.getData(this); + const dataFrame = $data.state.data?.series; + + if (!dataFrame) { + logger.warn('ValueSummary - getFieldsVariableLegendSyncSubscription: missing dataframe!'); + return; + } + + syncFieldsValueSummaryVisibleSeries(key, panel, dataFrame, this); + }); + } + /** - * Returns value subscription + * Returns value subscription for labels */ private getLabelsVariableLegendSyncSubscription(key: string) { const isLevel = key === LEVEL_VARIABLE_VALUE; diff --git a/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx b/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx index 3a123f4d..fe187826 100644 --- a/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx @@ -57,7 +57,7 @@ export class SelectLabelActionScene extends SceneObjectBase(null); const filterButtonDisabled = diff --git a/src/Components/ServiceScene/LogsPanelScene.tsx b/src/Components/ServiceScene/LogsPanelScene.tsx index e691c076..f5391e50 100644 --- a/src/Components/ServiceScene/LogsPanelScene.tsx +++ b/src/Components/ServiceScene/LogsPanelScene.tsx @@ -17,7 +17,7 @@ import { LogsListScene } from './LogsListScene'; import { LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { addToFilters, FilterType } from './Breakdowns/AddToFiltersButton'; import { getVariableForLabel } from '../../services/fields'; -import { VAR_FIELDS, VAR_LABELS, VAR_LEVELS, VAR_METADATA } from '../../services/variables'; +import { LEVEL_VARIABLE_VALUE, VAR_FIELDS, VAR_LABELS, VAR_LEVELS, VAR_METADATA } from '../../services/variables'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../services/analytics'; import { getAdHocFiltersVariable, @@ -39,6 +39,7 @@ import { LogsSortOrder } from '@grafana/schema'; import { getPrettyQueryExpr } from 'services/scenes'; import { LogsPanelError } from './LogsPanelError'; import { clearVariables } from 'services/variableHelpers'; +import { LevelsVariableScene } from '../IndexScene/LevelsVariableScene'; interface LogsPanelSceneState extends SceneObjectState { body?: VizPanel; @@ -418,9 +419,15 @@ export class LogsPanelScene extends SceneObjectBase { private handleLabelFilter(key: string, value: string, frame: DataFrame | undefined, operator: FilterType) { const variableType = getVariableForLabel(frame, key, this); - addToFilters(key, value, operator, this, variableType); + if (key === LEVEL_VARIABLE_VALUE) { + const levelsVariableScene = sceneGraph.findObject(this, (obj) => obj instanceof LevelsVariableScene); + if (levelsVariableScene instanceof LevelsVariableScene) { + levelsVariableScene.onFilterChange(); + } + } + reportAppInteraction( USER_EVENTS_PAGES.service_details, USER_EVENTS_ACTIONS.service_details.logs_detail_filter_applied, diff --git a/src/Components/ServiceScene/ServiceScene.tsx b/src/Components/ServiceScene/ServiceScene.tsx index a93f8e0e..e888b7c0 100644 --- a/src/Components/ServiceScene/ServiceScene.tsx +++ b/src/Components/ServiceScene/ServiceScene.tsx @@ -18,7 +18,14 @@ import { } from '@grafana/scenes'; import { LoadingPlaceholder } from '@grafana/ui'; import { getQueryRunner, getResourceQueryRunner } from 'services/panel'; -import { buildDataQuery, buildResourceQuery } from 'services/query'; +import { + buildDataQuery, + buildResourceQuery, + renderLevelsFilter, + renderLogQLFieldFilters, + renderLogQLLabelFilters, + renderLogQLMetadataFilters, +} from 'services/query'; import { EMPTY_VARIABLE_VALUE, isAdHocFilterValueUserInput, @@ -299,6 +306,7 @@ export class ServiceScene extends SceneObjectBase { this.showVariables(); this.getMetadata(); this.resetBodyAndData(); + this.resetVariableKeyExclusion(); this.setBreakdownView(); @@ -523,6 +531,33 @@ export class ServiceScene extends SceneObjectBase { }); } + /** + * Reset expression builders + * If we're in a scene that requires excluding keys from the expression, they will be set in a child scene which is activated after this scene. + * Otherwise, when navigating to a cached scene we could run incorrect queries + */ + private resetVariableKeyExclusion() { + const metadataVar = getMetadataVariable(this); + metadataVar.setState({ + expressionBuilder: (f) => renderLogQLMetadataFilters(f), + }); + + const fieldsVar = getFieldsVariable(this); + fieldsVar.setState({ + expressionBuilder: (f) => renderLogQLFieldFilters(f), + }); + + const labelsVar = getLabelsVariable(this); + labelsVar.setState({ + expressionBuilder: (f) => renderLogQLLabelFilters(f), + }); + + const levelsVar = getLevelsVariable(this); + levelsVar.setState({ + expressionBuilder: (f) => renderLevelsFilter(f), + }); + } + private resetBodyAndData() { let stateUpdate: Partial = {}; diff --git a/src/Components/ServiceSelectionScene/AddLabelToFiltersHeaderActionScene.tsx b/src/Components/ServiceSelectionScene/AddLabelToFiltersHeaderActionScene.tsx index 5db0d4bd..22884234 100644 --- a/src/Components/ServiceSelectionScene/AddLabelToFiltersHeaderActionScene.tsx +++ b/src/Components/ServiceSelectionScene/AddLabelToFiltersHeaderActionScene.tsx @@ -43,7 +43,7 @@ export class AddLabelToFiltersHeaderActionScene extends SceneObjectBase { - const value = getValueFromAdHocVariableFilter(variable, f); + const value = getValueFromAdHocVariableFilter(VAR_LABELS, f); return f.key === this.state.name && value.value === this.state.value; }); diff --git a/src/services/ExpressionBuilder.test.ts b/src/services/ExpressionBuilder.test.ts index 563dcb0d..ee56ac6d 100644 --- a/src/services/ExpressionBuilder.test.ts +++ b/src/services/ExpressionBuilder.test.ts @@ -45,9 +45,43 @@ describe('renderLogQLFieldFilters', () => { parser: 'logfmt', } as FieldValue), }, + { + key: 'cluster', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: 'lil-cluster-2', + parser: 'logfmt', + } as FieldValue), + }, + { + key: 'filename', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: 'C:\\Grafana\\logs\\logs.txt', + parser: 'logfmt', + } as FieldValue), + }, + { + key: 'pod', + operator: FilterOp.Equal, + value: JSON.stringify({ + value: 'pod-1', + parser: 'logfmt', + } as FieldValue), + }, + { + key: 'pod', + operator: FilterOp.Equal, + value: JSON.stringify({ + value: 'pod-2', + parser: 'logfmt', + } as FieldValue), + }, ]; - expect(renderLogQLFieldFilters(filters)).toEqual('| level!="info" | cluster!="lil-cluster"'); + expect(renderLogQLFieldFilters(filters)).toEqual( + '| pod="pod-1" or pod="pod-2" | level!="info" | cluster!="lil-cluster" | cluster!="lil-cluster-2" | filename!="C:\\\\Grafana\\\\logs\\\\logs.txt"' + ); }); test('Groups positive filters', () => { const filters: AdHocVariableFilter[] = [ @@ -198,7 +232,7 @@ describe('renderLogQLFieldFilters', () => { }, ]; - expect(renderLogQLFieldFilters(filters)).toEqual('| level!~"in.+" or level!~"info" | cluster!~"lil-cluster"'); + expect(renderLogQLFieldFilters(filters)).toEqual('| level!~"in.+" | level!~"info" | cluster!~"lil-cluster"'); }); test('Renders lte && gt numeric filters', () => { const filters: AdHocVariableFilter[] = [ @@ -346,15 +380,30 @@ describe('renderLogQLLabelFilters', () => { operator: FilterOp.NotEqual, value: 'lil-cluster', }, + { + key: 'cluster', + operator: FilterOp.NotEqual, + value: 'lil-cluster-2', + }, { key: 'filename', operator: FilterOp.NotEqual, value: 'C:\\Grafana\\logs\\logs.txt', }, + { + key: 'pod', + operator: FilterOp.Equal, + value: 'pod-1', + }, + { + key: 'pod', + operator: FilterOp.Equal, + value: 'pod-2', + }, ]; expect(renderLogQLLabelFilters(filters)).toEqual( - 'level!="info", cluster!="lil-cluster", filename!="C:\\\\Grafana\\\\logs\\\\logs.txt"' + 'level!="info", filename!="C:\\\\Grafana\\\\logs\\\\logs.txt", pod=~"pod-1|pod-2", cluster!~"lil-cluster|lil-cluster-2"' ); }); test('Groups positive filters', () => { @@ -686,9 +735,31 @@ describe('renderLogQLMetadataFilters', () => { operator: FilterOp.NotEqual, value: 'lil-cluster', }, + { + key: 'cluster', + operator: FilterOp.NotEqual, + value: 'lil-cluster-2', + }, + { + key: 'filename', + operator: FilterOp.NotEqual, + value: 'C:\\Grafana\\logs\\logs.txt', + }, + { + key: 'pod', + operator: FilterOp.Equal, + value: 'pod-1', + }, + { + key: 'pod', + operator: FilterOp.Equal, + value: 'pod-2', + }, ]; - expect(renderLogQLMetadataFilters(filters)).toEqual('| level!="info" | cluster!="lil-cluster"'); + expect(renderLogQLMetadataFilters(filters)).toEqual( + '| pod="pod-1" or pod="pod-2" | level!="info" | cluster!="lil-cluster" | cluster!="lil-cluster-2" | filename!="C:\\\\Grafana\\\\logs\\\\logs.txt"' + ); }); test('Groups positive filters', () => { const filters: AdHocVariableFilter[] = [ diff --git a/src/services/ExpressionBuilder.ts b/src/services/ExpressionBuilder.ts index 0acdefb8..8fc25819 100644 --- a/src/services/ExpressionBuilder.ts +++ b/src/services/ExpressionBuilder.ts @@ -56,7 +56,8 @@ interface Options { export class ExpressionBuilder { private filters: AdHocFilterWithLabels[]; private options: Options; - private valueSeparator = 'or'; + private positiveFilterValueSeparator = 'or'; + private negativeFilterValueSeparator = '|'; constructor( filters: AdHocFilterWithLabels[], @@ -381,7 +382,11 @@ export class ExpressionBuilder { values.forEach((value) => filtersWithSameOperatorsAndKeys.push(this.buildFilterString(key, operator, value))); } - filterStrings.push(filtersWithSameOperatorsAndKeys.join(` ${this.valueSeparator} `)); + if (isOperatorInclusive(operator)) { + filterStrings.push(filtersWithSameOperatorsAndKeys.join(` ${this.positiveFilterValueSeparator} `)); + } else { + filterStrings.push(filtersWithSameOperatorsAndKeys.join(` ${this.negativeFilterValueSeparator} `)); + } } return filterStrings; @@ -623,7 +628,9 @@ export class ExpressionBuilder { * Groups all filters by operator and key */ private groupFiltersByKey(filters: AdHocVariableFilter[]): Record> { - let filteredFilters: AdHocVariableFilter[] = filters.filter((f) => !this.options.ignoreKeys?.includes(f.key)); + let filteredFilters: AdHocVariableFilter[] = filters.filter( + (f) => !this.options.ignoreKeys?.includes(f.key) || isOperatorRegex(f.operator) + ); // We need at least one inclusive filter if (this.options.type === 'indexed') { diff --git a/src/services/fields.ts b/src/services/fields.ts index 42b05624..f6002e2c 100644 --- a/src/services/fields.ts +++ b/src/services/fields.ts @@ -150,7 +150,9 @@ export function getFilterBreakdownValueScene( ) .setOverrides(setLevelColorOverrides) .setMenu(new PanelMenu({ investigationOptions: { frame, fieldName: getTitle(frame), labelName: labelKey } })) - .setHeaderActions([new AddToFiltersButton({ frame, variableName , hideExclude: labelKey === LEVEL_VARIABLE_VALUE})]); + .setHeaderActions([ + new AddToFiltersButton({ frame, variableName, hideExclude: labelKey === LEVEL_VARIABLE_VALUE }), + ]); if (style === DrawStyle.Bars) { panel diff --git a/src/services/labels.test.ts b/src/services/labels.test.ts new file mode 100644 index 00000000..4bda274f --- /dev/null +++ b/src/services/labels.test.ts @@ -0,0 +1,400 @@ +import { AdHocFiltersVariable } from '@grafana/scenes'; +import { VAR_FIELDS, VAR_LABELS, VAR_METADATA } from './variables'; +import { FilterOp } from './filterTypes'; +import { getVisibleFilters } from './labels'; +import { VAR_FIELD_NAME } from '@grafana/data'; +import SpyInstance = jest.SpyInstance; + +describe('getVisibleFilters', () => { + let logSpy: SpyInstance; + beforeEach(() => { + logSpy = jest.spyOn(global.console, 'error'); + }); + afterEach(() => { + // If a field does not properly encode the value we will throw a console error, but it will still return a "proper" value. + // We want the test to fail in this case + expect(logSpy).toHaveBeenCalledTimes(0); + }); + describe('labels', () => { + it('Returns an empty array when everything is empty', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [], + }); + expect(getVisibleFilters('', [], labelsVariable)).toEqual([]); + }); + it('Returns all levels when there are no filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [], + }); + expect(getVisibleFilters('', ['error', 'info'], labelsVariable)).toEqual(['error', 'info']); + }); + it('Removes negatively filtered levels', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'error', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'info'], labelsVariable)).toEqual(['info']); + }); + it('Returns the positive levels from the filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'error', + }, + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'warn', + }, + { + key: 'detected_level', + operator: FilterOp.Equal, + value: 'info', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['info'], labelsVariable)).toEqual(['info']); + }); + it('Filters the levels by the current filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'error', + }, + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'warn', + }, + { + key: 'detected_level', + operator: FilterOp.Equal, + value: 'info', + }, + ], + }); + + expect(getVisibleFilters('detected_level', ['error', 'warn', 'info', 'debug'], labelsVariable)).toEqual(['info']); + }); + it('Handles empty positive log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.Equal, + value: '""', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'logs'], labelsVariable)).toEqual([]); + }); + it('Handles negative positive log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: '""', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'logs'], labelsVariable)).toEqual(['error', 'logs']); + }); + it('Handles exclusion regex negative log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_LABELS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.RegexNotEqual, + value: '""', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error'], labelsVariable)).toEqual(['error']); + }); + }); + describe('fields', () => { + it('Returns an empty array when everything is empty', () => { + const fieldsVariable = new AdHocFiltersVariable({ + name: VAR_FIELDS, + filters: [], + }); + expect(getVisibleFilters('', [], fieldsVariable)).toEqual([]); + }); + it('Returns all levels when there are no filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_FIELDS, + filters: [], + }); + expect(getVisibleFilters('', ['error', 'info'], labelsVariable)).toEqual(['error', 'info']); + }); + it('Removes negatively filtered levels', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_FIELDS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: 'error', + parser: 'logfmt', + }), + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'info'], labelsVariable)).toEqual(['info']); + }); + it('Returns the positive levels from the filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_FIELDS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: 'error', + parser: 'logfmt', + }), + valueLabels: ['error'], + }, + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: 'warn', + parser: 'logfmt', + }), + }, + { + key: 'detected_level', + operator: FilterOp.Equal, + value: JSON.stringify({ + value: 'info', + parser: 'logfmt', + }), + }, + ], + }); + expect(getVisibleFilters('detected_level', ['info'], labelsVariable)).toEqual(['info']); + }); + it('Filters the levels by the current filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_FIELDS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: 'error', + parser: 'logfmt', + }), + valueLabels: ['error'], + }, + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: 'warn', + parser: 'logfmt', + }), + valueLabels: ['error'], + }, + { + key: 'detected_level', + operator: FilterOp.Equal, + value: JSON.stringify({ + value: 'info', + parser: 'logfmt', + }), + valueLabels: ['info'], + }, + ], + }); + + expect(getVisibleFilters('detected_level', ['error', 'warn', 'info', 'debug'], labelsVariable)).toEqual(['info']); + }); + it('Handles empty positive log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_FIELDS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.Equal, + value: JSON.stringify({ + value: '""', + parser: 'logfmt', + }), + valueLabels: ['""'], + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'logs'], labelsVariable)).toEqual([]); + }); + it('Handles negative positive log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_FIELD_NAME, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: JSON.stringify({ + value: '""', + parser: 'logfmt', + }), + valueLabels: ['""'], + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'logs'], labelsVariable)).toEqual(['error', 'logs']); + }); + it('Handles exclusion regex negative log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_FIELDS, + filters: [ + { + key: 'detected_level', + operator: FilterOp.RegexNotEqual, + value: JSON.stringify({ + value: '""', + parser: 'logfmt', + }), + valueLabels: ['""'], + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error'], labelsVariable)).toEqual(['error']); + }); + }); + describe('metadata', () => { + it('Returns an empty array when everything is empty', () => { + const fieldsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [], + }); + expect(getVisibleFilters('', [], fieldsVariable)).toEqual([]); + }); + it('Returns all levels when there are no filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [], + }); + expect(getVisibleFilters('', ['error', 'info'], labelsVariable)).toEqual(['error', 'info']); + }); + it('Removes negatively filtered levels', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'error', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'info'], labelsVariable)).toEqual(['info']); + }); + it('Returns the positive levels from the filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'error', + }, + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'warn', + }, + { + key: 'detected_level', + operator: FilterOp.Equal, + value: 'info', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['info'], labelsVariable)).toEqual(['info']); + }); + it('Filters the levels by the current filters', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'error', + }, + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: 'warn', + }, + { + key: 'detected_level', + operator: FilterOp.Equal, + value: 'info', + }, + ], + }); + + expect(getVisibleFilters('detected_level', ['error', 'warn', 'info', 'debug'], labelsVariable)).toEqual(['info']); + }); + it('Handles empty positive log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [ + { + key: 'detected_level', + operator: FilterOp.Equal, + value: '""', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'logs'], labelsVariable)).toEqual([]); + }); + it('Handles negative positive log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [ + { + key: 'detected_level', + operator: FilterOp.NotEqual, + value: '""', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error', 'logs'], labelsVariable)).toEqual(['error', 'logs']); + }); + it('Handles exclusion regex negative log level filter', () => { + const labelsVariable = new AdHocFiltersVariable({ + name: VAR_METADATA, + filters: [ + { + key: 'detected_level', + operator: FilterOp.RegexNotEqual, + value: '""', + }, + ], + }); + expect(getVisibleFilters('detected_level', ['error'], labelsVariable)).toEqual(['error']); + }); + }); +}); diff --git a/src/services/labels.ts b/src/services/labels.ts index 3e280929..09ccfb16 100644 --- a/src/services/labels.ts +++ b/src/services/labels.ts @@ -1,8 +1,15 @@ -import { SceneObject } from '@grafana/scenes'; -import { LEVEL_VARIABLE_VALUE, VAR_LABELS } from './variables'; -import { getParserFromFieldsFilters } from './fields'; +import { AdHocFiltersVariable, SceneObject } from '@grafana/scenes'; +import { LEVEL_VARIABLE_VALUE, VAR_FIELDS, VAR_LABELS, VAR_METADATA } from './variables'; +import { getParserForField, getParserFromFieldsFilters } from './fields'; import { buildDataQuery } from './query'; -import { getFieldsVariable, getLabelsVariable, getLogsStreamSelector } from './variableGetters'; +import { + getFieldsAndMetadataVariable, + getFieldsVariable, + getLabelsVariable, + getLogsStreamSelector, + getMetadataVariable, + getValueFromFieldsFilter, +} from './variableGetters'; import { addToFilters, FilterType } from '../Components/ServiceScene/Breakdowns/AddToFiltersButton'; import { isOperatorExclusive, isOperatorInclusive } from './operatorHelpers'; import { getLabelValueFromDataFrame } from './levels'; @@ -38,6 +45,28 @@ export function getLabelsFromSeries(series: DataFrame[]): string[] { return labels.flatMap((f) => (f ? [f] : [])); } +export function toggleFieldFromFilter(key: string, value: string, sceneRef: SceneObject): FilterType { + const fieldsAndMetadataVariable = getFieldsAndMetadataVariable(sceneRef); + const empty = fieldsAndMetadataVariable.state.filters.length === 0; + const detectedFieldType = getParserForField(key, sceneRef); + const isMetadata = detectedFieldType === 'structuredMetadata'; + + const filterExists = fieldsAndMetadataVariable.state.filters.find((filter) => { + if (isMetadata) { + return isOperatorInclusive(filter.operator) && filter.value === value; + } + return isOperatorInclusive(filter.operator) && getValueFromFieldsFilter(filter).value === value; + }); + + if (empty || !filterExists) { + addToFilters(key, value, 'include', sceneRef, isMetadata ? VAR_METADATA : VAR_FIELDS); + return 'include'; + } else { + addToFilters(key, value, 'toggle', sceneRef, isMetadata ? VAR_METADATA : VAR_FIELDS); + return 'toggle'; + } +} + export function toggleLabelFromFilter(key: string, value: string, sceneRef: SceneObject): FilterType { const labelsVariable = getLabelsVariable(sceneRef); const empty = labelsVariable.state.filters.length === 0; @@ -56,14 +85,26 @@ export function toggleLabelFromFilter(key: string, value: string, sceneRef: Scen export function getVisibleLabels(key: string, allLabels: string[], sceneRef: SceneObject) { const labelsVariable = getLabelsVariable(sceneRef); - const inclusiveFilters = labelsVariable.state.filters + return getVisibleFilters(key, allLabels, labelsVariable); +} + +export function getVisibleFields(key: string, allLabels: string[], sceneRef: SceneObject) { + const fieldsVariable = getFieldsVariable(sceneRef); + return getVisibleFilters(key, allLabels, fieldsVariable); +} + +export function getVisibleMetadata(key: string, allLabels: string[], sceneRef: SceneObject) { + const metadataVariable = getMetadataVariable(sceneRef); + return getVisibleFilters(key, allLabels, metadataVariable); +} + +export function getVisibleFilters(key: string, allLabels: string[], variable: AdHocFiltersVariable) { + const inclusiveFilters = variable.state.filters .filter((filter) => filter.key === key && isOperatorInclusive(filter.operator)) - .map((filter) => filter.value.split('|')) - .join('|'); - const exclusiveLabels = labelsVariable.state.filters + .map((filter) => (variable.state.name === VAR_FIELDS ? getValueFromFieldsFilter(filter).value : filter.value)); + const exclusiveLabels = variable.state.filters .filter((filter) => filter.key === key && isOperatorExclusive(filter.operator)) - .map((filter) => filter.value.split('|')) - .join('|'); + .map((filter) => (variable.state.name === VAR_FIELDS ? getValueFromFieldsFilter(filter).value : filter.value)); return allLabels.filter((label) => { if (exclusiveLabels.includes(label)) { diff --git a/src/services/panel.ts b/src/services/panel.ts index d5d287d4..1f865a58 100644 --- a/src/services/panel.ts +++ b/src/services/panel.ts @@ -27,7 +27,8 @@ import { getLevelLabelsFromSeries, getVisibleLevels } from './levels'; import { LokiQuery, LokiQueryDirection } from './lokiQuery'; import { LOGS_COUNT_QUERY_REFID, LOGS_PANEL_QUERY_REFID } from '../Components/ServiceScene/ServiceScene'; import { getLogsPanelSortOrderFromStore, getLogsPanelSortOrderFromURL } from 'Components/ServiceScene/LogOptionsScene'; -import { getLabelsFromSeries, getVisibleLabels } from './labels'; +import { getLabelsFromSeries, getVisibleFields, getVisibleLabels, getVisibleMetadata } from './labels'; +import { getParserForField } from './fields'; const UNKNOWN_LEVEL_LOGS = 'logs'; export function setLevelColorOverrides(overrides: FieldConfigOverridesBuilder) { @@ -118,6 +119,7 @@ export function syncLevelsVisibleSeries(panel: VizPanel, series: DataFrame[], sc } /** + * @todo unit test * Set levels series visibility in the panel */ export function syncLabelsValueSummaryVisibleSeries( @@ -128,6 +130,7 @@ export function syncLabelsValueSummaryVisibleSeries( ) { const allLabels = getLabelsFromSeries(series); const focusedLabels = getVisibleLabels(key, allLabels, sceneRef); + const config = setValueSummaryFieldConfigs(FieldConfigBuilders.timeseries()); if (focusedLabels.length) { config.setOverrides(setLabelSeriesOverrides.bind(null, focusedLabels)); @@ -137,6 +140,33 @@ export function syncLabelsValueSummaryVisibleSeries( } } +/** + * Set fields series visibility in the panel + */ +export function syncFieldsValueSummaryVisibleSeries( + key: string, + panel: VizPanel, + series: DataFrame[], + sceneRef: SceneObject +) { + const allLabels = getLabelsFromSeries(series); + const detectedFieldType = getParserForField(key, sceneRef); + + const focusedLabels = + detectedFieldType === 'structuredMetadata' + ? getVisibleMetadata(key, allLabels, sceneRef) + : getVisibleFields(key, allLabels, sceneRef); + + const config = setValueSummaryFieldConfigs(FieldConfigBuilders.timeseries()); + + if (focusedLabels.length) { + config.setOverrides(setLabelSeriesOverrides.bind(null, focusedLabels)); + } + if (config instanceof FieldConfigBuilder) { + panel.onFieldConfigChange(config.build(), true); + } +} + function setColorByDisplayNameTransformation() { return (source: Observable) => { return source.pipe( diff --git a/src/services/variableGetters.ts b/src/services/variableGetters.ts index 273a2e93..6bea3e70 100644 --- a/src/services/variableGetters.ts +++ b/src/services/variableGetters.ts @@ -43,7 +43,7 @@ import { AdHocVariableFilter } from '@grafana/data'; import { logger } from './logger'; import { narrowFieldValue, NarrowingError } from './narrowing'; import { isFilterMetadata } from './filters'; -import { AdHocFilterTypes } from '../Components/ServiceScene/Breakdowns/AddToFiltersButton'; +import { AdHocFilterTypes, InterpolatedFilterType } from '../Components/ServiceScene/Breakdowns/AddToFiltersButton'; export function getLogsStreamSelector(options: LogsQueryOptions) { const { @@ -195,6 +195,11 @@ export function getUrlParamNameForVariable(variableName: string) { return `var-${variableName}`; } +/** + * Parses an adHoc filter and returns the encoded value and parser + * @param filter + * @param variableName - only used for debugging + */ export function getValueFromFieldsFilter( filter: { value: string; valueLabels?: string[] }, variableName: string = VAR_FIELDS @@ -235,10 +240,10 @@ export function getValueFromFieldsFilter( } export function getValueFromAdHocVariableFilter( - variable: AdHocFiltersVariable, + variableName: InterpolatedFilterType, filter?: AdHocVariableFilter ): AdHocFieldValue { - if (variable.state.name === VAR_FIELDS && filter) { + if (variableName === VAR_FIELDS && filter) { return getValueFromFieldsFilter(filter); } diff --git a/tests/exploreServicesBreakDown.spec.ts b/tests/exploreServicesBreakDown.spec.ts index a038be8b..d79316e2 100644 --- a/tests/exploreServicesBreakDown.spec.ts +++ b/tests/exploreServicesBreakDown.spec.ts @@ -484,11 +484,12 @@ test.describe('explore services breakdown page', () => { // And we'll have 2 requests, one on the aggregation, one for the label values expect(requests).toHaveLength(2); - // This should trigger more queries - await page.getByRole('button', { name: 'Exclude' }).nth(0).click(); + const excludeButton = page.getByRole('button', { name: 'Exclude' }).nth(0); - // Should have removed a panel - await expect(allPanels).toHaveCount(8); + // This should trigger more queries + await excludeButton.click(); + // Should have excluded a panel + await expect(excludeButton).toHaveAttribute('aria-selected', 'true'); // Adhoc content filter should be added await expect(page.getByLabel(E2EComboboxStrings.editByKey(fieldName))).toBeVisible(); @@ -501,8 +502,8 @@ test.describe('explore services breakdown page', () => { expect(query.expr).toContain('| logfmt | caller!=""'); }); }); - // Now we should have 3 queries, one more after adding the field exclusion filter - expect(requests).toHaveLength(3); + // Now we should still have 2 queries + expect(requests).toHaveLength(2); }); test(`should include field ${fieldName}, update filters, open filters breakdown`, async ({ page }) => { @@ -510,8 +511,6 @@ test.describe('explore services breakdown page', () => { await explorePage.scrollToBottom(); await page.getByTestId(`data-testid Panel header ${fieldName}`).getByRole('button', { name: 'Select' }).click(); await page.getByRole('button', { name: 'Include' }).nth(0).click(); - - await explorePage.assertFieldsIndex(); await expect(page.getByLabel(E2EComboboxStrings.editByKey(fieldName))).toBeVisible(); await expect(page.getByText('=').nth(1)).toBeVisible(); }); @@ -686,7 +685,6 @@ test.describe('explore services breakdown page', () => { await explorePage.goToFieldsTab(); await page.getByTestId(`data-testid Panel header ${fieldName}`).getByRole('button', { name: 'Select' }).click(); await page.getByRole('button', { name: 'Include' }).nth(0).click(); - await explorePage.assertFieldsIndex(); // Adhoc content filter should be added await expect(page.getByLabel(E2EComboboxStrings.editByKey(fieldName))).toBeVisible(); }); @@ -1551,6 +1549,52 @@ test.describe('explore services breakdown page', () => { await expect(summaryPanelBody).toBeVisible(); }); + test('field value breakdown: changing parser updates query', async ({ page }) => { + explorePage.blockAllQueriesExcept({ + refIds: [fieldName], + }); + + await explorePage.goToFieldsTab(); + + // Use the dropdown since the tenant field might not be visible + await page.getByText('FieldAll').click(); + await page.keyboard.type('caller'); + await page.keyboard.press('Enter'); + await explorePage.assertNotLoading(); + + await expect(explorePage.getAllPanelsLocator()).toHaveCount(9); + + // add a field with logfmt parser + await explorePage.addNthValueToCombobox('content', FilterOp.Equal, ComboBoxIndex.fields, 2, 'con'); + + await explorePage.assertPanelsNotLoading(); + + await expect(explorePage.getAllPanelsLocator()).toHaveCount(2); + }); + + test('label value breakdown: changing parser updates query', async ({ page }) => { + explorePage.blockAllQueriesExcept({ + refIds: ['LABEL_BREAKDOWN_VALUES'], + }); + + await explorePage.goToLabelsTab(); + + // Use the dropdown since the tenant field might not be visible + await page.getByText('LabelAll').click(); + await page.keyboard.type('detected'); + await page.keyboard.press('Enter'); + await explorePage.assertNotLoading(); + + await expect(explorePage.getAllPanelsLocator()).toHaveCount(5); + + // add a field with logfmt parser + await explorePage.addNthValueToCombobox('content', FilterOp.Equal, ComboBoxIndex.fields, 2, 'con'); + + await explorePage.assertPanelsNotLoading(); + + await expect(explorePage.getAllPanelsLocator()).toHaveCount(2); + }); + test.describe('line filters', () => { test('line filter', async ({ page }) => { let requestCount = 0, diff --git a/tests/exploreServicesJsonBreakDown.spec.ts b/tests/exploreServicesJsonBreakDown.spec.ts index 7a3a3be3..01ec5657 100644 --- a/tests/exploreServicesJsonBreakDown.spec.ts +++ b/tests/exploreServicesJsonBreakDown.spec.ts @@ -41,8 +41,8 @@ test.describe('explore nginx-json breakdown pages ', () => { expect(requests).toHaveLength(2); // Exclude a panel await page.getByRole('button', { name: 'Exclude' }).nth(0).click(); - // Should be removed from the UI, and also lets us know when the query is done loading - await expect(allPanels).toHaveCount(6); + // Should NOT be removed from the UI + await expect(allPanels).toHaveCount(7); // Adhoc content filter should be added await expect(page.getByLabel(E2EComboboxStrings.editByKey(fieldName))).toBeVisible(); @@ -57,7 +57,7 @@ test.describe('explore nginx-json breakdown pages ', () => { ); }); }); - expect(requests).toHaveLength(3); + expect(requests).toHaveLength(2); }); test('should see too many series button', async ({ page }) => { diff --git a/tests/exploreServicesJsonMixedBreakDown.spec.ts b/tests/exploreServicesJsonMixedBreakDown.spec.ts index 40d9935f..16f496cb 100644 --- a/tests/exploreServicesJsonMixedBreakDown.spec.ts +++ b/tests/exploreServicesJsonMixedBreakDown.spec.ts @@ -47,8 +47,8 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { expect(requests).toHaveLength(2); // Exclude a panel await page.getByRole('button', { name: 'Exclude' }).nth(0).click(); - // Should be removed from the UI, and also lets us know when the query is done loading - await expect(allPanels).toHaveCount(6); + // Should NOT be removed from the UI + await expect(allPanels).toHaveCount(7); // Adhoc content filter should be added await expect(page.getByLabel(E2EComboboxStrings.editByKey(mixedFieldName))).toBeVisible(); @@ -63,7 +63,7 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { ); }); }); - expect(requests).toHaveLength(3); + expect(requests).toHaveLength(2); }); test(`should exclude ${logFmtFieldName}, request should contain logfmt`, async ({ page }) => { let requests: PlaywrightRequest[] = []; @@ -89,6 +89,8 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { expect(requests).toHaveLength(2); // Exclude a panel await page.getByRole('button', { name: 'Exclude' }).nth(0).click(); + // Nav to fields index + await explorePage.goToFieldsTab(); // There is only one panel/value, so we should be redirected back to the aggregation after excluding it // We'll have all 12 responses from detected_fields await expect(allPanels).toHaveCount(13); @@ -101,20 +103,13 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { const post = req.post; const queries: LokiQuery[] = post.queries; queries.forEach((query) => { - if (index < 2) { - expect(query.expr).toContain( - `sum by (${logFmtFieldName}) (count_over_time({service_name="${serviceName}"} | logfmt | ${logFmtFieldName}!="" [$__auto]))` - ); - } - if (index >= 2) { - expect(query.expr).toContain( - `sum by (${logFmtFieldName}) (count_over_time({service_name="${serviceName}"} | logfmt | ${logFmtFieldName}!="" | caller!="flush.go:253" [$__auto]))` - ); - } + expect(query.expr).toContain( + `sum by (${logFmtFieldName}) (count_over_time({service_name="${serviceName}"} | logfmt | ${logFmtFieldName}!="" [$__auto]))` + ); }); }); - expect(requests.length).toBeGreaterThanOrEqual(3); + expect(requests.length).toBeGreaterThanOrEqual(2); }); test(`should exclude ${jsonFmtFieldName}, request should contain logfmt`, async ({ page }) => { @@ -137,8 +132,8 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { expect(requests).toHaveLength(2); // Exclude a panel await page.getByRole('button', { name: 'Exclude' }).nth(0).click(); - // Should be removed from the UI, and also lets us know when the query is done loading - await expect(allPanels).toHaveCount(3); + // Should NOT be removed from the UI, and also lets us know when the query is done loading + await expect(allPanels).toHaveCount(4); // Adhoc content filter should be added await expect(page.getByLabel(E2EComboboxStrings.editByKey(jsonFmtFieldName))).toBeVisible(); @@ -153,7 +148,7 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { ); }); }); - expect(requests).toHaveLength(3); + expect(requests).toHaveLength(2); }); test(`should exclude ${metadataFieldName}, request should contain no parser`, async ({ page }) => { let requests: PlaywrightRequest[] = []; @@ -182,7 +177,7 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { await expect(page.getByLabel(E2EComboboxStrings.editByKey(metadataFieldName))).toBeVisible(); await expect(page.getByText('!=')).toBeVisible(); - await expect.poll(() => requests).toHaveLength(3); + await expect.poll(() => requests).toHaveLength(2); requests.forEach((req) => { const post = req.post; diff --git a/tests/fixtures/explore.ts b/tests/fixtures/explore.ts index 7cd819fc..4cca909c 100644 --- a/tests/fixtures/explore.ts +++ b/tests/fixtures/explore.ts @@ -309,6 +309,41 @@ export class ExplorePage { }); } + async addNthValueToCombobox( + labelName: string, + operator: FilterOpType, + comboBox: ComboBoxIndex, + n: number, + typeAhead?: string + ) { + // Open combobox + const comboboxLocator = this.page.getByPlaceholder('Filter by label values').nth(comboBox); + await comboboxLocator.click(); + + if (typeAhead) { + await this.page.keyboard.type(typeAhead); + } + + // Select detected_level key + await this.page.getByRole('option', { name: labelName, exact: true }).click(); + await expect(this.getOperatorLocator(operator)).toHaveCount(1); + await expect(this.getOperatorLocator(operator)).toBeVisible(); + // Select operator + await this.getOperatorLocator(operator).click(); + + // assert the values have loaded + await expect(this.page.getByRole('option', { name: /\[compactor-.+]/ }).nth(0)).toBeVisible(); + + // Select the nth item + for (let i = 0; i < n; i++) { + await this.page.keyboard.press('ArrowDown'); + } + // Select the item + await this.page.keyboard.press('Enter'); + // Close the label name dropdown that opens after adding a value + await this.page.keyboard.press('Escape'); + } + /** * * @param labelName