Alerting: Improve performance ash page (#97619)

* first commit adding usememo and refactoring scenes objects to keep an state for variables values

* Fix scenes with the panel reacting to variables changes

* move body to the model

* address some pr feedback

* Refactoring central alert history scene  (#97658)

Refactoring

* fix test and some wrong imports

* update comments

* add eslint-disable-next-line

* remove unnecessary SceneFlexLayout and SceneFlexItem wrapper

* address pr feedback

* update tests for labels filtering

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Sonia Aguilar 2024-12-10 13:09:42 +01:00 committed by GitHub
parent 448d87157c
commit dd9638cade
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 221 additions and 161 deletions

View File

@ -1,14 +1,16 @@
import { css } from '@emotion/css';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { GrafanaTheme2, VariableHide } from '@grafana/data';
import {
CustomVariable,
EmbeddedScene,
PanelBuilders,
SceneComponentProps,
SceneControlsSpacer,
SceneFlexItem,
SceneFlexLayout,
SceneObjectBase,
SceneQueryRunner,
SceneReactObject,
SceneRefreshPicker,
@ -16,7 +18,9 @@ import {
SceneTimeRange,
SceneVariableSet,
TextBoxVariable,
VariableDependencyConfig,
VariableValueSelectors,
sceneGraph,
useUrlSync,
} from '@grafana/scenes';
import { GraphDrawStyle, VisibilityMode } from '@grafana/schema/dist/esm/index';
@ -36,14 +40,13 @@ import {
import { Trans } from 'app/core/internationalization';
import { LogMessages, logInfo } from '../../../Analytics';
import { DataSourceInformation } from '../../../home/Insights';
import { alertStateHistoryDatasource, useRegisterHistoryRuntimeDataSource } from './CentralHistoryRuntimeDataSource';
import { HistoryEventsListObject } from './EventListSceneObject';
export const LABELS_FILTER = 'labelsFilter';
export const STATE_FILTER_TO = 'stateFilterTo';
export const STATE_FILTER_FROM = 'stateFilterFrom';
export const LABELS_FILTER = 'LABELS_FILTER';
export const STATE_FILTER_TO = 'STATE_FILTER_TO';
export const STATE_FILTER_FROM = 'STATE_FILTER_FROM';
/**
*
* This scene shows the history of the alert state changes.
@ -67,74 +70,72 @@ export const CentralAlertHistoryScene = () => {
logInfo(LogMessages.loadedCentralAlertStateHistory);
}, []);
// create the variables for the filters
// textbox variable for filtering by labels
const labelsFilterVariable = new TextBoxVariable({
name: LABELS_FILTER,
label: 'Labels: ',
});
//custom variable for filtering by the current state
const transitionsToFilterVariable = new CustomVariable({
name: STATE_FILTER_TO,
value: StateFilterValues.all,
label: 'End state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, To Firing : ${StateFilterValues.firing},To Normal : ${StateFilterValues.normal},To Pending : ${StateFilterValues.pending}`,
});
//custom variable for filtering by the previous state
const transitionsFromFilterVariable = new CustomVariable({
name: STATE_FILTER_FROM,
value: StateFilterValues.all,
label: 'Start state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, From Firing : ${StateFilterValues.firing},From Normal : ${StateFilterValues.normal},From Pending : ${StateFilterValues.pending}`,
});
useRegisterHistoryRuntimeDataSource(); // register the runtime datasource for the history api.
const scene = new EmbeddedScene({
controls: [
new SceneReactObject({
component: LabelFilter,
}),
new SceneReactObject({
component: FilterInfo,
}),
new VariableValueSelectors({}),
new SceneReactObject({
component: ClearFilterButton,
props: {
labelsFilterVariable,
transitionsToFilterVariable,
transitionsFromFilterVariable,
},
}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
// use default time range as from 1 hour ago to now, as the limit of the history api is 5000 events,
// and using a wider time range might lead to showing gaps in the events list and the chart.
$timeRange: new SceneTimeRange({
from: 'now-1h',
to: 'now',
}),
$variables: new SceneVariableSet({
variables: [labelsFilterVariable, transitionsFromFilterVariable, transitionsToFilterVariable],
}),
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: getEventsSceneObject(alertStateHistoryDatasource),
const scene = useMemo(() => {
// create the variables for the filters
// textbox variable for filtering by labels
const labelsFilterVariable = new TextBoxVariable({
name: LABELS_FILTER,
label: 'Labels: ',
});
//custom variable for filtering by the current state
const transitionsToFilterVariable = new CustomVariable({
name: STATE_FILTER_TO,
value: StateFilterValues.all,
label: 'End state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, To Firing : ${StateFilterValues.firing},To Normal : ${StateFilterValues.normal},To Pending : ${StateFilterValues.pending}`,
});
//custom variable for filtering by the previous state
const transitionsFromFilterVariable = new CustomVariable({
name: STATE_FILTER_FROM,
value: StateFilterValues.all,
label: 'Start state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, From Firing : ${StateFilterValues.firing},From Normal : ${StateFilterValues.normal},From Pending : ${StateFilterValues.pending}`,
});
return new EmbeddedScene({
controls: [
new SceneReactObject({
component: LabelFilter,
}),
new SceneFlexItem({
body: new HistoryEventsListObject(),
new SceneReactObject({
component: FilterInfo,
}),
new VariableValueSelectors({}),
new ClearFilterButtonScenesObject({}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
}),
});
// use default time range as from 1 hour ago to now, as the limit of the history api is 5000 events,
// and using a wider time range might lead to showing gaps in the events list and the chart.
$timeRange: new SceneTimeRange({
from: 'now-1h',
to: 'now',
}),
$variables: new SceneVariableSet({
variables: [labelsFilterVariable, transitionsFromFilterVariable, transitionsToFilterVariable],
}),
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: getEventsSceneObject(),
}),
new SceneFlexItem({
body: new HistoryEventsListObject({}),
}),
],
}),
});
}, []);
// we need to call this to sync the url with the scene state
const isUrlSyncInitialized = useUrlSync(scene);
@ -147,22 +148,11 @@ export const CentralAlertHistoryScene = () => {
/**
* Creates a SceneFlexItem with a timeseries panel that shows the events.
* The query uses a runtime datasource that fetches the events from the history api.
* @param alertStateHistoryDataSource the datasource information for the runtime datasource
*/
function getEventsSceneObject(alertStateHistoryDataSource: DataSourceInformation) {
return new EmbeddedScene({
controls: [],
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: new SceneFlexLayout({
children: [getEventsScenesFlexItem(alertStateHistoryDataSource)],
}),
}),
],
}),
function getEventsSceneObject() {
return new SceneFlexLayout({
direction: 'column',
children: [getEventsScenesFlexItem()],
});
}
@ -171,15 +161,15 @@ function getEventsSceneObject(alertStateHistoryDataSource: DataSourceInformation
* @param datasource the datasource information for the runtime datasource
* @returns the SceneQueryRunner
*/
function getSceneQuery(datasource: DataSourceInformation) {
function getQueryRunnerForAlertHistoryDataSource() {
const query = new SceneQueryRunner({
datasource: datasource,
datasource: alertStateHistoryDatasource,
queries: [
{
refId: 'A',
expr: '',
queryType: 'range',
step: '10s',
labels: '${LABELS_FILTER}',
stateFrom: '${STATE_FILTER_FROM}',
stateTo: '${STATE_FILTER_TO}',
},
],
});
@ -189,7 +179,7 @@ function getSceneQuery(datasource: DataSourceInformation) {
* This function creates a SceneFlexItem with a timeseries panel that shows the events.
* The query uses a runtime datasource that fetches the events from the history api.
*/
export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
export function getEventsScenesFlexItem() {
return new SceneFlexItem({
minHeight: 300,
body: PanelBuilders.timeseries()
@ -197,7 +187,7 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
.setDescription(
'Each alert event represents an alert instance that changed its state at a particular point in time. The history of the data is displayed over a period of time.'
)
.setData(getSceneQuery(datasource))
.setData(getQueryRunnerForAlertHistoryDataSource())
.setColor({ mode: 'continuous-BlPu' })
.setCustomFieldConfig('fillOpacity', 100)
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
@ -213,47 +203,49 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
.setCustomFieldConfig('scaleDistribution', { type: ScaleDistribution.Linear })
.setOption('legend', { showLegend: false, displayMode: LegendDisplayMode.Hidden })
.setOption('tooltip', { mode: TooltipDisplayMode.Single })
.setNoValue('No events found')
.build(),
});
}
/*
* This component shows a button to clear the filters.
* It is shown when the filters are active.
* props:
* labelsFilterVariable: the textbox variable for filtering by labels
* transitionsToFilterVariable: the custom variable for filtering by the current state
* transitionsFromFilterVariable: the custom variable for filtering by the previous state
*/
function ClearFilterButton({
labelsFilterVariable,
transitionsToFilterVariable,
transitionsFromFilterVariable,
}: {
labelsFilterVariable: TextBoxVariable;
transitionsToFilterVariable: CustomVariable;
transitionsFromFilterVariable: CustomVariable;
}) {
// get the current values of the filters
const valueInLabelsFilter = labelsFilterVariable.getValue();
//todo: use parsePromQLStyleMatcherLooseSafe to validate the label filter and check the lenghtof the result
const valueInTransitionsFilter = transitionsToFilterVariable.getValue();
const valueInTransitionsFromFilter = transitionsFromFilterVariable.getValue();
export class ClearFilterButtonScenesObject extends SceneObjectBase {
public static Component = ClearFilterButtonObjectRenderer;
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO],
});
}
export function ClearFilterButtonObjectRenderer({ model }: SceneComponentProps<ClearFilterButtonScenesObject>) {
// This make sure the component is re-rendered when the variables change
model.useState();
const labelsFilter = sceneGraph.interpolate(model, '${LABELS_FILTER}');
const stateTo = sceneGraph.interpolate(model, '${STATE_FILTER_TO}');
const stateFrom = sceneGraph.interpolate(model, '${STATE_FILTER_FROM}');
// if no filter is active, return null
if (
!valueInLabelsFilter &&
valueInTransitionsFilter === StateFilterValues.all &&
valueInTransitionsFromFilter === StateFilterValues.all
) {
if (!labelsFilter && stateTo === StateFilterValues.all && stateFrom === StateFilterValues.all) {
return null;
}
const onClearFilter = () => {
labelsFilterVariable.setValue('');
transitionsToFilterVariable.changeValueTo(StateFilterValues.all);
transitionsFromFilterVariable.changeValueTo(StateFilterValues.all);
const labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model);
if (labelsFiltersVariable instanceof TextBoxVariable) {
labelsFiltersVariable.setValue('');
}
const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model);
if (stateToFilterVariable instanceof CustomVariable) {
stateToFilterVariable.changeValueTo(StateFilterValues.all);
}
const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model);
if (stateFromFilterVariable instanceof CustomVariable) {
stateFromFilterVariable.changeValueTo(StateFilterValues.all);
}
};
return (
<Tooltip content="Clear filter">
<Button variant={'secondary'} icon="times" onClick={onClearFilter}>

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { DataQuery, DataQueryRequest, DataQueryResponse, TestDataSourceResponse } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { RuntimeDataSource, sceneUtils } from '@grafana/scenes';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { dispatch } from 'app/store/store';
@ -9,7 +10,7 @@ import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { DataSourceInformation } from '../../../home/Insights';
import { LIMIT_EVENTS } from './EventListSceneObject';
import { getStateFilterFromInQueryParams, getStateFilterToInQueryParams, historyResultToDataFrame } from './utils';
import { historyResultToDataFrame } from './utils';
const historyDataSourceUid = '__history_api_ds_uid__';
const historyDataSourcePluginId = '__history_api_ds_pluginId__';
@ -31,6 +32,12 @@ export function useRegisterHistoryRuntimeDataSource() {
}, [ds]);
}
interface HistoryAPIQuery extends DataQuery {
labels?: string;
stateFrom?: string;
stateTo?: string;
}
/**
* This class is a runtime datasource that fetches the events from the history api.
* The events are grouped by alert instance and then converted to a DataFrame list.
@ -38,23 +45,28 @@ export function useRegisterHistoryRuntimeDataSource() {
* This allows us to filter the events by labels.
* The result is a timeseries panel that shows the events for the selected time range and filtered by labels.
*/
class HistoryAPIDatasource extends RuntimeDataSource {
class HistoryAPIDatasource extends RuntimeDataSource<HistoryAPIQuery> {
constructor(pluginId: string, uid: string) {
super(uid, pluginId);
}
async query(request: DataQueryRequest<DataQuery>): Promise<DataQueryResponse> {
async query(request: DataQueryRequest<HistoryAPIQuery>): Promise<DataQueryResponse> {
const from = request.range.from.unix();
const to = request.range.to.unix();
// get the query from the request
const query = request.targets[0]!;
// Get the labels and states filters from the URL
const stateTo = getStateFilterToInQueryParams();
const stateFrom = getStateFilterFromInQueryParams();
const templateSrv = getTemplateSrv();
// we get the labels, stateTo and stateFrom from the query variables
const labels = templateSrv.replace(query.labels ?? '', request.scopedVars);
const stateTo = templateSrv.replace(query.stateTo ?? '', request.scopedVars);
const stateFrom = templateSrv.replace(query.stateFrom ?? '', request.scopedVars);
const historyResult = await getHistory(from, to);
return {
data: historyResultToDataFrame(historyResult, { stateTo, stateFrom }),
data: historyResultToDataFrame(historyResult, { stateTo, stateFrom, labels }),
};
}

View File

@ -9,6 +9,7 @@ import {
SceneComponentProps,
SceneObjectBase,
TextBoxVariable,
VariableDependencyConfig,
VariableValue,
sceneGraph,
} from '@grafana/scenes';
@ -495,48 +496,54 @@ export const getStyles = (theme: GrafanaTheme2) => {
export class HistoryEventsListObject extends SceneObjectBase {
public static Component = HistoryEventsListObjectRenderer;
public constructor() {
super({});
}
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO],
});
}
export type FilterType = 'label' | 'stateFrom' | 'stateTo';
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) {
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
// eslint-disable-next-line
const labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model)! as TextBoxVariable;
// eslint-disable-next-line
const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model)! as CustomVariable;
// eslint-disable-next-line
const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model)! as CustomVariable;
// This make sure the component is re-rendered when the variables change
model.useState();
const valueInfilterTextBox: VariableValue = labelsFiltersVariable.getValue();
const valueInStateToFilter = stateToFilterVariable.getValue();
const valueInStateFromFilter = stateFromFilterVariable.getValue();
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
const labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model);
const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model);
const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model);
const addFilter = (key: string, value: string, type: FilterType) => {
const newFilterToAdd = `${key}=${value}`;
trackUseCentralHistoryFilterByClicking({ type, key, value });
if (type === 'stateTo') {
if (type === 'stateTo' && stateToFilterVariable instanceof CustomVariable) {
stateToFilterVariable.changeValueTo(value);
}
if (type === 'stateFrom') {
if (type === 'stateFrom' && stateFromFilterVariable instanceof CustomVariable) {
stateFromFilterVariable.changeValueTo(value);
}
const finalFilter = combineMatcherStrings(valueInfilterTextBox.toString(), newFilterToAdd);
if (type === 'label') {
if (type === 'label' && labelsFiltersVariable instanceof TextBoxVariable) {
const finalFilter = combineMatcherStrings(labelsFiltersVariable.state.value.toString(), newFilterToAdd);
labelsFiltersVariable.setValue(finalFilter);
}
};
return (
<HistoryEventsList
timeRange={timeRange}
valueInLabelFilter={valueInfilterTextBox}
addFilter={addFilter}
valueInStateToFilter={valueInStateToFilter}
valueInStateFromFilter={valueInStateFromFilter}
/>
);
if (
stateToFilterVariable instanceof CustomVariable &&
stateFromFilterVariable instanceof CustomVariable &&
labelsFiltersVariable instanceof TextBoxVariable
) {
return (
<HistoryEventsList
timeRange={timeRange}
valueInLabelFilter={labelsFiltersVariable.state.value}
addFilter={addFilter}
valueInStateToFilter={stateToFilterVariable.state.value}
valueInStateFromFilter={stateFromFilterVariable.state.value}
/>
);
} else {
return null;
}
}

View File

@ -39,7 +39,7 @@ exports[`historyResultToDataFrame should decode 1`] = `
]
`;
exports[`historyResultToDataFrame should decode and filter 1`] = `
exports[`historyResultToDataFrame should decode and filter example1 1`] = `
[
{
"fields": [
@ -69,3 +69,34 @@ exports[`historyResultToDataFrame should decode and filter 1`] = `
},
]
`;
exports[`historyResultToDataFrame should decode and filter example2 1`] = `
[
{
"fields": [
{
"config": {
"custom": {
"fillOpacity": 100,
},
"displayName": "Time",
},
"name": "time",
"type": "time",
"values": [
1727189670000,
],
},
{
"config": {},
"name": "value",
"type": "number",
"values": [
8,
],
},
],
"length": 1,
},
]
`;

View File

@ -6,7 +6,18 @@ describe('historyResultToDataFrame', () => {
expect(historyResultToDataFrame(fixtureData)).toMatchSnapshot();
});
it('should decode and filter', () => {
expect(historyResultToDataFrame(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting' })).toMatchSnapshot();
it('should decode and filter example1', () => {
expect(
historyResultToDataFrame(fixtureData, {
stateFrom: 'Pending',
stateTo: 'Alerting',
labels: "alertname: 'XSS attack vector'",
})
).toMatchSnapshot();
});
it('should decode and filter example2', () => {
expect(
historyResultToDataFrame(fixtureData, { stateFrom: 'Normal', stateTo: 'NoData', labels: 'region: EMEA' })
).toMatchSnapshot();
});
});

View File

@ -24,9 +24,16 @@ import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues }
const GROUPING_INTERVAL = 10 * 1000; // 10 seconds
const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL
const emptyFilters = {
interface HistoryFilters {
stateTo: string;
stateFrom: string;
labels: string;
}
const emptyFilters: HistoryFilters = {
stateTo: 'all',
stateFrom: 'all',
labels: '',
};
/*
@ -75,7 +82,7 @@ export function historyResultToDataFrame({ data }: DataFrameJSON, filters = empt
});
// Group DataFrames by time and filter by labels
return groupDataFramesByTimeAndFilterByLabels(dataFrames);
return groupDataFramesByTimeAndFilterByLabels(dataFrames, filters);
}
// Scenes sync variables in the URL adding a prefix to the variable name.
@ -98,9 +105,9 @@ export function getStateFilterFromInQueryParams() {
* This function groups the data frames by time and filters them by labels.
* The interval is set to 10 seconds.
* */
function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[]): DataFrame[] {
export function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[], filters: HistoryFilters): DataFrame[] {
// Filter data frames by labels. This is used to filter out the data frames that do not match the query.
const labelsFilterValue = getLabelsFilterInQueryParams();
const labelsFilterValue = filters.labels;
const dataframesFiltered = dataFrames.filter((frame) => {
const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified