Alerting: Use runtime data source for getting events from alert state history in the bar chart (#89307)

* Use runtime data source for getting events from alert state history in the bar chart

* extract translations

* refactor

* More refactor

* Update events limit

* Add info icon with tooltip info for label querying filter

* Add translations

* Create new useRuleHistoryRecords hook skipping extraction of common labels as they are not used

* Fix test

* update limit value for the events in the api to 5000

* Use state for rows key

* remove React import

* Address review comments

* Address review comments

* run prettier

* Remove duplicated handlers
pull/89995/head
Sonia Aguilar 11 months ago committed by Ryan McKinley
parent c3337c39b1
commit 523d999414
  1. 138
      public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx
  2. 79
      public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts
  3. 193
      public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx
  4. 30
      public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx
  5. 35
      public/app/features/alerting/unified/components/rules/central-state-history/historyResultToDataFrame.test.ts
  6. 138
      public/app/features/alerting/unified/components/rules/central-state-history/utils.ts
  7. 1
      public/app/features/alerting/unified/components/rules/state-history/common.ts
  8. 167
      public/app/features/alerting/unified/mocks/alertRuleApi.ts
  9. 21
      public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts
  10. 11
      public/locales/en-US/grafana.json
  11. 11
      public/locales/pseudo-LOCALE/grafana.json

@ -1,4 +1,5 @@
import { getDataSourceSrv } from '@grafana/runtime';
import { css } from '@emotion/css';
import {
EmbeddedScene,
PanelBuilders,
@ -9,35 +10,64 @@ import {
SceneReactObject,
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneVariableSet,
TextBoxVariable,
VariableValueSelectors,
useUrlSync,
} from '@grafana/scenes';
import { GraphDrawStyle, VisibilityMode } from '@grafana/schema/dist/esm/index';
import {
GraphDrawStyle,
GraphGradientMode,
Icon,
LegendDisplayMode,
LineInterpolation,
ScaleDistribution,
StackingMode,
Tooltip,
TooltipDisplayMode,
VisibilityMode,
} from '@grafana/schema/dist/esm/index';
useStyles2,
} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { DataSourceInformation } from '../../../home/Insights';
import { DataSourceInformation, PANEL_STYLES } from '../../../home/Insights';
import { SectionSubheader } from '../../../insights/SectionSubheader';
import { alertStateHistoryDatasource, useRegisterHistoryRuntimeDataSource } from './CentralHistoryRuntimeDataSource';
import { HistoryEventsListObject } from './EventListSceneObject';
import { HistoryEventsListObjectRenderer } from './CentralAlertHistory';
export const LABELS_FILTER = 'filter';
/**
*
* This scene shows the history of the alert state changes.
* It shows a timeseries panel with the alert state changes and a list of the events.
* The events in the panel are fetched from the history api, through a runtime datasource.
* The events in the list are fetched direclty from the history api.
* Main scene renders two children scene objects, one for the timeseries panel and one for the list of events.
* Both share time range and filter variable from the parent scene.
*/
export const CentralAlertHistoryScene = () => {
const dataSourceSrv = getDataSourceSrv();
const alertStateHistoryDatasource: DataSourceInformation = {
type: 'loki',
uid: 'grafanacloud-alert-state-history',
settings: undefined,
};
const filterVariable = new TextBoxVariable({
name: LABELS_FILTER,
label: 'Filter by labels: ',
});
alertStateHistoryDatasource.settings = dataSourceSrv.getInstanceSettings(alertStateHistoryDatasource.uid);
useRegisterHistoryRuntimeDataSource(); // register the runtime datasource for the history api.
const scene = new EmbeddedScene({
controls: [new SceneControlsSpacer(), new SceneTimePicker({}), new SceneRefreshPicker({})],
controls: [
new SceneReactObject({
component: FilterInfo,
}),
new VariableValueSelectors({}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
$timeRange: new SceneTimeRange({}), //needed for using the time range sync in the url
$variables: new SceneVariableSet({
variables: [filterVariable],
}),
body: new SceneFlexLayout({
direction: 'column',
children: [
@ -46,31 +76,35 @@ export const CentralAlertHistoryScene = () => {
body: getEventsSceneObject(alertStateHistoryDatasource),
}),
new SceneFlexItem({
body: new SceneReactObject({
component: HistoryEventsListObjectRenderer,
}),
body: new HistoryEventsListObject(),
}),
],
}),
});
// we need to call this to sync the url with the scene state
const isUrlSyncInitialized = useUrlSync(scene);
if (!isUrlSyncInitialized) {
return null;
}
return <scene.Component model={scene} />;
};
function getEventsSceneObject(ashDs: DataSourceInformation) {
/**
* 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: [
new SceneReactObject({
component: SectionSubheader,
}),
],
controls: [],
body: new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
ySizing: 'content',
body: new SceneFlexLayout({
children: [getEventsScenesFlexItem(ashDs)],
children: [getEventsScenesFlexItem(alertStateHistoryDataSource)],
}),
}),
],
@ -78,13 +112,18 @@ function getEventsSceneObject(ashDs: DataSourceInformation) {
});
}
/**
* Creates a SceneQueryRunner with the datasource information for the runtime datasource.
* @param datasource the datasource information for the runtime datasource
* @returns the SceneQueryRunner
*/
function getSceneQuery(datasource: DataSourceInformation) {
const query = new SceneQueryRunner({
datasource,
datasource: datasource,
queries: [
{
refId: 'A',
expr: 'count_over_time({from="state-history"} |= `` [$__auto])',
expr: '',
queryType: 'range',
step: '10s',
},
@ -92,10 +131,13 @@ function getSceneQuery(datasource: DataSourceInformation) {
});
return query;
}
/**
* 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) {
return new SceneFlexItem({
...PANEL_STYLES,
minHeight: 300,
body: PanelBuilders.timeseries()
.setTitle('Events')
.setDescription('Alert events during the period of time.')
@ -120,3 +162,39 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
.build(),
});
}
export const FilterInfo = () => {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<Tooltip
content={
<div>
<Trans i18nKey="central-alert-history.filter.info.label1">
Filter events using label querying without spaces, ex:
</Trans>
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
<Trans i18nKey="central-alert-history.filter.info.label2">Invalid use of spaces:</Trans>
<pre>{`{severity= "critical"}`}</pre>
<pre>{`{severity ="critical"}`}</pre>
<Trans i18nKey="central-alert-history.filter.info.label3">Valid use of spaces:</Trans>
<pre>{`{severity=" critical"}`}</pre>
<Trans i18nKey="central-alert-history.filter.info.label4">
Filter alerts using label querying without braces, ex:
</Trans>
<pre>{`severity="critical", instance=~"cluster-us-.+"`}</pre>
</div>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</div>
);
};
const getStyles = () => ({
container: css({
padding: '0',
alignSelf: 'center',
}),
});

@ -0,0 +1,79 @@
import { useEffect, useMemo } from 'react';
import { DataQuery, DataQueryRequest, DataQueryResponse, TestDataSourceResponse } from '@grafana/data';
import { RuntimeDataSource, sceneUtils } from '@grafana/scenes';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { dispatch } from 'app/store/store';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { DataSourceInformation } from '../../../home/Insights';
import { LIMIT_EVENTS } from './EventListSceneObject';
import { historyResultToDataFrame } from './utils';
const historyDataSourceUid = '__history_api_ds_uid__';
const historyDataSourcePluginId = '__history_api_ds_pluginId__';
export const alertStateHistoryDatasource: DataSourceInformation = {
type: historyDataSourcePluginId,
uid: historyDataSourceUid,
settings: undefined,
};
export function useRegisterHistoryRuntimeDataSource() {
// we need to memoize the datasource so it is not registered multiple times for each render
const ds = useMemo(() => new HistoryAPIDatasource(historyDataSourceUid, historyDataSourcePluginId), []);
useEffect(() => {
try {
// avoid showing error when the datasource is already registered
sceneUtils.registerRuntimeDataSource({ dataSource: ds });
} catch (e) {}
}, [ds]);
}
/**
* 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.
* The DataFrame list is then grouped by time.
* 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 {
constructor(pluginId: string, uid: string) {
super(uid, pluginId);
}
async query(request: DataQueryRequest<DataQuery>): Promise<DataQueryResponse> {
const from = request.range.from.unix();
const to = request.range.to.unix();
return {
data: historyResultToDataFrame(await getHistory(from, to)),
};
}
testDatasource(): Promise<TestDataSourceResponse> {
return Promise.resolve({ status: 'success', message: 'Data source is working', title: 'Success' });
}
}
/**
* Fetch the history events from the history api.
* @param from the start time
* @param to the end time
* @returns the history events only filtered by time
*/
export const getHistory = (from: number, to: number) => {
return dispatch(
stateHistoryApi.endpoints.getRuleHistory.initiate(
{
from: from,
to: to,
limit: LIMIT_EVENTS,
},
{
forceRefetch: Boolean(getTimeSrv().getAutoRefreshInteval().interval), // force refetch in case we are using the refresh option
}
)
).unwrap();
};

@ -1,27 +1,13 @@
import { css } from '@emotion/css';
import { forwardRef, useCallback, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { DataFrameJSON, GrafanaTheme2, TimeRange } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes';
import {
Alert,
Button,
Field,
Icon,
Input,
Label,
LoadingBar,
Stack,
Text,
Tooltip,
useStyles2,
withErrorBoundary,
} from '@grafana/ui';
import { SceneComponentProps, SceneObjectBase, TextBoxVariable, VariableValue, sceneGraph } from '@grafana/scenes';
import { Alert, Icon, LoadingBar, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { Trans, t } from 'app/core/internationalization';
import { t } from 'app/core/internationalization';
import {
GrafanaAlertStateWithReason,
isAlertStateWithReason,
@ -31,86 +17,78 @@ import {
} from 'app/types/unified-alerting-dto';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { stringifyErrorLike } from '../../../utils/misc';
import { hashLabelsOrAnnotations } from '../../../utils/rule-id';
import { AlertLabels } from '../../AlertLabels';
import { CollapseToggle } from '../../CollapseToggle';
import { LogRecord } from '../state-history/common';
import { useRuleHistoryRecords } from '../state-history/useRuleHistoryRecords';
const LIMIT_EVENTS = 250;
const HistoryEventsList = ({ timeRange }: { timeRange?: TimeRange }) => {
const styles = useStyles2(getStyles);
// Filter state
const [eventsFilter, setEventsFilter] = useState('');
// form for filter fields
const { register, handleSubmit, reset } = useForm({ defaultValues: { query: '' } }); // form for search field
import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords';
import { LABELS_FILTER } from './CentralAlertHistoryScene';
export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level.
/**
*
* This component displays a list of history events.
* It fetches the events from the history api and displays them in a list.
* The list is filtered by the labels in the filter variable and by the time range variable in the scene graph.
*/
export const HistoryEventsList = ({
timeRange,
valueInfilterTextBox,
}: {
timeRange?: TimeRange;
valueInfilterTextBox: VariableValue;
}) => {
const from = timeRange?.from.unix();
const to = timeRange?.to.unix();
const onFilterCleared = useCallback(() => {
setEventsFilter('');
reset();
}, [setEventsFilter, reset]);
const {
data: stateHistory,
isLoading,
isError,
error,
} = stateHistoryApi.endpoints.getRuleHistory.useQuery(
{
from: from,
to: to,
limit: LIMIT_EVENTS,
},
{
refetchOnFocus: true,
refetchOnReconnect: true,
}
} = stateHistoryApi.endpoints.getRuleHistory.useQuery({
from: from,
to: to,
limit: LIMIT_EVENTS,
});
const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords(
stateHistory,
valueInfilterTextBox.toString()
);
const { historyRecords } = useRuleHistoryRecords(stateHistory, eventsFilter);
const historyRecords = historyRecordsNotSorted.sort((a, b) => b.timestamp - a.timestamp);
if (isError) {
return <HistoryErrorMessage error={error} />;
}
return (
<Stack direction="column" gap={1}>
<div className={styles.labelsFilter}>
<form onSubmit={handleSubmit((data) => setEventsFilter(data.query))}>
<SearchFieldInput
{...register('query')}
showClearFilterSuffix={!!eventsFilter}
onClearFilterClick={onFilterCleared}
/>
<input type="submit" hidden />
</form>
</div>
<>
<LoadingIndicator visible={isLoading} />
<HistoryLogEvents logRecords={historyRecords} />
</Stack>
</>
);
};
// todo: this function has been copied from RuleList.v2.tsx, should be moved to a shared location
const LoadingIndicator = ({ visible = false }) => {
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
return <div ref={measureRef}>{visible && <LoadingBar width={width} />}</div>;
return <div ref={measureRef}>{visible && <LoadingBar width={width} data-testid="loading-bar" />}</div>;
};
interface HistoryLogEventsProps {
logRecords: LogRecord[];
}
function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
// display log records
return (
<ul>
{logRecords.map((record) => {
return <EventRow key={record.timestamp + hashLabelsOrAnnotations(record.line.labels ?? {})} record={record} />;
return <EventRow key={record.timestamp + (record.line.fingerprint ?? '')} record={record} />;
})}
</ul>
);
@ -129,52 +107,12 @@ function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
return <Alert title={title}>{stringifyErrorLike(error)}</Alert>;
}
interface SearchFieldInputProps {
showClearFilterSuffix: boolean;
onClearFilterClick: () => void;
}
const SearchFieldInput = forwardRef<HTMLInputElement, SearchFieldInputProps>(
({ showClearFilterSuffix, onClearFilterClick, ...rest }: SearchFieldInputProps, ref) => {
const placeholder = t('central-alert-history.filter.placeholder', 'Filter events in the list with labels');
return (
<Field
label={
<Label htmlFor="eventsSearchInput">
<Stack gap={0.5}>
<span>
<Trans i18nKey="central-alert-history.filter.label">Filter events</Trans>
</span>
</Stack>
</Label>
}
>
<Input
id="eventsSearchInput"
prefix={<Icon name="search" />}
suffix={
showClearFilterSuffix && (
<Button fill="text" icon="times" size="sm" onClick={onClearFilterClick}>
<Trans i18nKey="central-alert-history.filter.button.clear">Clear</Trans>
</Button>
)
}
placeholder={placeholder}
ref={ref}
{...rest}
/>
</Field>
);
}
);
SearchFieldInput.displayName = 'SearchFieldInput';
function EventRow({ record }: { record: LogRecord }) {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
return (
<div>
<div className={styles.header} data-testid="rule-group-header">
<div className={styles.header} data-testid="event-row-header">
<CollapseToggle
size="sm"
className={styles.collapseToggle}
@ -365,19 +303,60 @@ export const getStyles = (theme: GrafanaTheme2) => {
display: 'block',
color: theme.colors.text.link,
}),
labelsFilter: css({
width: '100%',
paddingTop: theme.spacing(4),
}),
};
};
/**
* This is a scene object that displays a list of history events.
*/
export class HistoryEventsListObject extends SceneObjectBase {
public static Component = HistoryEventsListObjectRenderer;
public constructor() {
super({});
}
}
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) {
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
const filtersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model)!;
const valueInfilterTextBox: VariableValue = !(filtersVariable instanceof TextBoxVariable)
? ''
: filtersVariable.getValue();
return <HistoryEventsList timeRange={timeRange} valueInfilterTextBox={valueInfilterTextBox} />;
}
function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) {
return useMemo(() => {
if (!stateHistory?.data) {
return { historyRecords: [] };
}
const filterMatchers = filter ? parseMatchers(filter) : [];
const [tsValues, lines] = stateHistory.data.values;
const timestamps = isNumbers(tsValues) ? tsValues : [];
// merge timestamp with "line"
const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => {
const line = lines[index];
if (!isLine(line)) {
return acc;
}
// values property can be undefined for some instance states (e.g. NoData)
const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers);
if (filterMatch) {
acc.push({ timestamp, line });
}
return acc;
}, []);
return <HistoryEventsList timeRange={timeRange} />;
return {
historyRecords: logRecords,
};
}, [stateHistory, filter]);
}

@ -0,0 +1,30 @@
import { render, waitFor } from 'test/test-utils';
import { byLabelText, byTestId } from 'testing-library-selector';
import { setupMswServer } from '../../../mockApi';
import { HistoryEventsList } from './EventListSceneObject';
setupMswServer();
// msw server is setup to intercept the history api call and return the mocked data by default
// that consists in 4 rows.
// 2 rows for alert1 and 2 rows for alert2
const ui = {
rowHeader: byTestId('event-row-header'),
};
describe('HistoryEventsList', () => {
it('should render the list correctly filtered by label in filter variable', async () => {
render(<HistoryEventsList valueInfilterTextBox={'alertname=alert1'} />);
await waitFor(() => {
expect(byLabelText('Loading bar').query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.getAll()).toHaveLength(2); // 2 events for alert1
expect(ui.rowHeader.getAll()[0]).toHaveTextContent(
'June 14 at 06:39:00alert1alertnamealert1grafana_folderFOLDER Ahandler/alerting/*'
);
expect(ui.rowHeader.getAll()[1]).toHaveTextContent(
'June 14 at 06:38:30alert1alertnamealert1grafana_folderFOLDER Ahandler/alerting/*'
);
});
});

@ -0,0 +1,35 @@
import {
getHistoryResponse,
time_0,
time_plus_10,
time_plus_15,
time_plus_30,
time_plus_5,
} from '../../../mocks/alertRuleApi';
import { historyResultToDataFrame } from './utils';
describe('historyResultToDataFrame', () => {
it('should return correct result grouping by 10 seconds', async () => {
const result = historyResultToDataFrame(getHistoryResponse([time_0, time_0, time_plus_30, time_plus_30]));
expect(result[0].length).toBe(2);
expect(result[0].fields[0].name).toBe('time');
expect(result[0].fields[1].name).toBe('value');
expect(result[0].fields[0].values).toStrictEqual([time_0, time_plus_30]);
expect(result[0].fields[1].values).toStrictEqual([2, 2]);
const result2 = historyResultToDataFrame(getHistoryResponse([time_0, time_plus_5, time_plus_30, time_plus_30]));
expect(result2[0].length).toBe(2);
expect(result2[0].fields[0].name).toBe('time');
expect(result2[0].fields[1].name).toBe('value');
expect(result2[0].fields[0].values).toStrictEqual([time_0, time_plus_30]);
expect(result2[0].fields[1].values).toStrictEqual([2, 2]);
const result3 = historyResultToDataFrame(getHistoryResponse([time_0, time_plus_15, time_plus_10, time_plus_30]));
expect(result3[0].length).toBe(3);
expect(result3[0].fields[0].name).toBe('time');
expect(result3[0].fields[1].name).toBe('value');
expect(result3[0].fields[0].values).toStrictEqual([time_0, time_plus_10, time_plus_30]);
expect(result3[0].fields[1].values).toStrictEqual([1, 2, 1]);
});
});

@ -0,0 +1,138 @@
import { groupBy } from 'lodash';
import { DataFrame, Field as DataFrameField, DataFrameJSON, Field, FieldType } from '@grafana/data';
import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers';
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
import { LogRecord } from '../state-history/common';
import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords';
import { LABELS_FILTER } from './CentralAlertHistoryScene';
const GROUPING_INTERVAL = 10 * 1000; // 10 seconds
const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL
/*
* This function is used to convert the history response to a DataFrame list and filter the data by labels.
* The response is a list of log records, each log record has a timestamp and a line.
* We group all records by alert instance (unique set of labels) and create a DataFrame for each group (instance).
* This allows us to be able to filter by labels in the groupDataFramesByTime function.
*/
export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] {
const tsValues = data?.data?.values[0] ?? [];
const timestamps: number[] = isNumbers(tsValues) ? tsValues : [];
const lines = data?.data?.values[1] ?? [];
const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => {
const line = lines[index];
// values property can be undefined for some instance states (e.g. NoData)
if (isLine(line)) {
acc.push({ timestamp, line });
}
return acc;
}, []);
// Group log records by alert instance
const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => {
return JSON.stringify(record.line.labels);
});
// Convert each group of log records to a DataFrame
const dataFrames: DataFrame[] = Object.entries(logRecordsByInstance).map<DataFrame>(([key, records]) => {
// key is the stringified labels
return logRecordsToDataFrame(key, records);
});
// Group DataFrames by time and filter by labels
return groupDataFramesByTimeAndFilterByLabels(dataFrames);
}
// Scenes sync variables in the URL adding a prefix to the variable name.
function getFilterInQueryParams() {
const queryParams = new URLSearchParams(window.location.search);
return queryParams.get(`${QUERY_PARAM_PREFIX}${LABELS_FILTER}`) ?? '';
}
/*
* 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[] {
// Filter data frames by labels. This is used to filter out the data frames that do not match the query.
const filterValue = getFilterInQueryParams();
const dataframesFiltered = dataFrames.filter((frame) => {
const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified
const matchers = Boolean(filterValue) ? parseMatchers(filterValue) : [];
return labelsMatchMatchers(labels, matchers);
});
// Extract time fields from filtered data frames
const timeFieldList = dataframesFiltered.flatMap((frame) => frame.fields.find((field) => field.name === 'time'));
// Group time fields by interval
const groupedTimeFields = groupBy(
timeFieldList?.flatMap((tf) => tf?.values),
(time: number) => Math.floor(time / GROUPING_INTERVAL) * GROUPING_INTERVAL
);
// Create new time field with grouped time values
const newTimeField: Field = {
name: 'time',
type: FieldType.time,
values: Object.keys(groupedTimeFields).map(Number),
config: { displayName: 'Time', custom: { fillOpacity: 100 } },
};
// Create count field with count of records in each group
const countField: Field = {
name: 'value',
type: FieldType.number,
values: Object.values(groupedTimeFields).map((group) => group.length),
config: {},
};
// Return new DataFrame with time and count fields
return [
{
fields: [newTimeField, countField],
length: newTimeField.values.length,
},
];
}
/*
* This function is used to convert the log records to a DataFrame.
* The DataFrame has two fields: time and value.
* The time field is the timestamp of the log record.
* The value field is always 1.
* */
function logRecordsToDataFrame(instanceLabels: string, records: LogRecord[]): DataFrame {
const timeField: DataFrameField = {
name: 'time',
type: FieldType.time,
values: [...records.map((record) => record.timestamp)],
config: { displayName: 'Time', custom: { fillOpacity: 100 } },
};
// Sort time field values
const timeIndex = timeField.values.map((_, index) => index);
timeIndex.sort(fieldIndexComparer(timeField));
// Create DataFrame with time and value fields
const frame: DataFrame = {
fields: [
{
...timeField,
values: timeField.values.map((_, i) => timeField.values[timeIndex[i]]),
},
{
name: instanceLabels,
type: FieldType.number,
values: timeField.values.map((record) => 1),
config: {},
},
],
length: timeField.values.length,
name: instanceLabels,
};
return frame;
}

@ -7,6 +7,7 @@ export interface Line {
current: GrafanaAlertStateWithReason;
values?: Record<string, number>;
labels?: Record<string, string>;
fingerprint?: string;
ruleUID?: string;
}

@ -1,6 +1,7 @@
import { http, HttpResponse } from 'msw';
import { SetupServer } from 'msw/node';
import { FieldType } from '@grafana/data';
import {
GrafanaAlertStateDecision,
PromRulesResponse,
@ -8,7 +9,7 @@ import {
RulerRuleGroupDTO,
} from 'app/types/unified-alerting-dto';
import { PreviewResponse, PREVIEW_URL, PROM_RULES_URL } from '../api/alertRuleApi';
import { PREVIEW_URL, PreviewResponse, PROM_RULES_URL } from '../api/alertRuleApi';
import { Annotation } from '../utils/constants';
export function mockPreviewApiResponse(server: SetupServer, result: PreviewResponse) {
@ -80,3 +81,167 @@ export const namespaces: Record<string, RulerRuleGroupDTO[]> = {
[grafanaRulerNamespace.uid]: [grafanaRulerGroup],
[grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup],
};
//-------------------- for alert history tests we reuse these constants --------------------
export const time_0 = 1718368710000;
// time1 + 30 seg
export const time_plus_30 = 1718368740000;
// time1 + 5 seg
export const time_plus_5 = 1718368715000;
// time1 + 15 seg
export const time_plus_15 = 1718368725000;
// time1 + 10 seg
export const time_plus_10 = 1718368720000;
// returns 4 transitions. times is an array of 4 timestamps.
export const getHistoryResponse = (times: number[]) => ({
schema: {
fields: [
{
name: 'time',
type: FieldType.time,
labels: {},
},
{
name: 'line',
type: FieldType.other,
labels: {},
},
{
name: 'labels',
type: FieldType.other,
labels: {},
},
],
},
data: {
values: [
[...times],
[
{
schemaVersion: 1,
previous: 'Pending',
current: 'Alerting',
value: {
A: 1,
B: 1,
C: 1,
},
condition: 'C',
dashboardUID: '',
panelID: 0,
fingerprint: '141da2d491f61029',
ruleTitle: 'alert1',
ruleID: 7,
ruleUID: 'adnpo0g62bg1sb',
labels: {
alertname: 'alert1',
grafana_folder: 'FOLDER A',
handler: '/alerting/*',
},
},
{
schemaVersion: 1,
previous: 'Pending',
current: 'Alerting',
value: {
A: 1,
B: 1,
C: 1,
},
condition: 'C',
dashboardUID: '',
panelID: 0,
fingerprint: '141da2d491f61029',
ruleTitle: 'alert2',
ruleID: 3,
ruleUID: 'adna1xso80hdsd',
labels: {
alertname: 'alert2',
grafana_folder: 'FOLDER A',
handler: '/alerting/*',
},
},
{
schemaVersion: 1,
previous: 'Pending',
current: 'Alerting',
value: {
A: 1,
B: 1,
C: 1,
},
condition: 'C',
dashboardUID: '',
panelID: 0,
fingerprint: '141da2d491f61029',
ruleTitle: 'alert1',
ruleID: 7,
ruleUID: 'adnpo0g62bg1sb',
labels: {
alertname: 'alert1',
grafana_folder: 'FOLDER A',
handler: '/alerting/*',
},
},
{
schemaVersion: 1,
previous: 'Pending',
current: 'Alerting',
value: {
A: 1,
B: 1,
C: 1,
},
condition: 'C',
dashboardUID: '',
panelID: 0,
fingerprint: '5d438530c73fc657',
ruleTitle: 'alert2',
ruleID: 3,
ruleUID: 'adna1xso80hdsd',
labels: {
alertname: 'alert2',
grafana_folder: 'FOLDER A',
handler: '/alerting/*',
},
},
],
[
{
folderUID: 'edlvwh5881z40e',
from: 'state-history',
group: 'GROUP111',
level: 'info',
orgID: '1',
service_name: 'unknown_service',
},
{
folderUID: 'edlvwh5881z40e',
from: 'state-history',
group: 'GROUP111',
level: 'info',
orgID: '1',
service_name: 'unknown_service',
},
{
folderUID: 'edlvwh5881z40e',
from: 'state-history',
group: 'GROUP111',
level: 'info',
orgID: '1',
service_name: 'unknown_service',
},
{
folderUID: 'edlvwh5881z40e',
from: 'state-history',
group: 'GROUP111',
level: 'info',
orgID: '1',
service_name: 'unknown_service',
},
],
],
},
});

@ -8,9 +8,15 @@ import {
RulerRulesConfigDTO,
} from '../../../../../../types/unified-alerting-dto';
import { AlertGroupUpdated } from '../../../api/alertRuleApi';
import { grafanaRulerRule, namespaceByUid, namespaces } from '../../alertRuleApi';
import {
getHistoryResponse,
grafanaRulerRule,
namespaceByUid,
namespaces,
time_0,
time_plus_30,
} from '../../alertRuleApi';
import { HandlerOptions } from '../configure';
export const rulerRulesHandler = () => {
return http.get(`/api/ruler/grafana/api/v1/rules`, () => {
const response = Object.entries(namespaces).reduce<RulerRulesConfigDTO>((acc, [namespaceUid, groups]) => {
@ -118,12 +124,19 @@ export const rulerRuleHandler = () => {
});
};
export const historyHandler = () => {
return http.get('/api/v1/rules/history', () => {
return HttpResponse.json(getHistoryResponse([time_0, time_0, time_plus_30, time_plus_30]));
});
};
const handlers = [
rulerRulesHandler(),
getRulerRuleNamespaceHandler(),
updateRulerRuleNamespaceHandler(),
rulerRuleGroupHandler(),
deleteRulerRuleGroupHandler(),
rulerRuleHandler(),
historyHandler(),
updateRulerRuleNamespaceHandler(),
deleteRulerRuleGroupHandler(),
];
export default handlers;

@ -158,11 +158,12 @@
"central-alert-history": {
"error": "Something went wrong loading the alert state history",
"filter": {
"button": {
"clear": "Clear"
},
"label": "Filter events",
"placeholder": "Filter events in the list with labels"
"info": {
"label1": "Filter events using label querying without spaces, ex:",
"label2": "Invalid use of spaces:",
"label3": "Valid use of spaces:",
"label4": "Filter alerts using label querying without braces, ex:"
}
}
},
"clipboard-button": {

@ -158,11 +158,12 @@
"central-alert-history": {
"error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
"filter": {
"button": {
"clear": "Cľęäř"
},
"label": "Fįľŧęř ęvęʼnŧş",
"placeholder": "Fįľŧęř ęvęʼnŧş įʼn ŧĥę ľįşŧ ŵįŧĥ ľäþęľş"
"info": {
"label1": "Fįľŧęř ęvęʼnŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ şpäčęş, ęχ:",
"label2": "Ĩʼnväľįđ ūşę őƒ şpäčęş:",
"label3": "Väľįđ ūşę őƒ şpäčęş:",
"label4": "Fįľŧęř äľęřŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ þřäčęş, ęχ:"
}
}
},
"clipboard-button": {

Loading…
Cancel
Save