Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(LabelsView): Include/exclude panel actions #210

Merged
merged 19 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 0 additions & 10 deletions e2e/tests/labels-view/labels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,6 @@ test.describe('Labels view', () => {
stylePath: './e2e/fixtures/css/hide-all-controls.css',
});
});

test('Add to filters action', async ({ exploreProfilesPage }) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exchanged for unit tests below

await exploreProfilesPage.clickOnPanelAction('eu-north', 'Add to filters');

await exploreProfilesPage.assertFilters([['region', '=', 'eu-north']]);

await expect(exploreProfilesPage.getSceneBody()).toHaveScreenshot({
stylePath: './e2e/fixtures/css/hide-all-controls.css',
});
});
});

test.describe('Compare flow', () => {
Expand Down
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,21 @@ import {
import { Stack, useStyles2 } from '@grafana/ui';
import { prepareHistoryEntry } from '@shared/domain/prepareHistoryEntry';
import { reportInteraction } from '@shared/domain/reportInteraction';
import { getProfileMetric, ProfileMetricId } from '@shared/infrastructure/profile-metrics/getProfileMetric';
import React, { useMemo } from 'react';
import { Unsubscribable } from 'rxjs';
import { EventViewDiffFlameGraph } from 'src/pages/ProfilesExplorerView/domain/events/EventViewDiffFlameGraph';

import { FavAction } from '../../../../domain/actions/FavAction';
import { SelectAction } from '../../../../domain/actions/SelectAction';
import { EventAddLabelToFilters } from '../../../../domain/events/EventAddLabelToFilters';
import { EventExpandPanel } from '../../../../domain/events/EventExpandPanel';
import { EventSelectLabel } from '../../../../domain/events/EventSelectLabel';
import { EventViewServiceFlameGraph } from '../../../../domain/events/EventViewServiceFlameGraph';
import { addFilter } from '../../../../domain/variables/FiltersVariable/filters-ops';
import {
clearLabelValue,
excludeLabelValue,
includeLabelValue,
} from '../../../../domain/variables/FiltersVariable/filters-ops';
import { FiltersVariable } from '../../../../domain/variables/FiltersVariable/FiltersVariable';
import { GroupByVariable } from '../../../../domain/variables/GroupByVariable/GroupByVariable';
import { getSceneVariableValue } from '../../../../helpers/getSceneVariableValue';
Expand All @@ -43,6 +47,10 @@ import { SceneProfilesExplorer } from '../../../SceneProfilesExplorer/SceneProfi
import { SceneStatsPanel } from './components/SceneLabelValuesGrid/components/SceneStatsPanel/SceneStatsPanel';
import { CompareTarget } from './components/SceneLabelValuesGrid/domain/types';
import { SceneLabelValuesGrid } from './components/SceneLabelValuesGrid/SceneLabelValuesGrid';
import { IncludeExcludeAction } from './domain/actions/IncludeExcludeAction/IncludeExcludeAction';
import { EventClearLabelFromFilters } from './domain/events/EventClearLabelFromFilters';
import { EventExcludeLabelFromFilters } from './domain/events/EventExcludeLabelFromFilters';
import { EventIncludeLabelInFilters } from './domain/events/EventIncludeLabelInFilters';
import { EventSelectForCompare } from './domain/events/EventSelectForCompare';
import { CompareControls } from './ui/CompareControls';

Expand Down Expand Up @@ -132,13 +140,23 @@ export class SceneGroupByLabels extends SceneObjectBase<SceneGroupByLabelsState>
this.selectForCompare(compareTarget, item);
});

const addToFiltersSub = this.subscribeToEvent(EventAddLabelToFilters, (event) => {
this.addLabelValueToFilters(event.payload.item);
const includeFilterSub = this.subscribeToEvent(EventIncludeLabelInFilters, (event) => {
this.includeLabelValueInFilters(event.payload.item);
});

const excludeFilterSub = this.subscribeToEvent(EventExcludeLabelFromFilters, (event) => {
this.excludeLabelValueFromFilters(event.payload.item);
});

const clearFilterSub = this.subscribeToEvent(EventClearLabelFromFilters, (event) => {
this.clearLabelValueFromFilters(event.payload.item);
});

return {
unsubscribe() {
addToFiltersSub.unsubscribe();
clearFilterSub.unsubscribe();
excludeFilterSub.unsubscribe();
includeFilterSub.unsubscribe();
selectForCompareSub.unsubscribe();
expandPanelSub.unsubscribe();
selectLabelSub.unsubscribe();
Expand Down Expand Up @@ -242,8 +260,21 @@ export class SceneGroupByLabels extends SceneObjectBase<SceneGroupByLabelsState>
startColorIndex,
label,
headerActions: (item) => [
new SelectAction({ EventClass: EventViewServiceFlameGraph, item }),
new SelectAction({ EventClass: EventAddLabelToFilters, item }),
new SelectAction({
EventClass: EventViewServiceFlameGraph,
item,
tooltip: (item, model) => {
const { queryRunnerParams, label } = item;
const profileMetricId =
queryRunnerParams.profileMetricId || getSceneVariableValue(model, 'profileMetricId');
const groupByValue = getSceneVariableValue(model, 'groupBy');

return `View the "${
getProfileMetric(profileMetricId as ProfileMetricId).type
}" flame graph for "${groupByValue}=${label}"`;
},
}),
new IncludeExcludeAction({ item }),
new FavAction({ item }),
],
});
Expand All @@ -260,16 +291,25 @@ export class SceneGroupByLabels extends SceneObjectBase<SceneGroupByLabelsState>
this.state.drawer.close();
}

addLabelValueToFilters(item: GridItemData) {
const { filters } = item.queryRunnerParams;
includeLabelValueInFilters(item: GridItemData) {
const [filterToInclude] = item.queryRunnerParams.filters!;
const filtersVariable = sceneGraph.findByKeyAndType(this, 'filters', FiltersVariable);

if (filters?.[0]) {
const filterToAdd = filters?.[0];
addFilter(sceneGraph.findByKeyAndType(this, 'filters', FiltersVariable), filterToAdd);
return;
}
filtersVariable.setState({ filters: includeLabelValue(filtersVariable.state.filters, filterToInclude) });
}

excludeLabelValueFromFilters(item: GridItemData) {
const filtersVariable = sceneGraph.findByKeyAndType(this, 'filters', FiltersVariable);
const [filterToExclude] = item.queryRunnerParams.filters!;

filtersVariable.setState({ filters: excludeLabelValue(filtersVariable.state.filters, filterToExclude) });
}

clearLabelValueFromFilters(item: GridItemData) {
const filtersVariable = sceneGraph.findByKeyAndType(this, 'filters', FiltersVariable);
const [filterToClear] = item.queryRunnerParams.filters!;

console.error('Cannot build filter! Missing "filters" and "groupBy" value.', item);
filtersVariable.setState({ filters: clearLabelValue(filtersVariable.state.filters, filterToClear) });
}

openExpandedPanelDrawer(item: GridItemData) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { FiltersVariable } from '../../../../../../domain/variables/FiltersVaria
import { GroupByVariable } from '../../../../../../domain/variables/GroupByVariable/GroupByVariable';
import { getSceneVariableValue } from '../../../../../../helpers/getSceneVariableValue';
import { getSeriesLabelFieldName } from '../../../../../../infrastructure/helpers/getSeriesLabelFieldName';
import { buildTimeSeriesQueryRunner } from '../../../../../../infrastructure/timeseries/buildTimeSeriesQueryRunner';
import { SceneEmptyState } from '../../../../../SceneByVariableRepeaterGrid/components/SceneEmptyState/SceneEmptyState';
import { SceneErrorState } from '../../../../../SceneByVariableRepeaterGrid/components/SceneErrorState/SceneErrorState';
import {
Expand All @@ -45,6 +44,7 @@ import {
} from '../../../../../SceneByVariableRepeaterGrid/infrastructure/data-transformations';
import { GridItemData } from '../../../../../SceneByVariableRepeaterGrid/types/GridItemData';
import { SceneLabelValuePanel } from './components/SceneLabelValuePanel';
import { buildLabelValuesGridQueryRunner } from './infrastructure/buildLabelValuesGridQueryRunner';

interface SceneLabelValuesGridState extends EmbeddedSceneState {
$data: SceneDataProvider;
Expand Down Expand Up @@ -84,7 +84,7 @@ export class SceneLabelValuesGrid extends SceneObjectBase<SceneLabelValuesGridSt
items: [],
isLoading: true,
$data: new SceneDataTransformer({
$data: buildTimeSeriesQueryRunner({ groupBy: { label } }),
$data: buildLabelValuesGridQueryRunner({ label }),
transformations: [addRefId, addStats, sortSeries],
}),
hideNoData: false,
Expand Down Expand Up @@ -248,7 +248,7 @@ export class SceneLabelValuesGrid extends SceneObjectBase<SceneLabelValuesGridSt
this.setState({
isLoading: true,
$data: new SceneDataTransformer({
$data: buildTimeSeriesQueryRunner({ groupBy: { label: this.state.label } }),
$data: buildLabelValuesGridQueryRunner({ label: this.state.label }),
transformations: [addRefId, addStats, sortSeries],
}),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SceneQueryRunner } from '@grafana/scenes';

import { PYROSCOPE_DATA_SOURCE } from '../../../../../../../infrastructure/pyroscope-data-sources';
grafakus marked this conversation as resolved.
Show resolved Hide resolved

export function buildLabelValuesGridQueryRunner({ label }: { label: string }) {
const selector = 'service_name="$serviceName"';

return new SceneQueryRunner({
datasource: PYROSCOPE_DATA_SOURCE,
queries: [
{
refId: `$profileMetricId-${selector}-${label}`,
queryType: 'metrics',
profileTypeId: '$profileMetricId',
labelSelector: `{${selector}}`,
groupBy: [label],
},
],
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { AdHocVariableFilter } from '@grafana/data';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { isRegexOperator } from '@shared/components/QueryBuilder/domain/helpers/isRegexOperator';
import { reportInteraction } from '@shared/domain/reportInteraction';
import React, { useMemo } from 'react';

import { FiltersVariable } from '../../../../../../../domain/variables/FiltersVariable/FiltersVariable';
import { GridItemData } from '../../../../../../SceneByVariableRepeaterGrid/types/GridItemData';
import { EventClearLabelFromFilters } from '../../events/EventClearLabelFromFilters';
import { EventExcludeLabelFromFilters } from '../../events/EventExcludeLabelFromFilters';
import { EventIncludeLabelInFilters } from '../../events/EventIncludeLabelInFilters';
import { FilterButtons } from './ui/FilterButtons';

export interface IncludeExcludeActionState extends SceneObjectState {
item: GridItemData;
}

export class IncludeExcludeAction extends SceneObjectBase<IncludeExcludeActionState> {
constructor({ item }: IncludeExcludeActionState) {
super({ item });
}

getStatus(filters: AdHocVariableFilter[]) {
const { key, value } = this.state.item.queryRunnerParams.filters![0];

const found = filters.find((f) => f.key === key);
if (!found) {
return 'clear';
}

if (isRegexOperator(found.operator) && found.value.split('|').includes(value)) {
return found.operator === '=~' ? 'included' : 'excluded';
}

// found.operator is '=' or '!='
if (found.value === value) {
return found.operator === '=' ? 'included' : 'excluded';
}

return 'clear';
}

onInclude = () => {
reportInteraction('g_pyroscope_app_include_action_clicked');

this.publishEvent(new EventIncludeLabelInFilters({ item: this.state.item }), true);
};

onExclude = () => {
reportInteraction('g_pyroscope_app_exclude_action_clicked');

this.publishEvent(new EventExcludeLabelFromFilters({ item: this.state.item }), true);
};

onClear = () => {
this.publishEvent(new EventClearLabelFromFilters({ item: this.state.item }), true);
};

public static Component = ({ model }: SceneComponentProps<IncludeExcludeAction>) => {
const { item } = model.useState();

const { filters } = (sceneGraph.findByKeyAndType(model, 'filters', FiltersVariable) as FiltersVariable).useState();
const status = useMemo(() => model.getStatus(filters), [filters, model]);
grafakus marked this conversation as resolved.
Show resolved Hide resolved

return (
<FilterButtons
label={item.value}
status={status}
onInclude={model.onInclude}
onExclude={model.onExclude}
onClear={model.onClear}
/>
);
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, useStyles2 } from '@grafana/ui';
import React, { memo } from 'react';

type FilterButtonsProps = {
label: string;
status: 'included' | 'excluded' | 'clear';
onInclude: () => void;
onExclude: () => void;
onClear: () => void;
};

function getStatus({ status, label, onInclude, onExclude, onClear }: FilterButtonsProps) {
const isIncludeSelected = status === 'included';
const includeTooltip = !isIncludeSelected ? `Include "${label}" in the filters` : `Clear "${label}" from the filters`;

const isExcludeSelected = status === 'excluded';
const excludeTooltip = !isExcludeSelected ? `Exclude "${label}" in the filters` : `Clear "${label}" from the filters`;

return {
include: {
isSelected: isIncludeSelected,
tooltip: includeTooltip,
onClick: isIncludeSelected ? onClear : onInclude,
},
exclude: {
isSelected: isExcludeSelected,
tooltip: excludeTooltip,
onClick: isExcludeSelected ? onClear : onExclude,
},
};
}

// Kindly borrowed and adapted from https://github.com/grafana/explore-logs/blob/main/src/Components/FilterButton.tsx :)
const FilterButtonsComponent = (props: FilterButtonsProps) => {
const styles = useStyles2(getStyles);

const { include, exclude } = getStatus(props);

return (
<div className={styles.container}>
<Button
size="sm"
fill="outline"
variant={include.isSelected ? 'primary' : 'secondary'}
aria-selected={include.isSelected}
className={cx(styles.includeButton, include.isSelected && 'selected')}
onClick={include.onClick}
tooltip={include.tooltip}
tooltipPlacement="top"
data-testid="filter-button-include"
>
Include
</Button>
<Button
size="sm"
fill="outline"
variant={exclude.isSelected ? 'primary' : 'secondary'}
aria-selected={exclude.isSelected}
className={cx(styles.excludeButton, exclude.isSelected && 'selected')}
onClick={exclude.onClick}
tooltip={exclude.tooltip}
tooltipPlacement="top"
data-testid="filter-button-exclude"
>
Exclude
</Button>
</div>
);
};

export const FilterButtons = memo(FilterButtonsComponent);

const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
display: flex;
justify-content: center;
`,
includeButton: css`
border-radius: ${theme.shape.radius.default} 0 0 ${theme.shape.radius.default};

&:not(.selected) {
border-right: none;
}
`,
excludeButton: css`
border-radius: 0 ${theme.shape.radius.default} ${theme.shape.radius.default} 0;

&:not(.selected) {
border-left: none;
}
`,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { BusEventWithPayload } from '@grafana/data';

import { GridItemData } from '../../../../../SceneByVariableRepeaterGrid/types/GridItemData';

export interface EventClearLabelFromFiltersPayload {
item: GridItemData;
}

export class EventClearLabelFromFilters extends BusEventWithPayload<EventClearLabelFromFiltersPayload> {
public static type = 'clear-label-from-filters';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { BusEventWithPayload } from '@grafana/data';

import { GridItemData } from '../../../../../SceneByVariableRepeaterGrid/types/GridItemData';

export interface EventExcludeLabelFromFiltersPayload {
item: GridItemData;
}

export class EventExcludeLabelFromFilters extends BusEventWithPayload<EventExcludeLabelFromFiltersPayload> {
public static type = 'exclude-label-from-filters';
}
Loading
Loading