From 92072fa4d712d235820a1c3c88d24f36d7bbd22f Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue, 25 Feb 2025 09:40:37 -0600 Subject: [PATCH] Multiple include: Levels & Labels (#1074) * feat: Support multiple includes in value breakdowns --------- Co-authored-by: Matias Chomicki --- src/Components/FilterButton.tsx | 29 +- .../IndexScene/LevelsVariableScene.tsx | 75 +--- .../Breakdowns/AddToFiltersButton.test.tsx | 4 +- .../Breakdowns/AddToFiltersButton.tsx | 146 ++++--- .../Breakdowns/FieldValuesBreakdownScene.tsx | 334 ++++++++++++--- .../Breakdowns/LabelValuesBreakdownScene.tsx | 271 +++++++++--- .../Breakdowns/NumericFilterPopoverScene.tsx | 16 +- .../Breakdowns/Panels/ValueSummary.tsx | 177 +++++++- .../Breakdowns/SelectLabelActionScene.tsx | 4 +- .../ServiceScene/LogsPanelScene.tsx | 11 +- .../ServiceScene/LogsTableScene.tsx | 10 + .../ServiceScene/LogsVolumePanel.tsx | 69 +-- src/Components/ServiceScene/ServiceScene.tsx | 25 +- .../AddLabelToFiltersHeaderActionScene.tsx | 2 +- .../ServiceSelectionScene.tsx | 4 +- src/services/ExpressionBuilder.test.ts | 108 ++++- src/services/ExpressionBuilder.ts | 95 +++-- src/services/analytics.ts | 1 + src/services/fields.ts | 4 +- src/services/labels.test.ts | 400 ++++++++++++++++++ src/services/labels.ts | 93 +++- src/services/levels.test.ts | 29 +- src/services/levels.ts | 17 +- src/services/panel.ts | 88 +++- src/services/query.ts | 16 +- src/services/variableGetters.ts | 11 +- tests/exploreServices.spec.ts | 1 + tests/exploreServicesBreakDown.spec.ts | 156 +++++-- tests/exploreServicesJsonBreakDown.spec.ts | 6 +- .../exploreServicesJsonMixedBreakDown.spec.ts | 57 ++- tests/fixtures/explore.ts | 35 ++ 31 files changed, 1863 insertions(+), 431 deletions(-) create mode 100644 src/services/labels.test.ts diff --git a/src/Components/FilterButton.tsx b/src/Components/FilterButton.tsx index abe37f365..269aadbcf 100644 --- a/src/Components/FilterButton.tsx +++ b/src/Components/FilterButton.tsx @@ -15,11 +15,12 @@ type Props = { include: string; exclude: string; }; + hideExclude?: boolean }; export const FilterButton = (props: Props) => { - const { isExcluded, isIncluded, onInclude, onExclude, onClear, titles, buttonFill } = props; - const styles = useStyles2(getStyles, isIncluded, isExcluded); + const { isExcluded, isIncluded, onInclude, onExclude, onClear, titles, buttonFill, hideExclude } = props; + const styles = useStyles2(getStyles, isIncluded, isExcluded, hideExclude); return (
- + }
); }; -const getStyles = (theme: GrafanaTheme2, isIncluded: boolean, isExcluded: boolean) => { +const getStyles = (theme: GrafanaTheme2, isIncluded: boolean, isExcluded: boolean, hideExclude?: boolean) => { return { container: css({ display: 'flex', @@ -58,7 +59,7 @@ const getStyles = (theme: GrafanaTheme2, isIncluded: boolean, isExcluded: boolea }), includeButton: css({ borderRadius: 0, - borderRight: isIncluded ? undefined : 'none', + borderRight: isIncluded || hideExclude ? undefined : 'none', }), excludeButton: css({ borderRadius: `0 ${theme.shape.radius.default} ${theme.shape.radius.default} 0`, diff --git a/src/Components/IndexScene/LevelsVariableScene.tsx b/src/Components/IndexScene/LevelsVariableScene.tsx index a6ab0c581..41203d51a 100644 --- a/src/Components/IndexScene/LevelsVariableScene.tsx +++ b/src/Components/IndexScene/LevelsVariableScene.tsx @@ -1,10 +1,4 @@ -import { - ControlsLabel, - SceneComponentProps, - SceneObjectBase, - SceneObjectState, - SceneVariableValueChangedEvent, -} from '@grafana/scenes'; +import { ControlsLabel, SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import React from 'react'; import { getLevelsVariable } from '../../services/variableGetters'; import { GrafanaTheme2, MetricFindValue, SelectableValue } from '@grafana/data'; @@ -31,21 +25,17 @@ export class LevelsVariableScene extends SceneObjectBase this.onFilterChange()); } - private onFilterChange() { + public onFilterChange() { const levelsVar = getLevelsVariable(this); - if (levelsVar.state.filters.length) { - this.setState({ - options: levelsVar.state.filters.map((filter) => ({ - text: filter.valueLabels?.[0] ?? filter.value, - selected: true, - value: filter.value, - })), - }); - } + this.setState({ + options: levelsVar.state.filters.map((filter) => ({ + text: filter.valueLabels?.[0] ?? filter.value, + selected: true, + value: filter.value, + })), + }); } getTagValues = () => { @@ -143,7 +133,7 @@ export class LevelsVariableScene extends SceneObjectBase v.selected)} options={options?.map((val) => ({ - value: val.text, + value: val.value, label: val.text, }))} /> @@ -156,49 +146,4 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex: css({ flex: '1', }), - removeButton: css({ - marginInline: theme.spacing(0.5), - cursor: 'pointer', - '&:hover': { - color: theme.colors.text.primary, - }, - }), - pillText: css({ - maxWidth: '200px', - width: '100%', - textOverflow: 'ellipsis', - overflow: 'hidden', - }), - tooltipText: css({ - textAlign: 'center', - }), - comboboxWrapper: css({ - display: 'flex', - flexWrap: 'nowrap', - alignItems: 'center', - columnGap: theme.spacing(1), - rowGap: theme.spacing(0.5), - minHeight: theme.spacing(4), - backgroundColor: theme.components.input.background, - border: `1px solid ${theme.colors.border.strong}`, - borderRadius: theme.shape.radius.default, - paddingInline: theme.spacing(1), - paddingBlock: theme.spacing(0.5), - flexGrow: 1, - }), - comboboxFocusOutline: css({ - '&:focus-within': { - outline: '2px dotted transparent', - outlineOffset: '2px', - boxShadow: `0 0 0 2px ${theme.colors.background.canvas}, 0 0 0px 4px ${theme.colors.primary.main}`, - transitionTimingFunction: `cubic-bezier(0.19, 1, 0.22, 1)`, - transitionDuration: '0.2s', - transitionProperty: 'outline, outline-offset, box-shadow', - zIndex: 2, - }, - }), - filterIcon: css({ - color: theme.colors.text.secondary, - alignSelf: 'center', - }), }); diff --git a/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.test.tsx b/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.test.tsx index c6af80e20..50beb34bb 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 22a7a74ce..b40ce0c60 100644 --- a/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.tsx +++ b/src/Components/ServiceScene/Breakdowns/AddToFiltersButton.tsx @@ -1,7 +1,13 @@ import React from 'react'; import { AdHocVariableFilter, BusEventBase, DataFrame } from '@grafana/data'; -import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { + AdHocFiltersVariable, + SceneComponentProps, + SceneObject, + SceneObjectBase, + SceneObjectState, +} from '@grafana/scenes'; import { VariableHide } from '@grafana/schema'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; import { @@ -24,14 +30,25 @@ import { 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; variableName: InterpolatedFilterType; + hideExclude?: boolean; + isIncluded?: boolean; + isExcluded?: boolean; } export class AddFilterEvent extends BusEventBase { - constructor(public operator: FilterType | NumericFilterType, public key: string, public value: string) { + constructor( + public source: 'legend' | 'filterButton' | 'variable', + public operator: FilterType | NumericFilterType, + public key: string, + public value?: string + ) { super(); } public static type = 'add-filter'; @@ -54,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); } @@ -66,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); } @@ -109,7 +122,7 @@ const getNumericOperatorType = (op: NumericFilterType | string): OperatorType | return undefined; }; -export function removeFilter( +export function removeNumericFilter( key: string, scene: SceneObject, operator?: NumericFilterType, @@ -172,11 +185,11 @@ export function addNumericFilter( }, ]; - scene.publishEvent(new AddFilterEvent(operator, key, value), true); - variable.setState({ filters, }); + + scene.publishEvent(new AddFilterEvent('filterButton', operator, key, value), true); } export function addToFilters( @@ -184,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); } @@ -197,20 +206,26 @@ 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') { - return !(filter.key === key && filter.operator !== FilterOp.Equal); + return !(filter.key === key && filter.operator === FilterOp.NotEqual); + } + if (operator === 'exclude') { + return !(filter.key === key && filter.operator === FilterOp.Equal); } return !(filter.key === key && fieldValue.value === value); @@ -225,16 +240,17 @@ export function addToFilters( key, operator: operator === 'exclude' ? FilterOp.NotEqual : FilterOp.Equal, value: valueObject ? valueObject : value, - valueLabels: [value], + valueLabels: [valueLabel], }, ]; } - scene.publishEvent(new AddFilterEvent(operator, key, value), true); - + // Variable needs to be updated before event is published! variable.setState({ filters, }); + + scene.publishEvent(new AddFilterEvent('filterButton', operator, key, value), true); } export function replaceFilter( @@ -272,6 +288,59 @@ function resolveVariableTypeForField(field: string, scene: SceneObject): Interpo } export class AddToFiltersButton extends SceneObjectBase { + constructor(state: AddToFiltersButtonState) { + super(state); + + this.addActivationHandler(this.onActivate.bind(this)); + } + + onActivate() { + const filter = getFilter(this.state.frame); + if (filter) { + const variable = getUIAdHocVariable(this.state.variableName, filter.name, this); + this.setFilterState(variable); + + this._subs.add( + variable.subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.setFilterState(variable); + } + }) + ); + } + } + + private setFilterState(variable: AdHocFiltersVariable) { + const filter = getFilter(this.state.frame); + if (!filter) { + this.setState({ + isIncluded: false, + isExcluded: false, + }); + return; + } + + // Check if the filter is already there + const filterInSelectedFilters = variable.state.filters.find((f) => { + const isMetadata = isFilterMetadata(filter); + const value = getValueFromAdHocVariableFilter(isMetadata ? VAR_METADATA : VAR_FIELDS, f); + return f.key === filter.name && value.value === filter.value; + }); + + if (!filterInSelectedFilters) { + this.setState({ + isIncluded: false, + isExcluded: false, + }); + return; + } + + this.setState({ + isIncluded: filterInSelectedFilters.operator === FilterOp.Equal, + isExcluded: filterInSelectedFilters.operator === FilterOp.NotEqual, + }); + } + public onClick = (type: FilterType) => { const filter = getFilter(this.state.frame); if (!filter) { @@ -293,41 +362,17 @@ export class AddToFiltersButton extends SceneObjectBase ); }; - isSelected = () => { - const filter = getFilter(this.state.frame); - if (!filter) { - return { isIncluded: false, isExcluded: false }; - } - - const variable = getUIAdHocVariable(this.state.variableName, filter.name, this); - - // Check if the filter is already there - const filterInSelectedFilters = variable.state.filters.find((f) => { - const value = getValueFromAdHocVariableFilter(variable, f); - return f.key === filter.name && value.value === filter.value; - }); - - if (!filterInSelectedFilters) { - return { isIncluded: false, isExcluded: false }; - } - - // @todo support regex operators? - return { - isIncluded: filterInSelectedFilters.operator === FilterOp.Equal, - isExcluded: filterInSelectedFilters.operator === FilterOp.NotEqual, - }; - }; - public static Component = ({ model }: SceneComponentProps) => { - const { isIncluded, isExcluded } = model.isSelected(); + const { hideExclude, isExcluded, isIncluded } = model.useState(); return ( model.onClick('include')} onClear={() => model.onClick('clear')} onExclude={() => model.onClick('exclude')} + hideExclude={hideExclude} /> ); }; @@ -338,6 +383,7 @@ const getFilter = (frame: DataFrame) => { const filterNameAndValueObj = frame.fields[1]?.labels ?? {}; // Sanity check - filter should have only one key-value pair if (Object.keys(filterNameAndValueObj).length !== 1) { + logger.warn('getFilter: unexpected empty labels'); return; } const name = Object.keys(filterNameAndValueObj)[0]; diff --git a/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx index 22e335ac7..e0e61aaa4 100644 --- a/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx @@ -4,41 +4,52 @@ import { SceneCSSGridLayout, SceneDataProvider, SceneDataState, - SceneDataTransformer, SceneFlexItem, SceneFlexLayout, sceneGraph, 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; } export class FieldValuesBreakdownScene extends SceneObjectBase { @@ -66,55 +77,233 @@ 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(); + // Set query runner this.setState({ body: this.build(query), - $data: new SceneDataTransformer({ - $data: getQueryRunner([query]), - transformations: [], - }), + $data: this.buildQueryRunner(), }); + // 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(); + } + + private buildQueryRunner() { + const query = this.buildQuery(); + return getQueryRunner([query], { runQueriesMode: 'manual' }); + } + + /** + * 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); + // Manually interpolate query so we don't pollute the variable interpolation for other queries + const { variableName, filterExpression } = this.removeFieldLabelFromVariableInterpolation(); + const expression = sceneGraph.interpolate(this, queryString.replace(`$\{${variableName}}`, filterExpression)); + + return buildDataQuery(expression, { legendFormat: `{{${tagKey}}}`, refId: tagKey }); + } + + /** + * Sets activation subscriptions + */ + private setSubscriptions() { + // Subscribe to time range changes this._subs.add( - this.state.$data?.subscribeToState((newState) => { - this.onValuesDataQueryChange(newState, query); + sceneGraph.getTimeRange(this).subscribeToState(() => { + // Run query on time range change + this.runQuery(); }) ); + + // VARIABLE SUBS + // Subscribe to line filter changes + this._subs.add( + 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() { + const key = this.getTagKey(); + // 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.runQuery(); + } + }) + ); - // @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(); + this._subs.add( + getMetadataVariable(this).subscribeToState(async (newState, prevState) => { + if ( + !areArraysEqual( + newState.filters.filter((f) => f.key !== key), + prevState.filters.filter((f) => f.key !== key) + ) + ) { + 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() { + const key = this.getTagKey(); + // 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.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.filter((f) => f.key !== key), + prevState.filters.filter((f) => f.key !== key) + ) + ) { + this.runQuery(); } + }) + ); + } + + /** + * Rebuild the query before running. + * If so update the query with the new parser and set the parser to state. + */ + private rebuildQuery() { + const query = this.buildQuery(); + this.getSceneQueryRunner()?.setState({ + queries: [query], + }); + } + + /** + * 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.rebuildQuery(); + 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(); + let filterExpression; + let variableName: typeof VAR_FIELDS | typeof VAR_METADATA; + + // 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); + variableName = VAR_METADATA; + filterExpression = renderLogQLMetadataFilters(metadataVar.state.filters, [tagKey]); + } else { + variableName = VAR_FIELDS; + const fieldsVar = getFieldsVariable(this); + filterExpression = renderLogQLFieldFilters(fieldsVar.state.filters, [tagKey]); + } + + return { filterExpression, variableName }; + } + + /** + * 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 +315,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 +391,7 @@ export class FieldValuesBreakdownScene extends SceneObjectBase, }), - new ValueSummaryPanelScene({ title: optionValue }), + new ValueSummaryPanelScene({ title: optionValue, type: 'field', tagKey: this.getTagKey() }), new SceneReactObject({ reactNode: , }), @@ -228,7 +411,7 @@ export class FieldValuesBreakdownScene extends SceneObjectBase, }), - new ValueSummaryPanelScene({ title: optionValue }), + new ValueSummaryPanelScene({ title: optionValue, type: 'field', tagKey: this.getTagKey() }), new SceneReactObject({ reactNode: , }), @@ -266,7 +449,7 @@ export class FieldValuesBreakdownScene extends SceneObjectBase; @@ -44,7 +60,6 @@ type DisplayErrors = Record; export interface LabelValueBreakdownSceneState extends SceneObjectState { body?: (LayoutSwitcher & SceneObject) | (NoMatchingLabelsScene & SceneObject) | (EmptyLayoutScene & SceneObject); $data?: SceneDataProvider; - lastFilterEvent?: AddFilterEvent; errors: DisplayErrors; } @@ -60,14 +75,55 @@ export class LabelValuesBreakdownScene extends SceneObjectBase { + this.subscribeToEvent(AddFilterEvent, (event) => { + const levelsVariableScene = sceneGraph.findObject(this, (obj) => obj instanceof LevelsVariableScene); + if (levelsVariableScene instanceof LevelsVariableScene) { + levelsVariableScene.onFilterChange(); + } + }) + ); + + // QUERY RUNNER SUBS + // Subscribe to value breakdown state + this._subs.add( + this.state.$data?.subscribeToState((newState, prevState) => { + this.onValuesDataQueryChange(newState); + }) + ); + + // VARIABLE SUBS + // Subscribe to label change via dropdown + this._subs.add( + getLabelGroupByVariable(this).subscribeToState((newState) => { if (newState.value === ALL_VARIABLE_VALUE) { this.setState({ $data: undefined, @@ -77,49 +133,162 @@ export class LabelValuesBreakdownScene extends SceneObjectBase { - this.setState({ - lastFilterEvent: event, - }); - }); + // Subscribe to time range changes + this._subs.add( + sceneGraph.getTimeRange(this).subscribeToState(() => { + // Run query on time range change + this.runQuery(); + }) + ); this._subs.add( - this.state.$data?.subscribeToState((newState, prevState) => { - this.onValuesDataQueryChange(newState, prevState); + getFieldsVariable(this).subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + // Check to see if the new field filter changes the parser, if so rebuild the query + this.runQuery(); + } }) ); - } - private onValuesDataQueryChange(newState: SceneDataState, prevState: SceneDataState) { - // Set empty states - this.setEmptyStates(newState); + // Subscribe to fields variable changes + this._subs.add( + getMetadataVariable(this).subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.runQuery(); + } + }) + ); - // Set error states - this.setErrorStates(newState); + // Subscribe to line filter variable changes + this._subs.add( + getLineFiltersVariable(this).subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.runQuery(); + } + }) + ); - // Navigate back to main page if user reduced cardinality to 1 - this.navigateOnLastFilter(newState); - } + // Subscribe to pattern variable changes + this._subs.add( + getPatternsVariable(this).subscribeToState((newState, prevState) => { + if (newState.value !== prevState.value) { + this.runQuery(); + } + }) + ); - private navigateOnLastFilter(newState: SceneDataState) { - if (newState.data?.state === LoadingState.Done || newState.data?.state === LoadingState.Streaming) { - // No panels for the user to select, presumably because everything has been excluded - const event = this.state.lastFilterEvent; + const key = this.getTagKey(); - // @todo discuss: Do we want to let users exclude all labels? Or should we redirect when excluding the penultimate panel? - if (event) { - if (event.operator === 'exclude' && newState.data.series.length < 1) { - this.navigateToLabels(); + this._subs.add( + getLabelsVariable(this).subscribeToState(async (newState, prevState) => { + if ( + !areArraysEqual( + newState.filters.filter((f) => key === LEVEL_VARIABLE_VALUE && f.key !== key), + prevState.filters.filter((f) => key === LEVEL_VARIABLE_VALUE && f.key !== key) + ) + ) { + 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.navigateToLabels(); + this._subs.add( + getLevelsVariable(this).subscribeToState(async (newState, prevState) => { + if ( + !areArraysEqual( + newState.filters.filter((f) => key !== LEVEL_VARIABLE_VALUE && f.key !== key), + prevState.filters.filter((f) => key !== LEVEL_VARIABLE_VALUE && f.key !== key) + ) + ) { + this.runQuery(); } + }) + ); + } + + /** + * Since we run this query manually, we want to rebuild it before every execution + */ + private rebuildQuery() { + // Rebuild the query + this.getSceneQueryRunner()?.setState({ + queries: [this.buildQuery()], + }); + } + + /** + * Run the label values breakdown query. + * Generates the filterExpression excluding all filters with a key that matches the label. + */ + private runQuery() { + this.rebuildQuery(); + const queryRunner = this.getSceneQueryRunner(); + queryRunner?.runQueries(); + } + + /** + * Helper method that grabs the SceneQueryRunner for the label value breakdown query. + */ + 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: 'LabelValuesBreakdownScene: Unable to find query runner in value breakdown!' }); + throw error; } + + return queryRunners[0]; + } + logger.warn('LabelValuesBreakdownScene: Query is attempting to execute, but query runner is undefined!'); + return undefined; + } + + /** + * Generates the filterExpression for the label value query and saves it to state. + * We have to manually generate the filterExpression as we want to exclude every filter for the target variable that matches the key used in this value breakdown. + * e.g. in the "cluster" breakdown, we don't want to execute this query containing a cluster filter, or users will only be able to include a single value. + */ + private removeValueLabelFromVariableInterpolation() { + const tagKey = this.getTagKey(); + let filterExpression; + let variableName: typeof VAR_LEVELS | typeof VAR_LABELS; + + if (tagKey === LEVEL_VARIABLE_VALUE) { + const levelsVar = getLevelsVariable(this); + variableName = VAR_LEVELS; + filterExpression = renderLevelsFilter(levelsVar.state.filters, [tagKey]); + } else { + const labelsVar = getLabelsVariable(this); + variableName = VAR_LABELS; + filterExpression = renderLogQLLabelFilters(labelsVar.state.filters, [tagKey]); } + + return { filterExpression, variableName }; + } + + /** + * Helper method to get the key/label name from the variable on the parent scene + */ + private getTagKey() { + const variable = getLabelGroupByVariable(this); + return String(variable.state.value); + } + + /** + * Actions to run when the value breakdown query response is received. + */ + private onValuesDataQueryChange(newState: SceneDataState) { + // Set empty states + this.setEmptyStates(newState); + + // Set error states + this.setErrorStates(newState); } + /** + * Sets the error body state + */ private setErrorStates(newState: SceneDataState) { // If panels have errors if (newState?.data?.errors && newState.data?.state !== LoadingState.Done) { @@ -138,6 +307,9 @@ export class LabelValuesBreakdownScene extends SceneObjectBase 0 && !(this.state.body instanceof LayoutSwitcher)) { @@ -161,6 +333,9 @@ export class LabelValuesBreakdownScene extends SceneObjectBase labelBreakdownScene.state.search.state.filter ?? ''; @@ -238,7 +413,7 @@ export class LabelValuesBreakdownScene extends SceneObjectBase }), - new ValueSummaryPanelScene({ title: tagKey, levelColor: true }), + new ValueSummaryPanelScene({ title: tagKey, levelColor: true, tagKey: this.getTagKey(), type: 'label' }), new SceneReactObject({ reactNode: }), new ByFrameRepeater({ body: new SceneCSSGridLayout({ @@ -270,7 +445,7 @@ export class LabelValuesBreakdownScene extends SceneObjectBase }), - new ValueSummaryPanelScene({ title: tagKey, levelColor: true }), + 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/NumericFilterPopoverScene.tsx b/src/Components/ServiceScene/Breakdowns/NumericFilterPopoverScene.tsx index c564aa103..0eff9db9c 100644 --- a/src/Components/ServiceScene/Breakdowns/NumericFilterPopoverScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/NumericFilterPopoverScene.tsx @@ -6,7 +6,7 @@ import { css, cx } from '@emotion/css'; import { SelectLabelActionScene } from './SelectLabelActionScene'; import { addNumericFilter, - removeFilter, + removeNumericFilter, validateVariableNameForField, InterpolatedFilterType, } from './AddToFiltersButton'; @@ -185,7 +185,12 @@ export class NumericFilterPopoverScene extends SceneObjectBase { constructor(state: ValueSummaryPanelSceneState) { @@ -47,6 +69,10 @@ export class ValueSummaryPanelScene extends SceneObjectBase this.extendTimeSeriesLegendBus(context), + }); + this.setState({ body: new SceneFlexLayout({ key: VALUE_SUMMARY_PANEL_KEY, @@ -78,6 +104,149 @@ export class ValueSummaryPanelScene extends SceneObjectBase { + const $data = sceneGraph.getData(this); + const dataFrame = $data.state.data?.series; + const key = this.state.tagKey; + + 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('Cannot find VizPanel'); + } + + this.initLegendOptions(dataFrame, key, panel); + + if (this.state.type === 'label') { + this._subs.add(this.getLabelsVariableLegendSyncSubscription(key)); + } else { + this._subs.add(this.getFieldsVariableLegendSyncSubscription(key, getFieldsVariable(this))); + this._subs.add(this.getFieldsVariableLegendSyncSubscription(key, getMetadataVariable(this))); + } + + this._subs.add(this.getQuerySubscription(key, $data, panel)); + + context.onToggleSeriesVisibility = (value: string, mode: SeriesVisibilityChangeMode) => { + let action: FilterType; + if (this.state.type === 'label') { + if (key === LEVEL_VARIABLE_VALUE) { + action = toggleLevelFromFilter(value, this); + } else { + action = toggleLabelFromFilter(key, value, this); + } + } else { + action = toggleFieldFromFilter(key, value, this); + } + + reportAppInteraction( + USER_EVENTS_PAGES.service_details, + USER_EVENTS_ACTIONS.service_details.label_in_panel_summary_clicked, + { + label: value, + action, + } + ); + }; + }; + + /** + * Sync legend with current dataframe + */ + private initLegendOptions(dataFrame: DataFrame[] | undefined, key: string, panel: VizPanel<{}, {}>) { + if (dataFrame) { + if (this.state.type === 'label') { + if (key === LEVEL_VARIABLE_VALUE) { + syncLevelsVisibleSeries(panel, dataFrame, this); + } else { + syncLabelsValueSummaryVisibleSeries(key, panel, dataFrame, this); + } + } else { + syncFieldsValueSummaryVisibleSeries(key, panel, dataFrame, this); + } + } + } + + /** + * Sync visible series on dataframe update + */ + private getQuerySubscription(key: string, $data: SceneDataProvider, panel: VizPanel<{}, {}>) { + return $data.subscribeToState((newState, prevState) => { + if (newState.data?.state === LoadingState.Done) { + 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 for labels + */ + private getLabelsVariableLegendSyncSubscription(key: string) { + const isLevel = key === LEVEL_VARIABLE_VALUE; + const variable = isLevel ? getLevelsVariable(this) : getLabelsVariable(this); + 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 - getLabelsVariableLegendSyncSubscription: Cannot find VizPanel'); + } + + const $data = sceneGraph.getData(this); + const dataFrame = $data.state.data?.series; + + if (!dataFrame) { + logger.warn('ValueSummary - getLabelsVariableLegendSyncSubscription: missing dataframe!'); + return; + } + + if (isLevel) { + syncLevelsVisibleSeries(panel, dataFrame, this); + } else { + syncLabelsValueSummaryVisibleSeries(key, panel, dataFrame, this); + } + }); + } } export function setValueSummaryHeight(vizPanelFlexLayout: SceneFlexLayout, collapsableState: CollapsablePanelText) { @@ -107,10 +276,10 @@ function buildValueSummaryPanel(title: string, options?: { levelColor?: boolean .setCustomFieldConfig('fillOpacity', 100) .setCustomFieldConfig('lineWidth', 0) .setCustomFieldConfig('pointSize', 0) + .setCustomFieldConfig('drawStyle', DrawStyle.Bars) // 11.5 // .setShowMenuAlways(true) - .setSeriesLimit(SUMMARY_PANEL_SERIES_LIMIT) - .setCustomFieldConfig('drawStyle', DrawStyle.Bars); + .setSeriesLimit(SUMMARY_PANEL_SERIES_LIMIT); if (options?.levelColor) { body.setOverrides(setLevelColorOverrides); diff --git a/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx b/src/Components/ServiceScene/Breakdowns/SelectLabelActionScene.tsx index 3a123f4d7..fe1878266 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 e691c076f..f5391e503 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/LogsTableScene.tsx b/src/Components/ServiceScene/LogsTableScene.tsx index a0c8819c5..55f9e6b7d 100644 --- a/src/Components/ServiceScene/LogsTableScene.tsx +++ b/src/Components/ServiceScene/LogsTableScene.tsx @@ -12,6 +12,8 @@ import { getLogsPanelFrame } from './ServiceScene'; import { getVariableForLabel } from '../../services/fields'; import { PanelMenu } from '../Panels/PanelMenu'; import { LogLineState } from '../Table/Context/TableColumnsContext'; +import { LEVEL_VARIABLE_VALUE } from '../../services/variables'; +import { LevelsVariableScene } from '../IndexScene/LevelsVariableScene'; interface LogsTableSceneState extends SceneObjectState { menu?: PanelMenu; @@ -53,6 +55,14 @@ export class LogsTableScene extends SceneObjectBase { const addFilter = (filter: AdHocVariableFilter) => { const variableType = getVariableForLabel(dataFrame, filter.key, model); addAdHocFilter(filter, parentModel, variableType); + + // Update levels variable when adding filter from table + if (filter.key === LEVEL_VARIABLE_VALUE) { + const levelsVariableScene = sceneGraph.findObject(model, (obj) => obj instanceof LevelsVariableScene); + if (levelsVariableScene instanceof LevelsVariableScene) { + levelsVariableScene.onFilterChange(); + } + } }; // Get reference to panel wrapper so table knows how much space it can use to render diff --git a/src/Components/ServiceScene/LogsVolumePanel.tsx b/src/Components/ServiceScene/LogsVolumePanel.tsx index edbe39c2c..1cef126dc 100644 --- a/src/Components/ServiceScene/LogsVolumePanel.tsx +++ b/src/Components/ServiceScene/LogsVolumePanel.tsx @@ -10,7 +10,7 @@ import { VizPanel, } from '@grafana/scenes'; import { LegendDisplayMode, PanelContext, SeriesVisibilityChangeMode, useStyles2 } from '@grafana/ui'; -import { getQueryRunner, setLogsVolumeFieldConfigs, syncLogsPanelVisibleSeries } from 'services/panel'; +import { getQueryRunner, setLogsVolumeFieldConfigs, syncLevelsVisibleSeries } from 'services/panel'; import { buildDataQuery, LINE_LIMIT } from 'services/query'; import { LEVEL_VARIABLE_VALUE } from 'services/variables'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; @@ -25,6 +25,8 @@ import { getSeriesVisibleRange, getVisibleRangeFrame } from 'services/logsFrame' import { getLogsVolumeOption, setLogsVolumeOption } from 'services/store'; import { IndexScene } from '../IndexScene/IndexScene'; import { LogsVolumeActions } from './LogsVolumeActions'; +import { AddFilterEvent } from './Breakdowns/AddToFiltersButton'; +import { LevelsVariableScene } from '../IndexScene/LevelsVariableScene'; export interface LogsVolumePanelState extends SceneObjectState { panel?: VizPanel; @@ -54,21 +56,42 @@ export class LogsVolumePanel extends SceneObjectBase { const labels = getLabelsVariable(this); const fields = getFieldsVariable(this); - labels.subscribeToState((newState, prevState) => { - if (!areArraysEqual(newState.filters, prevState.filters)) { - this.setState({ - panel: this.getVizPanel(), - }); - } - }); + // Set panel on labels variable filter update + this._subs.add( + labels.subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.setState({ + panel: this.getVizPanel(), + }); + } + }) + ); - fields.subscribeToState((newState, prevState) => { - if (!areArraysEqual(newState.filters, prevState.filters)) { - this.setState({ - panel: this.getVizPanel(), - }); - } - }); + // Set Panel on fields variable filter update + this._subs.add( + fields.subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.setState({ + panel: this.getVizPanel(), + }); + } + }) + ); + + // trigger variable render on AddFilterEvent, set filter state to trigger logs panel query + this._subs.add( + this.subscribeToEvent(AddFilterEvent, (event) => { + if (event.key === LEVEL_VARIABLE_VALUE) { + const levelsVariableScene = sceneGraph.findObject(this, (obj) => obj instanceof LevelsVariableScene); + if (levelsVariableScene instanceof LevelsVariableScene) { + levelsVariableScene.onFilterChange(); + + const levelsVar = getLevelsVariable(this); + levelsVar.setState({ filters: levelsVar.state.filters }); + } + } + }) + ); } private getTitle(totalLogsCount: number | undefined, logsCount: number | undefined) { @@ -134,7 +157,7 @@ export class LogsVolumePanel extends SceneObjectBase { } else { this.displayVisibleRange(); } - syncLogsPanelVisibleSeries(panel, newState.data.series, this); + syncLevelsVisibleSeries(panel, newState.data.series, this); }) ); @@ -209,23 +232,19 @@ export class LogsVolumePanel extends SceneObjectBase { return; } - syncLogsPanelVisibleSeries(panel, panel?.state.$data?.state.data?.series, this); + syncLevelsVisibleSeries(panel, panel?.state.$data?.state.data?.series, this); }) ); - context.onToggleSeriesVisibility = (level: string, mode: SeriesVisibilityChangeMode) => { - // @TODO. We don't yet support filters with multiple values. - if (mode === SeriesVisibilityChangeMode.AppendToSelection) { - return; - } - - const action = toggleLevelFromFilter(level, this); + context.onToggleSeriesVisibility = (label: string, mode: SeriesVisibilityChangeMode) => { + const action = toggleLevelFromFilter(label, this); + this.publishEvent(new AddFilterEvent('legend', 'include', LEVEL_VARIABLE_VALUE, label), true); reportAppInteraction( USER_EVENTS_PAGES.service_details, USER_EVENTS_ACTIONS.service_details.level_in_logs_volume_clicked, { - level, + level: label, action, } ); diff --git a/src/Components/ServiceScene/ServiceScene.tsx b/src/Components/ServiceScene/ServiceScene.tsx index fc9d562cd..1e44b5488 100644 --- a/src/Components/ServiceScene/ServiceScene.tsx +++ b/src/Components/ServiceScene/ServiceScene.tsx @@ -318,7 +318,8 @@ export class ServiceScene extends SceneObjectBase { this.setSubscribeToLabelsVariable(); this._subs.add(this.subscribeToFieldsVariable()); this._subs.add(this.subscribeToMetadataVariable()); - this._subs.add(this.subscribeToLevelsVariable()); + this._subs.add(this.subscribeToLevelsVariableChangedEvent()); + this._subs.add(this.subscribeToLevelsVariableFiltersState()); this._subs.add(this.subscribeToDataSourceVariable()); this._subs.add(this.subscribeToPatternsVariable()); this._subs.add(this.subscribeToLineFiltersVariable()); @@ -363,7 +364,8 @@ export class ServiceScene extends SceneObjectBase { } private subscribeToFieldsVariable() { - return getFieldsVariable(this).subscribeToState((newState, prevState) => { + const fieldsVar = getFieldsVariable(this); + return fieldsVar.subscribeToState((newState, prevState) => { if (!areArraysEqual(newState.filters, prevState.filters)) { this.state.$detectedFieldsData?.runQueries(); this.state.$logsCount?.runQueries(); @@ -372,7 +374,8 @@ export class ServiceScene extends SceneObjectBase { } private subscribeToMetadataVariable() { - return getMetadataVariable(this).subscribeToState((newState, prevState) => { + const metadataVar = getMetadataVariable(this); + return metadataVar.subscribeToState((newState, prevState) => { if (!areArraysEqual(newState.filters, prevState.filters)) { this.state.$detectedFieldsData?.runQueries(); this.state.$logsCount?.runQueries(); @@ -384,10 +387,22 @@ export class ServiceScene extends SceneObjectBase { * Subscribe to SceneVariableValueChangedEvent and run logs count and detectedFields on update. * In the levels variable renderer we update the ad-hoc filters, but we don't always want to immediately execute queries. */ - private subscribeToLevelsVariable() { + private subscribeToLevelsVariableChangedEvent() { return getLevelsVariable(this).subscribeToEvent(SceneVariableValueChangedEvent, () => { this.state.$detectedFieldsData?.runQueries(); - this.state.$logsCount?.runQueries(); + }); + } + + /** + * Subscribe to actual filter changes and update the logs count + * @private + */ + private subscribeToLevelsVariableFiltersState() { + const levelsVariable = getLevelsVariable(this); + return levelsVariable.subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.state.$logsCount?.runQueries(); + } }); } diff --git a/src/Components/ServiceSelectionScene/AddLabelToFiltersHeaderActionScene.tsx b/src/Components/ServiceSelectionScene/AddLabelToFiltersHeaderActionScene.tsx index 5db0d4bd5..228842344 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/Components/ServiceSelectionScene/ServiceSelectionScene.tsx b/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx index 137fe02f6..e594af049 100644 --- a/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx +++ b/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx @@ -61,7 +61,7 @@ import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'se import { getQueryRunner, getSceneQueryRunner, setLevelColorOverrides } from 'services/panel'; import { ConfigureVolumeError } from './ConfigureVolumeError'; import { NoServiceSearchResults } from './NoServiceSearchResults'; -import { getLabelsFromSeries, toggleLevelVisibility } from 'services/levels'; +import { getLevelLabelsFromSeries, toggleLevelVisibility } from 'services/levels'; import { ServiceFieldSelector } from '../ServiceScene/Breakdowns/FieldSelector'; import { CustomConstantVariable } from '../../services/CustomConstantVariable'; import { areArraysEqual } from '../../services/comparison'; @@ -1022,7 +1022,7 @@ export class ServiceSelectionScene extends SceneObjectBase { originalOnToggleSeriesVisibility?.(level, mode); - const allLevels = getLabelsFromSeries(panel.state.$data?.state.data?.series ?? []); + const allLevels = getLevelLabelsFromSeries(panel.state.$data?.state.data?.series ?? []); const levels = toggleLevelVisibility(level, this.state.serviceLevel.get(labelValue), mode, allLevels); this.state.serviceLevel.set(labelValue, levels); diff --git a/src/services/ExpressionBuilder.test.ts b/src/services/ExpressionBuilder.test.ts index 03f72ec1f..ee56ac6d5 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[] = [ @@ -278,6 +312,35 @@ describe('renderLogQLFieldFilters', () => { }); describe('renderLogQLLabelFilters', () => { + describe('excluding keys', () => { + it('should not remove the only include filter', () => { + const filters: AdHocVariableFilter[] = [ + { + key: 'service', + operator: FilterOp.Equal, + value: 'service-1', + }, + ]; + + expect(renderLogQLLabelFilters(filters, ['service'])).toEqual('service="service-1"'); + }); + it('should remove filters matching ignore keys', () => { + const filters: AdHocVariableFilter[] = [ + { + key: 'service', + operator: FilterOp.Equal, + value: 'service-1', + }, + { + key: 'cluster', + operator: FilterOp.Equal, + value: 'us-east-1', + }, + ]; + + expect(renderLogQLLabelFilters(filters, ['cluster'])).toEqual('service="service-1"'); + }); + }); test('Renders positive filters', () => { const filters: AdHocVariableFilter[] = [ { @@ -317,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', () => { @@ -657,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 2acfe45aa..99cb6c841 100644 --- a/src/services/ExpressionBuilder.ts +++ b/src/services/ExpressionBuilder.ts @@ -41,14 +41,25 @@ interface Options { * Sets if the values are JSON encoded */ decodeFilters: boolean; + + /** + * Keys to ignore + */ + ignoreKeys?: string[]; + + filterType: 'indexed' | 'field'; } export class ExpressionBuilder { private filters: AdHocFilterWithLabels[]; private options: Options; - private valueSeparator = 'or'; + private positiveFilterValueSeparator = 'or'; + private negativeFilterValueSeparator = '|'; - constructor(filters: AdHocFilterWithLabels[], options: Options = { joinMatchFilters: true, decodeFilters: false }) { + constructor( + filters: AdHocFilterWithLabels[], + options: Options = { joinMatchFilters: true, decodeFilters: false, filterType: 'field' } + ) { this.filters = filters; this.options = options; if (!this.options.debug) { @@ -83,7 +94,7 @@ export class ExpressionBuilder { * Returns logQL expression for AdHocFilterWithLabels[] * Merges multiple include matches into regex */ - public getLabelsExpr(): string { + protected getExpr(): string { let { equalsFilters, notEqualsFilters, @@ -127,50 +138,57 @@ export class ExpressionBuilder { return ''; } + public getLabelsExpr(options?: Partial): string { + const defaultOptions: Options = { joinMatchFilters: true, decodeFilters: false, filterType: 'indexed' }; + this.options = { ...defaultOptions, ...options }; + return this.getExpr(); + } + /** * Returns merged filters separated by pipe */ - public getMetadataExpr( - options: Options = { + public getMetadataExpr(options?: Partial): string { + const defaultOptions: Options = { filterSeparator: ' |', prefix: '| ', joinMatchFilters: false, decodeFilters: false, - } - ): string { - this.options = options; - return this.getLabelsExpr(); + filterType: 'field', + }; + this.options = { ...defaultOptions, ...options }; + return this.getExpr(); } /** * Same as metadata, but only include operators supported */ - public getLevelsExpr( - options: Options = { + public getLevelsExpr(options?: Partial): string { + const defaultOptions: Options = { filterSeparator: ' |', prefix: '| ', joinMatchFilters: false, decodeFilters: false, - } - ): string { - this.options = options; - return this.getLabelsExpr(); + filterType: 'field', + }; + + this.options = { ...defaultOptions, ...options }; + return this.getExpr(); } /** * Returns merged filters separated by pipe * JSON encodes value */ - public getFieldsExpr( - options: Options = { + public getFieldsExpr(options?: Partial): string { + const defaultOptions: Options = { filterSeparator: ' |', prefix: '| ', joinMatchFilters: false, decodeFilters: true, - } - ): string { - this.options = options; - return this.getLabelsExpr(); + filterType: 'field', + }; + this.options = { ...defaultOptions, ...options }; + return this.getExpr(); } /** @@ -260,7 +278,7 @@ export class ExpressionBuilder { const allFiltersString = trim(this.combineValues(allFilters, `${this.options.filterSeparator ?? ','} `)); if (this.options.debug) { - console.info('labels expr', { allFiltersString }); + console.info('DEBUG labels expr', { allFiltersString }); } return allFiltersString; @@ -361,7 +379,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; @@ -603,22 +625,33 @@ export class ExpressionBuilder { * Groups all filters by operator and key */ private groupFiltersByKey(filters: AdHocVariableFilter[]): Record> { - const positiveMatch = filters.filter( + 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.filterType === 'indexed') { + if (filteredFilters.length < 1) { + filteredFilters = filters; + } + } + + const positiveMatch = filteredFilters.filter( (filter) => isOperatorInclusive(filter.operator) && !isOperatorRegex(filter.operator) ); - const positiveRegex = filters.filter( + const positiveRegex = filteredFilters.filter( (filter) => isOperatorInclusive(filter.operator) && isOperatorRegex(filter.operator) ); - const negativeMatch = filters.filter( + const negativeMatch = filteredFilters.filter( (filter) => isOperatorExclusive(filter.operator) && !isOperatorRegex(filter.operator) ); - const negativeRegex = filters.filter( + const negativeRegex = filteredFilters.filter( (filter) => isOperatorExclusive(filter.operator) && isOperatorRegex(filter.operator) ); - const gt = filters.filter((filter) => filter.operator === FilterOp.gt); - const gte = filters.filter((filter) => filter.operator === FilterOp.gte); - const lt = filters.filter((filter) => filter.operator === FilterOp.lt); - const lte = filters.filter((filter) => filter.operator === FilterOp.lte); + const gt = filteredFilters.filter((filter) => filter.operator === FilterOp.gt); + const gte = filteredFilters.filter((filter) => filter.operator === FilterOp.gte); + const lt = filteredFilters.filter((filter) => filter.operator === FilterOp.lt); + const lte = filteredFilters.filter((filter) => filter.operator === FilterOp.lte); // Field ops const positiveMatchGroup = groupBy(positiveMatch, (filter) => filter.key); diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 4ec498899..2679e1466 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -47,6 +47,7 @@ export const USER_EVENTS_ACTIONS = { select_field_in_breakdown_clicked: 'select_field_in_breakdown_clicked', // Clicking on one of the levels in the Logs Volume panel level_in_logs_volume_clicked: 'level_in_logs_volume_clicked', + label_in_panel_summary_clicked: 'label_in_panel_summary_clicked', // Changing layout type (e.g. single/grid/rows). Used in multiple views. The view type is passed as a parameter. Props: layout, view layout_type_changed: 'layout_type_changed', // Changing search string in logs. Props: searchQuery diff --git a/src/services/fields.ts b/src/services/fields.ts index e74523469..f6002e2ca 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 })]); + .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 000000000..4bda274fb --- /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 8666d405b..09ccfb16d 100644 --- a/src/services/labels.ts +++ b/src/services/labels.ts @@ -1,8 +1,19 @@ -import { SceneObject } from '@grafana/scenes'; -import { LEVEL_VARIABLE_VALUE } 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, 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'; +import { DataFrame } from '@grafana/data'; export const LABEL_BREAKDOWN_GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; @@ -28,3 +39,77 @@ export function buildLabelsQuery(sceneRef: SceneObject, optionValue: string, opt { legendFormat: `{{${optionValue}}}`, refId: 'LABEL_BREAKDOWN_VALUES' } ); } + +export function getLabelsFromSeries(series: DataFrame[]): string[] { + const labels = series.map((dataFrame) => getLabelValueFromDataFrame(dataFrame)); + 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; + const filterExists = labelsVariable.state.filters.find( + (filter) => filter.value === value && isOperatorInclusive(filter.operator) + ); + + if (empty || !filterExists) { + addToFilters(key, value, 'include', sceneRef, VAR_LABELS); + return 'include'; + } else { + addToFilters(key, value, 'toggle', sceneRef, VAR_LABELS); + return 'toggle'; + } +} + +export function getVisibleLabels(key: string, allLabels: string[], sceneRef: SceneObject) { + const labelsVariable = getLabelsVariable(sceneRef); + 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) => (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) => (variable.state.name === VAR_FIELDS ? getValueFromFieldsFilter(filter).value : filter.value)); + + return allLabels.filter((label) => { + if (exclusiveLabels.includes(label)) { + return false; + } + return inclusiveFilters.length === 0 || inclusiveFilters.includes(label); + }); +} diff --git a/src/services/levels.test.ts b/src/services/levels.test.ts index 19e857ce0..c80de257f 100644 --- a/src/services/levels.test.ts +++ b/src/services/levels.test.ts @@ -1,5 +1,5 @@ import { SeriesVisibilityChangeMode } from '@grafana/ui'; -import { getLabelsFromSeries, getVisibleLevels, toggleLevelFromFilter, toggleLevelVisibility } from './levels'; +import { getLevelLabelsFromSeries, getVisibleLevels, toggleLevelFromFilter, toggleLevelVisibility } from './levels'; import { AdHocVariableFilter, FieldType, toDataFrame } from '@grafana/data'; import { VAR_LEVELS } from './variables'; import { AdHocFiltersVariable, SceneObject } from '@grafana/scenes'; @@ -99,7 +99,7 @@ describe('getLabelsFromSeries', () => { }), ]; it('returns the label value from time series', () => { - expect(getLabelsFromSeries(series)).toEqual(['error', 'warn', 'logs']); + expect(getLevelLabelsFromSeries(series)).toEqual(['error', 'warn', 'logs']); }); }); @@ -261,42 +261,45 @@ describe('toggleLevelFromFilter', () => { it('Sets the filter when it is empty', () => { setup([]); - expect(toggleLevelFromFilter('info', scene)).toBe('add'); - expect(replaceFilter).toHaveBeenCalledTimes(1); + expect(toggleLevelFromFilter('info', scene)).toBe('include'); + expect(addToFilters).toHaveBeenCalledTimes(1); }); it('Overwrites the filter if exists with a different value', () => { setup([{ key: 'detected_level', operator: FilterOp.Equal, value: 'error' }]); - expect(toggleLevelFromFilter('info', scene)).toBe('add'); - expect(replaceFilter).toHaveBeenCalledTimes(1); + expect(toggleLevelFromFilter('info', scene)).toBe('include'); + expect(addToFilters).toHaveBeenCalledTimes(1); }); it('Toggles it off if the filter with the same value exists', () => { setup([{ key: 'detected_level', operator: FilterOp.Equal, value: 'info' }]); - expect(toggleLevelFromFilter('info', scene)).toBe('remove'); + expect(toggleLevelFromFilter('info', scene)).toBe('toggle'); expect(addToFilters).toHaveBeenCalledTimes(1); }); it('Toggles it off if a regex filter with the exact value exists', () => { - setup([{ key: 'detected_level', operator: FilterOp.RegexEqual, value: 'info' }]); + setup([{ key: 'detected_level', operator: FilterOp.Equal, value: 'info' }]); - expect(toggleLevelFromFilter('info', scene)).toBe('remove'); + expect(toggleLevelFromFilter('info', scene)).toBe('toggle'); expect(addToFilters).toHaveBeenCalledTimes(1); }); it('Toggles it off if a regex filter containing the value exists', () => { - setup([{ key: 'detected_level', operator: FilterOp.RegexEqual, value: 'info|warn' }]); + setup([ + { key: 'detected_level', operator: FilterOp.Equal, value: 'info' }, + { key: 'detected_level', operator: FilterOp.Equal, value: 'warn' }, + ]); - expect(toggleLevelFromFilter('info', scene)).toBe('remove'); + expect(toggleLevelFromFilter('info', scene)).toBe('toggle'); expect(addToFilters).toHaveBeenCalledTimes(1); }); it('Handles empty log levels', () => { setup([]); - expect(toggleLevelFromFilter('logs', scene)).toBe('add'); - expect(replaceFilter).toHaveBeenCalledWith(LEVEL_NAME, '""', 'include', scene, VAR_LEVELS); + expect(toggleLevelFromFilter('logs', scene)).toBe('include'); + expect(addToFilters).toHaveBeenCalledWith(LEVEL_NAME, '""', 'include', scene, VAR_LEVELS); }); }); diff --git a/src/services/levels.ts b/src/services/levels.ts index 0194fd917..5d7a70a09 100644 --- a/src/services/levels.ts +++ b/src/services/levels.ts @@ -2,7 +2,7 @@ import { DataFrame } from '@grafana/data'; import { SeriesVisibilityChangeMode } from '@grafana/ui'; import { LEVEL_VARIABLE_VALUE, VAR_LEVELS } from './variables'; import { SceneObject } from '@grafana/scenes'; -import { addToFilters, replaceFilter } from 'Components/ServiceScene/Breakdowns/AddToFiltersButton'; +import { addToFilters, FilterType } from 'Components/ServiceScene/Breakdowns/AddToFiltersButton'; import { getLevelsVariable } from './variableGetters'; import { isOperatorExclusive, isOperatorInclusive } from './operatorHelpers'; @@ -36,7 +36,7 @@ export function toggleLevelVisibility( return [...levels, level]; } -export function getLabelsFromSeries(series: DataFrame[]) { +export function getLevelLabelsFromSeries(series: DataFrame[]) { return series.map((dataFrame) => getLabelValueFromDataFrame(dataFrame) ?? 'logs'); } @@ -90,25 +90,22 @@ function normalizeLevelName(level: string) { * If the filter exists but it's different, it's replaced. * If the filter exists, it's removed. */ -export function toggleLevelFromFilter(level: string, sceneRef: SceneObject) { +export function toggleLevelFromFilter(level: string, sceneRef: SceneObject): FilterType { const levelFilter = getLevelsVariable(sceneRef); const empty = levelFilter.state.filters.length === 0; const filterExists = levelFilter.state.filters.find( - (filter) => filter.value.split('|').includes(level) && isOperatorInclusive(filter.operator) + (filter) => filter.value === level && isOperatorInclusive(filter.operator) ); if (level === 'logs') { level = '""'; } - let action; if (empty || !filterExists) { - replaceFilter(LEVEL_VARIABLE_VALUE, level, 'include', sceneRef, VAR_LEVELS); - action = 'add'; + addToFilters(LEVEL_VARIABLE_VALUE, level, 'include', sceneRef, VAR_LEVELS); + return 'include'; } else { addToFilters(LEVEL_VARIABLE_VALUE, level, 'toggle', sceneRef, VAR_LEVELS); - action = 'remove'; + return 'toggle'; } - - return action; } diff --git a/src/services/panel.ts b/src/services/panel.ts index 631425b93..1f865a589 100644 --- a/src/services/panel.ts +++ b/src/services/panel.ts @@ -23,10 +23,12 @@ import { HideSeriesConfig, LogsSortOrder } from '@grafana/schema'; import { WRAPPED_LOKI_DS_UID } from './datasource'; import { LogsSceneQueryRunner } from './LogsSceneQueryRunner'; import { DrawStyle, StackingMode } from '@grafana/ui'; -import { getLabelsFromSeries, getVisibleLevels } from './levels'; +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, getVisibleFields, getVisibleLabels, getVisibleMetadata } from './labels'; +import { getParserForField } from './fields'; const UNKNOWN_LEVEL_LOGS = 'logs'; export function setLevelColorOverrides(overrides: FieldConfigOverridesBuilder) { @@ -65,17 +67,28 @@ export function setLogsVolumeFieldConfigs( .setOverrides(setLevelColorOverrides); } +export function setValueSummaryFieldConfigs( + builder: ReturnType | ReturnType +) { + return builder + .setCustomFieldConfig('stacking', { mode: StackingMode.Normal }) + .setCustomFieldConfig('fillOpacity', 100) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('pointSize', 0) + .setCustomFieldConfig('drawStyle', DrawStyle.Bars); +} + interface TimeSeriesFieldConfig extends FieldConfig { hideFrom: HideSeriesConfig; } -export function setLevelSeriesOverrides(levels: string[], overrideConfig: FieldConfigOverridesBuilder) { +export function setLabelSeriesOverrides(labels: string[], overrideConfig: FieldConfigOverridesBuilder) { overrideConfig .match({ id: FieldMatcherID.byNames, options: { mode: 'exclude', - names: levels, + names: labels, prefix: 'All except:', readOnly: true, }, @@ -92,17 +105,68 @@ export function setLevelSeriesOverrides(levels: string[], overrideConfig: FieldC overrides[overrides.length - 1].__systemRef = 'hideSeriesFrom'; } -export function syncLogsPanelVisibleSeries(panel: VizPanel, series: DataFrame[], sceneRef: SceneObject) { - const focusedLevels = getVisibleLevels(getLabelsFromSeries(series), sceneRef); - if (focusedLevels?.length) { - const config = setLogsVolumeFieldConfigs(FieldConfigBuilders.timeseries()).setOverrides( - setLevelSeriesOverrides.bind(null, focusedLevels) - ); - if (config instanceof FieldConfigBuilder) { - panel.onFieldConfigChange(config.build(), true); - } +/** + * Sets labels series visibility in the panel + */ +export function syncLevelsVisibleSeries(panel: VizPanel, series: DataFrame[], sceneRef: SceneObject) { + const focusedLevels = getVisibleLevels(getLevelLabelsFromSeries(series), sceneRef); + const config = setLogsVolumeFieldConfigs(FieldConfigBuilders.timeseries()).setOverrides( + setLabelSeriesOverrides.bind(null, focusedLevels) + ); + if (config instanceof FieldConfigBuilder) { + panel.onFieldConfigChange(config.build(), true); + } +} + +/** + * @todo unit test + * Set levels series visibility in the panel + */ +export function syncLabelsValueSummaryVisibleSeries( + key: string, + panel: VizPanel, + series: DataFrame[], + sceneRef: SceneObject +) { + const allLabels = getLabelsFromSeries(series); + const focusedLabels = getVisibleLabels(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); } } + +/** + * 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/query.ts b/src/services/query.ts index f2f11180f..d55847478 100644 --- a/src/services/query.ts +++ b/src/services/query.ts @@ -66,9 +66,9 @@ export const buildVolumeQuery = ( return buildResourceQuery(expr, resource, { ...queryParamsOverrides }, primaryLabel); }; -export function renderLogQLLabelFilters(filters: AdHocFilterWithLabels[]) { +export function renderLogQLLabelFilters(filters: AdHocFilterWithLabels[], ignoreKeys?: string[]) { const filtersTransformer = new ExpressionBuilder(filters); - return filtersTransformer.getLabelsExpr(); + return filtersTransformer.getLabelsExpr({ ignoreKeys }); } export function onAddCustomAdHocValue(item: SelectableValue): { @@ -111,19 +111,19 @@ export function onAddCustomFieldValue( }; } -export function renderLevelsFilter(filters: AdHocVariableFilter[]) { +export function renderLevelsFilter(filters: AdHocVariableFilter[], ignoreKeys?: string[]) { const filterTransformer = new ExpressionBuilder(filters); - return filterTransformer.getLevelsExpr(); + return filterTransformer.getLevelsExpr({ ignoreKeys }); } -export function renderLogQLMetadataFilters(filters: AdHocVariableFilter[]) { +export function renderLogQLMetadataFilters(filters: AdHocVariableFilter[], ignoreKeys?: string[]) { const filterTransformer = new ExpressionBuilder(filters); - return filterTransformer.getMetadataExpr(); + return filterTransformer.getMetadataExpr({ ignoreKeys }); } -export function renderLogQLFieldFilters(filters: AdHocVariableFilter[]) { +export function renderLogQLFieldFilters(filters: AdHocVariableFilter[], ignoreKeys?: string[]) { const filterTransformer = new ExpressionBuilder(filters); - return filterTransformer.getFieldsExpr(); + return filterTransformer.getFieldsExpr({ ignoreKeys }); } export function escapeDoubleQuotedLineFilter(filter: AdHocFilterWithLabels) { diff --git a/src/services/variableGetters.ts b/src/services/variableGetters.ts index 273a2e93b..6bea3e703 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/exploreServices.spec.ts b/tests/exploreServices.spec.ts index 6745841c6..cd29dde61 100644 --- a/tests/exploreServices.spec.ts +++ b/tests/exploreServices.spec.ts @@ -141,6 +141,7 @@ test.describe('explore services page', () => { await expect(page.getByText(/level=info/).first()).toBeVisible(); await page.getByTitle('debug').first().click(); await expect(page.getByText(/level=debug/).first()).toBeVisible(); + await expect.poll(() => page.getByText(/level=info/).count()).toBe(0); await expect(page.getByText(/level=info/)).not.toBeVisible(); }); diff --git a/tests/exploreServicesBreakDown.spec.ts b/tests/exploreServicesBreakDown.spec.ts index 3f93caa1c..c11a60eee 100644 --- a/tests/exploreServicesBreakDown.spec.ts +++ b/tests/exploreServicesBreakDown.spec.ts @@ -52,6 +52,26 @@ test.describe('explore services breakdown page', () => { await expect(selectClusterButton).toHaveCount(1); await page.getByLabel(`Select ${labelName}`).click(); + // include eu-west-1 cluster + const includeCluster = 'eu-west-1'; + const clusterIncludeSelectButton = page + .getByTestId(`data-testid Panel header ${includeCluster}`) + .getByTestId('data-testid button-filter-include'); + await expect(clusterIncludeSelectButton).toHaveCount(1); + await clusterIncludeSelectButton.click(); + + // include us-west-1 cluster + const includeCluster2 = 'us-west-1'; + const cluster2IncludeSelectButton = page + .getByTestId(`data-testid Panel header ${includeCluster2}`) + .getByTestId('data-testid button-filter-include'); + await expect(clusterIncludeSelectButton).toHaveCount(1); + await cluster2IncludeSelectButton.click(); + + // assert there are 2 includes selected + await expect(clusterIncludeSelectButton).toHaveAttribute('aria-selected', 'true'); + await expect(cluster2IncludeSelectButton).toHaveAttribute('aria-selected', 'true'); + // exclude "us-east-1" cluster const excludeCluster = 'us-east-1'; const clusterExcludeSelectButton = page @@ -60,13 +80,18 @@ test.describe('explore services breakdown page', () => { await expect(clusterExcludeSelectButton).toHaveCount(1); await clusterExcludeSelectButton.click(); - // include eu-west-1 cluster - const includeCluster = 'eu-west-1'; - const clusterIncludeSelectButton = page - .getByTestId(`data-testid Panel header ${includeCluster}`) - .getByTestId('data-testid button-filter-include'); - await expect(clusterIncludeSelectButton).toHaveCount(1); + // assert the includes were removed, exclude is shown + await expect(clusterIncludeSelectButton).not.toHaveAttribute('aria-selected', 'true'); + await expect(cluster2IncludeSelectButton).not.toHaveAttribute('aria-selected', 'true'); + await expect(clusterExcludeSelectButton).toHaveAttribute('aria-selected', 'true'); + + // Add an include which should remove exclude button await clusterIncludeSelectButton.click(); + await expect(clusterExcludeSelectButton).not.toHaveAttribute('aria-selected', 'true'); + await expect(clusterIncludeSelectButton).toHaveAttribute('aria-selected', 'true'); + + // Navigate to labels tab + await explorePage.goToLabelsTab(); // Include should navigate us back to labels tab await explorePage.assertTabsNotLoading(); @@ -149,10 +174,10 @@ test.describe('explore services breakdown page', () => { await expect(clusterExcludeFilter).toHaveText('cluster != us-east-1'); // Assert remaining two us-.+ cluster values are showing - await expect(page.getByTestId(/data-testid Panel header us-.+/)).toHaveCount(2); + await expect(page.getByTestId(/data-testid Panel header us-.+/)).toHaveCount(3); // Assert there are only 3 panels (2 value panels + summary panel) - await expect(page.getByTestId(/data-testid Panel header/)).toHaveCount(3); + await expect(page.getByTestId(/data-testid Panel header/)).toHaveCount(4); }); test('logs panel should have panel-content class suffix', async ({ page }) => { @@ -340,6 +365,7 @@ test.describe('explore services breakdown page', () => { await expect(panels.first()).toBeVisible(); const panelTitles: Array = []; + await expect.poll(() => panels.count()).toBeGreaterThanOrEqual(5); for (const panel of await panels.all()) { const panelTitle = await panel.getByRole('heading').textContent(); panelTitles.push(panelTitle); @@ -363,8 +389,8 @@ test.describe('explore services breakdown page', () => { await expect(panels.first()).toBeVisible(); // assert the sort order hasn't changed - for (let i = 0; i < panelTitles.length; i++) { - expect(await panels.nth(i).getByRole('heading').textContent()).toEqual(panelTitles[panelTitles.length - i - 1]); + for (let i = 1; i < panelTitles.length; i++) { + expect(await panels.nth(i).getByRole('heading').textContent()).toEqual(panelTitles[panelTitles.length - i]); } }); @@ -457,18 +483,21 @@ test.describe('explore services breakdown page', () => { // Should see 8 panels after it's done loading await expect(allPanels).toHaveCount(9); // And we'll have 2 requests, one on the aggregation, one for the label values - expect(requests).toHaveLength(2); + await expect.poll(() => 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(); await expect(page.getByText('!=')).toBeVisible(); + await expect.poll(() => requests).toHaveLength(2); + requests.forEach((req) => { const post = req.post; const queries: LokiQuery[] = post.queries; @@ -476,8 +505,6 @@ 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); }); test(`should include field ${fieldName}, update filters, open filters breakdown`, async ({ page }) => { @@ -485,8 +512,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(); }); @@ -500,14 +525,21 @@ test.describe('explore services breakdown page', () => { // Go to caller values breakdown await page.getByLabel(`Select ${fieldName}`).click(); + const panels = explorePage.getAllPanelsLocator(); + await expect(panels).toHaveCount(9); // Add custom regex value await explorePage.addCustomValueToCombobox(fieldName, FilterOp.RegexEqual, ComboBoxIndex.fields, `.+st.+`, 'ca'); await expect(page.getByLabel(E2EComboboxStrings.editByKey(fieldName))).toBeVisible(); await expect(page.getByText('=~')).toBeVisible(); - const panels = explorePage.getAllPanelsLocator(); - await expect(panels).toHaveCount(4); + + // Filter will not change output + await expect(panels).toHaveCount(9); await expect(page.getByTestId(/data-testid Panel header .+st.+/).getByTestId('header-container')).toHaveCount(3); + + await explorePage.goToFieldsTab(); + // Verify that the regex query worked after navigating back to the label breakdown + await expect(page.getByTestId(/data-testid VizLegend series/)).toHaveCount(3); }); test(`Levels: include ${levelName} values`, async ({ page }) => { @@ -529,7 +561,7 @@ test.describe('explore services breakdown page', () => { await page.keyboard.press('Escape'); const panels = explorePage.getAllPanelsLocator(); - await expect(panels).toHaveCount(3); + await expect(panels).toHaveCount(5); await expect(page.getByTestId(/data-testid Panel header debug|error/).getByTestId('header-container')).toHaveCount( 2 ); @@ -557,8 +589,11 @@ test.describe('explore services breakdown page', () => { await explorePage.assertPanelsNotLoading(); // Get panel count to ensure the pod regex filter reduces the result set - const panelCount = await explorePage.getAllPanelsLocator().count(); - expect(panelCount).toBeGreaterThan(8); + await explorePage.assertNotLoading(); + await explorePage.assertPanelsNotLoading(); + + // Pods have a variable count! + await expect.poll(() => explorePage.getAllPanelsLocator().count()).toBeGreaterThanOrEqual(10); // Filter hardcoded pod names for tempo-ingester service await explorePage.addCustomValueToCombobox( metadataName, @@ -569,11 +604,19 @@ test.describe('explore services breakdown page', () => { await expect(page.getByLabel(E2EComboboxStrings.editByKey(metadataName))).toBeVisible(); await expect(page.getByText('=~').nth(3)).toBeVisible(); - const panels = explorePage.getAllPanelsLocator(); - await expect(panels).toHaveCount(9); - await expect( - page.getByTestId(/data-testid Panel header tempo-ingester-[hc]{2}-\d.+/).getByTestId('header-container') - ).toHaveCount(8); + await explorePage.assertNotLoading(); + await explorePage.assertPanelsNotLoading(); + await expect + .poll(() => + page + .getByTestId(/data-testid Panel header tempo-ingester-[hc]{2}-\d.+/) + .getByTestId('header-container') + .count() + ) + .toBe(8); + await explorePage.goToFieldsTab(); + // Verify that the regex query worked after navigating back to the label breakdown + await expect.poll(() => page.getByTestId(/data-testid VizLegend series/).count()).toBe(8); }); test('should only load fields that are in the viewport', async ({ page }) => { @@ -632,10 +675,10 @@ test.describe('explore services breakdown page', () => { // Panel on the top should not await expect(page.getByTestId(/data-testid Panel header/).first()).not.toBeInViewport(); // Wait for a bit for the requests to be made - await page.waitForTimeout(250); + await expect.poll(() => requestCount).toEqual(TOTAL_ROWS * COUNT_PER_ROW - 1); // 7 rows, last row only has 2 expect(requestCount).toEqual(TOTAL_ROWS * COUNT_PER_ROW - 1); - expect(logsCountQueryCount).toEqual(2); + await expect.poll(() => logsCountQueryCount).toEqual(2); }); test('Patterns should show error state when API call returns error', async ({ page }) => { @@ -659,7 +702,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(); }); @@ -1014,7 +1056,7 @@ test.describe('explore services breakdown page', () => { await expect(bytesIncludeButton).toHaveText('Include'); // Assert that we actually ran some queries - expect(numberOfQueries).toBeGreaterThan(0); + await expect.poll(() => numberOfQueries).toBeGreaterThan(0); }); test('should exclude all logs that contain bytes field', async ({ page }) => { @@ -1524,6 +1566,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, @@ -1615,7 +1703,7 @@ test.describe('explore services breakdown page', () => { await lastLineFilterLoc.click(); await page.keyboard.type('[dD]ebug'); await page.getByRole('button', { name: 'Include' }).click(); - await expect(highlightedMatchesInFirstRow).toHaveCount(1); + await expect.poll(() => highlightedMatchesInFirstRow.count()).toBe(1); expect(logsCountQueryCount).toEqual(5); expect(logsPanelQueryCount).toEqual(5); diff --git a/tests/exploreServicesJsonBreakDown.spec.ts b/tests/exploreServicesJsonBreakDown.spec.ts index 7a3a3be34..01ec56572 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 40d9935fa..26ef44c02 100644 --- a/tests/exploreServicesJsonMixedBreakDown.spec.ts +++ b/tests/exploreServicesJsonMixedBreakDown.spec.ts @@ -17,9 +17,6 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { await explorePage.setExtraTallViewportSize(); await explorePage.clearLocalStorage(); await explorePage.gotoServicesBreakdownOldUrl(serviceName); - explorePage.blockAllQueriesExcept({ - refIds: ['logsPanelQuery', mixedFieldName], - }); }); test.afterEach(async ({ page }) => { @@ -44,16 +41,18 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { // We should have 6 panels await expect(allPanels).toHaveCount(7); // Should have 2 queries by now - expect(requests).toHaveLength(2); + await expect.poll(() => 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(); await expect(page.getByText('!=')).toBeVisible(); + await expect.poll(() => requests).toHaveLength(2); + requests.forEach((req) => { const post = req.post; const queries: LokiQuery[] = post.queries; @@ -63,7 +62,6 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { ); }); }); - expect(requests).toHaveLength(3); }); test(`should exclude ${logFmtFieldName}, request should contain logfmt`, async ({ page }) => { let requests: PlaywrightRequest[] = []; @@ -71,6 +69,7 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { refIds: [logFmtFieldName], requests, }); + // First request should fire here await explorePage.goToFieldsTab(); const allPanels = explorePage.getAllPanelsLocator(); @@ -89,6 +88,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); @@ -96,27 +97,20 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { // Adhoc content filter should be added await expect(page.getByLabel(E2EComboboxStrings.editByKey(logFmtFieldName))).toBeVisible(); await expect(page.getByText('!=')).toBeVisible(); - - requests.forEach((req, index) => { - 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(requests.length).toBeGreaterThanOrEqual(3); + await expect.poll(() => requests.length).toBeGreaterThanOrEqual(2); + + // Aggregation query, no filter + expect(requests[0]?.post?.queries[0]?.expr).toEqual( + `sum by (${logFmtFieldName}) (count_over_time({service_name="${serviceName}"} | logfmt | ${logFmtFieldName}!="" [$__auto]))` + ); + // Value breakdown query, with/without filter + expect(requests[1]?.post?.queries[0]?.expr).toEqual( + `sum by (${logFmtFieldName}) (count_over_time({service_name="${serviceName}"} | logfmt | ${logFmtFieldName}!="" [$__auto]))` + ); + if (requests.length === 3) { + console.log('DEBUG: unexpected third request', requests[1]?.post?.queries[0]?.expr); + } }); - test(`should exclude ${jsonFmtFieldName}, request should contain logfmt`, async ({ page }) => { let requests: PlaywrightRequest[] = []; explorePage.blockAllQueriesExcept({ @@ -137,13 +131,15 @@ 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(); await expect(page.getByText('!=')).toBeVisible(); + await expect.poll(() => requests).toHaveLength(2); + requests.forEach((req) => { const post = req.post; const queries: LokiQuery[] = post.queries; @@ -153,7 +149,6 @@ test.describe('explore nginx-json-mixed breakdown pages ', () => { ); }); }); - expect(requests).toHaveLength(3); }); 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 7cd819fca..4cca909cc 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