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