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

Add filter to homepage #326

Merged
merged 12 commits into from
Feb 20, 2025
5 changes: 3 additions & 2 deletions src/components/Home/AttributePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ export interface AttributePanelState extends SceneObjectState {
title: string;
type: HomepagePanelType;
renderDurationPanel?: boolean;
filter?: string;
}

export class AttributePanel extends SceneObjectBase<AttributePanelState> {
constructor(state: AttributePanelState) {
super({
$data: new SceneQueryRunner({
datasource: explorationDS,
queries: [{ refId: 'A', queryType: 'traceql', tableType: 'spans', limit: 10, ...state.query }],
queries: [{ refId: 'A', queryType: 'traceql', tableType: 'spans', limit: 10, ...state.query, exemplars: 0 }],
}),
...state,
});
Expand Down Expand Up @@ -86,7 +87,7 @@ export class AttributePanel extends SceneObjectBase<AttributePanelState> {
children: [
new AttributePanel({
query: {
query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}}`,
query: `{nestedSetParent<0 && duration > ${minDuration} ${state.filter ?? ''}}`,
},
title: state.title,
type: state.type,
Expand Down
7 changes: 4 additions & 3 deletions src/components/Home/AttributePanelRows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { AttributePanelRows } from './AttributePanelRows';
import { DataFrame, Field } from '@grafana/data';

describe('AttributePanelRows', () => {
const createField = (name: string, values: any[], labels: Record<string, string> = {}) =>
const createField = (name: string, values: any[], labels: Record<string, string> = {}, type?: string) =>
({
name,
values,
labels,
type,
}) as Field;

const createDataFrame = (fields: Field[]) =>
Expand All @@ -19,11 +20,11 @@ describe('AttributePanelRows', () => {
const dummySeries = [
createDataFrame([
createField('time', []),
createField('Test service 1', [10, 20], { 'resource.service.name': '"Test service 1"' }),
createField('Test service 1', [10, 20], { 'resource.service.name': '"Test service 1"' }, 'number'),
]),
createDataFrame([
createField('time', []),
createField('Test service 2', [15, 5], { 'resource.service.name': '"Test service 2"' }),
createField('Test service 2', [15, 5], { 'resource.service.name': '"Test service 2"' }, 'number'),
]),
];

Expand Down
51 changes: 37 additions & 14 deletions src/components/Home/HeaderScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,22 @@ import { Button, Icon, LinkButton, Stack, useStyles2, useTheme2 } from '@grafana
import {
EXPLORATIONS_ROUTE,
} from '../../utils/shared';
import { getDatasourceVariable, getHomeScene } from '../../utils/utils';
import { getDatasourceVariable, getHomeFilterVariable, getHomeScene } from '../../utils/utils';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Home } from 'pages/Home/Home';
import { DarkModeRocket, LightModeRocket } from '../../utils/rockets';
import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics';
import { Home } from 'pages/Home/Home';

export class HeaderScene extends SceneObjectBase {
static Component = ({ model }: SceneComponentProps<Home>) => {
public static Component = ({ model }: SceneComponentProps<Home>) => {
const home = getHomeScene(model);
const navigate = useNavigate();
const { controls } = home.useState();
const styles = useStyles2(getStyles);
const theme = useTheme2();

const dsVariable = getDatasourceVariable(home);
const filterVariable = getHomeFilterVariable(home);

return (
<div className={styles.container}>
Expand Down Expand Up @@ -66,16 +67,27 @@ export class HeaderScene extends SceneObjectBase {
</div>

<Stack gap={2}>
{dsVariable && (
<Stack gap={1} alignItems={'center'}>
<div className={styles.datasourceLabel}>Data source</div>
<dsVariable.Component model={dsVariable} />
</Stack>
)}
<div className={styles.controls}>
{controls.map((control) => (
<control.Component key={control.state.key} model={control} />
))}
<div className={styles.variablesAndControls}>
<div className={styles.variables}>
{dsVariable && (
<Stack gap={1} alignItems={'center'}>
<div className={styles.label}>Data source</div>
<dsVariable.Component model={dsVariable} />
</Stack>
)}
{filterVariable && (
<Stack gap={1} alignItems={'center'}>
<div className={styles.label}>Filter</div>
<filterVariable.Component model={filterVariable} />
</Stack>
)}
</div>

<div className={styles.controls}>
{controls?.map((control) => (
<control.Component key={control.state.key} model={control} />
))}
</div>
</div>
</Stack>
</div>
Expand Down Expand Up @@ -130,9 +142,20 @@ function getStyles(theme: GrafanaTheme2) {
}
}),

datasourceLabel: css({
label: css({
fontSize: '12px',
}),
variablesAndControls: css({
alignItems: 'center',
gap: theme.spacing(2),
display: 'flex',
justifyContent: 'space-between',
width: '100%',
}),
variables: css({
display: 'flex',
gap: theme.spacing(2),
}),
controls: css({
display: 'flex',
gap: theme.spacing(1),
Expand Down
66 changes: 53 additions & 13 deletions src/pages/Home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import React from 'react';
// eslint-disable-next-line no-restricted-imports
import { duration } from 'moment';

import { GrafanaTheme2 } from '@grafana/data';
import { AdHocVariableFilter, GrafanaTheme2 } from '@grafana/data';
import {
AdHocFiltersVariable,
DataSourceVariable,
SceneComponentProps,
SceneCSSGridItem,
Expand All @@ -21,22 +22,31 @@ import {
} from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';

import { DATASOURCE_LS_KEY, VAR_DATASOURCE } from '../../utils/shared';
import {
DATASOURCE_LS_KEY,
explorationDS,
HOMEPAGE_FILTERS_LS_KEY,
VAR_DATASOURCE,
VAR_HOME_FILTER,
} from '../../utils/shared';
import { AttributePanel } from 'components/Home/AttributePanel';
import { HeaderScene } from 'components/Home/HeaderScene';
import { getDatasourceVariable } from 'utils/utils';
import { getDatasourceVariable, getHomeFilterVariable } from 'utils/utils';
import { reportAppInteraction, USER_EVENTS_PAGES, USER_EVENTS_ACTIONS } from 'utils/analytics';
import { getTagKeysProvider, renderTraceQLLabelFilters } from './utils';

export interface HomeState extends SceneObjectState {
controls: SceneObject[];
controls?: SceneObject[];
initialDS?: string;
initialFilters: AdHocVariableFilter[];
body?: SceneCSSGridLayout;
}

export class Home extends SceneObjectBase<HomeState> {
public constructor(state: Partial<HomeState>) {
public constructor(state: HomeState) {
super({
$timeRange: state.$timeRange ?? new SceneTimeRange({}),
$variables: state.$variables ?? getVariableSet(state.initialDS),
$variables: state.$variables ?? getVariableSet(state.initialFilters, state.initialDS),
controls: state.controls ?? [new SceneTimePicker({}), new SceneRefreshPicker({})],
...state,
});
Expand All @@ -45,26 +55,48 @@ export class Home extends SceneObjectBase<HomeState> {
}

private _onActivate() {
const sceneTimeRange = sceneGraph.getTimeRange(this);
const filterVariable = getHomeFilterVariable(this);
filterVariable.setState({
getTagKeysProvider: getTagKeysProvider,
});

getDatasourceVariable(this).subscribeToState((newState) => {
if (newState.value) {
localStorage.setItem(DATASOURCE_LS_KEY, newState.value.toString());
}
});

const sceneTimeRange = sceneGraph.getTimeRange(this);
getHomeFilterVariable(this).subscribeToState((newState, prevState) => {
if (newState.filters !== prevState.filters) {
this.buildPanels(sceneTimeRange, newState.filters);

// save the filters to local storage
localStorage.setItem(HOMEPAGE_FILTERS_LS_KEY, JSON.stringify(newState.filters));

const newFilters = newState.filters.filter((f) => !prevState.filters.find((pf) => pf.key === f.key));
if (newFilters.length > 0) {
reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.filter_changed, {
key: newFilters[0].key,
});
}
}
});

sceneTimeRange.subscribeToState((newState, prevState) => {
if (newState.value.from !== prevState.value.from || newState.value.to !== prevState.value.to) {
this.buildPanels(sceneTimeRange);
this.buildPanels(sceneTimeRange, filterVariable.state.filters);
}
});
this.buildPanels(sceneTimeRange);
this.buildPanels(sceneTimeRange, filterVariable.state.filters);
}

buildPanels(sceneTimeRange: SceneTimeRangeLike) {
buildPanels(sceneTimeRange: SceneTimeRangeLike, filters: AdHocVariableFilter[]) {
const from = sceneTimeRange.state.value.from.unix();
const to = sceneTimeRange.state.value.to.unix();
const dur = duration(to - from, 's');
const durString = `${dur.asSeconds()}s`;
const renderedFilters = renderTraceQLLabelFilters(filters);

this.setState({
body: new SceneCSSGridLayout({
Expand All @@ -77,7 +109,7 @@ export class Home extends SceneObjectBase<HomeState> {
new SceneCSSGridItem({
body: new AttributePanel({
query: {
query: '{nestedSetParent < 0 && status = error} | count_over_time() by (resource.service.name)',
query: `{nestedSetParent < 0 && status = error ${renderedFilters}} | count_over_time() by (resource.service.name)`,
step: durString,
},
title: 'Errored services',
Expand All @@ -97,10 +129,11 @@ export class Home extends SceneObjectBase<HomeState> {
new SceneCSSGridItem({
body: new AttributePanel({
query: {
query: '{nestedSetParent<0} | histogram_over_time(duration)',
query: `{nestedSetParent<0 ${renderedFilters}} | histogram_over_time(duration)`,
},
title: 'Slow traces',
type: 'slowest-traces',
filter: renderedFilters,
}),
}),
],
Expand All @@ -123,7 +156,7 @@ export class Home extends SceneObjectBase<HomeState> {
};
}

function getVariableSet(initialDS?: string) {
function getVariableSet(initialFilters: AdHocVariableFilter[], initialDS?: string) {
return new SceneVariableSet({
variables: [
new DataSourceVariable({
Expand All @@ -132,6 +165,13 @@ function getVariableSet(initialDS?: string) {
value: initialDS,
pluginId: 'tempo',
}),
new AdHocFiltersVariable({
name: VAR_HOME_FILTER,
datasource: explorationDS,
layout: 'combobox',
filters: initialFilters,
allowCustomValue: true,
}),
],
});
}
Expand Down
6 changes: 4 additions & 2 deletions src/pages/Home/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, { useEffect, useState } from 'react';
import { newHome } from '../../utils/utils';
import { DATASOURCE_LS_KEY } from '../../utils/shared';
import { DATASOURCE_LS_KEY, HOMEPAGE_FILTERS_LS_KEY } from '../../utils/shared';
import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../utils/analytics';
import { Home } from './Home';

const HomePage = () => {
const initialDs = localStorage.getItem(DATASOURCE_LS_KEY) || '';
const [home] = useState(newHome(initialDs));
const localStorageFilters = localStorage.getItem(HOMEPAGE_FILTERS_LS_KEY);
const filters = localStorageFilters ? JSON.parse(localStorageFilters) : [];
const [home] = useState(newHome(filters, initialDs));

return <HomeView home={home} />;
};
Expand Down
72 changes: 72 additions & 0 deletions src/pages/Home/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { MetricFindValue } from "@grafana/data";
import { filterKeys, renderTraceQLLabelFilters } from "./utils";
import { EVENT_ATTR, EVENT_INTRINSIC, ignoredAttributes, ignoredAttributesHomeFilter } from "utils/shared";

describe('filterKeys', () => {
it('should handle an empty input array', () => {
const filteredKeys = filterKeys([]);
expect(filteredKeys).toEqual([]);
});

it('should return correct keys', () => {
const mockKeys: MetricFindValue[] = [
{ text: 'resource.cpu' },
{ text: 'resource.memory' },
{ text: 'span.duration' },
{ text: 'span.name' },
{ text: ignoredAttributes[0] },
{ text: ignoredAttributesHomeFilter[0] },
{ text: `${EVENT_ATTR}timestamp` },
{ text: `${EVENT_INTRINSIC}timestamp` },
];

const filteredKeys = filterKeys(mockKeys);

expect(filteredKeys).toEqual([
{ text: 'resource.cpu' },
{ text: 'resource.memory' },
{ text: 'span.duration' },
{ text: 'span.name' },
]);
});

it('should order keys correctly', () => {
const mockKeys: MetricFindValue[] = [
{ text: ignoredAttributes[0] },
{ text: ignoredAttributesHomeFilter[0] },
{ text: 'span.duration' },
{ text: 'span.name' },
{ text: `${EVENT_ATTR}timestamp` },
{ text: `${EVENT_INTRINSIC}timestamp` },
{ text: 'resource.cpu' },
{ text: 'resource.memory' },
];

const filteredKeys = filterKeys(mockKeys);

expect(filteredKeys).toEqual([
{ text: 'resource.cpu' },
{ text: 'resource.memory' },
{ text: 'span.duration' },
{ text: 'span.name' },
]);
});
});

describe('renderTraceQLLabelFilters', () => {
it('returns an empty string when value is empty', () => {
expect(renderTraceQLLabelFilters([{ key: 'testKey', operator: '=', value: '' }])).toBe('');
});

it('wraps value in quotes when key matches RESOURCE_ATTR or SPAN_ATTR and value is not a number', () => {
expect(renderTraceQLLabelFilters([{ key: 'resource.attr', operator: '=', value: 'testValue' }, { key: 'span.attr', operator: '=', value: 'testValue' }])).toBe('&& resource.attr="testValue" && span.attr="testValue"');
});

it('does not wrap value in quotes when it is already quoted', () => {
expect(renderTraceQLLabelFilters([{ key: 'resource.attr', operator: '=', value: '"testValue"' }])).toBe('&& resource.attr="testValue"');
});

it('does not wrap number in quotes', () => {
expect(renderTraceQLLabelFilters([{ key: 'custom.attr', operator: '=', value: '123' }])).toBe('&& custom.attr=123');
});
});
Loading