diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx index 066ba2fb546..6e52b27e1d2 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx @@ -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, PANEL_STYLES } from '../../../home/Insights'; -import { SectionSubheader } from '../../../insights/SectionSubheader'; +import { DataSourceInformation } from '../../../home/Insights'; -import { HistoryEventsListObjectRenderer } from './CentralAlertHistory'; +import { alertStateHistoryDatasource, useRegisterHistoryRuntimeDataSource } from './CentralHistoryRuntimeDataSource'; +import { HistoryEventsListObject } from './EventListSceneObject'; + +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 ; }; - -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 ( +
+ + + Filter events using label querying without spaces, ex: + +
{`{severity="critical", instance=~"cluster-us-.+"}`}
+ Invalid use of spaces: +
{`{severity= "critical"}`}
+
{`{severity ="critical"}`}
+ Valid use of spaces: +
{`{severity=" critical"}`}
+ + Filter alerts using label querying without braces, ex: + +
{`severity="critical", instance=~"cluster-us-.+"`}
+
+ } + > + + + + ); +}; + +const getStyles = () => ({ + container: css({ + padding: '0', + alignSelf: 'center', + }), +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts new file mode 100644 index 00000000000..287eceabbf1 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts @@ -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): Promise { + const from = request.range.from.unix(); + const to = request.range.to.unix(); + + return { + data: historyResultToDataFrame(await getHistory(from, to)), + }; + } + + testDatasource(): Promise { + 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(); +}; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistory.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx similarity index 69% rename from public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistory.tsx rename to public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx index 26b6db879ce..33d39a6eb01 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistory.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx @@ -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'; +import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; -const LIMIT_EVENTS = 250; +import { LABELS_FILTER } from './CentralAlertHistoryScene'; -const HistoryEventsList = ({ timeRange }: { timeRange?: TimeRange }) => { - const styles = useStyles2(getStyles); +export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level. - // Filter state - const [eventsFilter, setEventsFilter] = useState(''); - // form for filter fields - const { register, handleSubmit, reset } = useForm({ defaultValues: { query: '' } }); // form for search field +/** + * + * 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 ; } return ( - -
-
setEventsFilter(data.query))}> - - - -
+ <> -
+ ); }; // 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(); - return
{visible && }
; + return
{visible && }
; }; interface HistoryLogEventsProps { logRecords: LogRecord[]; } function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) { - // display log records return (
    {logRecords.map((record) => { - return ; + return ; })}
); @@ -129,52 +107,12 @@ function HistoryErrorMessage({ error }: HistoryErrorMessageProps) { return {stringifyErrorLike(error)}; } -interface SearchFieldInputProps { - showClearFilterSuffix: boolean; - onClearFilterClick: () => void; -} -const SearchFieldInput = forwardRef( - ({ showClearFilterSuffix, onClearFilterClick, ...rest }: SearchFieldInputProps, ref) => { - const placeholder = t('central-alert-history.filter.placeholder', 'Filter events in the list with labels'); - return ( - - - - Filter events - - - - } - > - } - suffix={ - showClearFilterSuffix && ( - - ) - } - placeholder={placeholder} - ref={ref} - {...rest} - /> - - ); - } -); - -SearchFieldInput.displayName = 'SearchFieldInput'; - function EventRow({ record }: { record: LogRecord }) { const styles = useStyles2(getStyles); const [isCollapsed, setIsCollapsed] = useState(true); return (
-
+
{ 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) { const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph + const filtersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model)!; - return ; + const valueInfilterTextBox: VariableValue = !(filtersVariable instanceof TextBoxVariable) + ? '' + : filtersVariable.getValue(); + + return ; +} + +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 { + historyRecords: logRecords, + }; + }, [stateHistory, filter]); } diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx new file mode 100644 index 00000000000..d72b0f3922f --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx @@ -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(); + 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/*' + ); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/historyResultToDataFrame.test.ts b/public/app/features/alerting/unified/components/rules/central-state-history/historyResultToDataFrame.test.ts new file mode 100644 index 00000000000..da01be495d5 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/historyResultToDataFrame.test.ts @@ -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]); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts new file mode 100644 index 00000000000..02cd7a7dc8d --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts @@ -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(([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; +} diff --git a/public/app/features/alerting/unified/components/rules/state-history/common.ts b/public/app/features/alerting/unified/components/rules/state-history/common.ts index d6d924dccdf..a3f486752da 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/common.ts +++ b/public/app/features/alerting/unified/components/rules/state-history/common.ts @@ -7,6 +7,7 @@ export interface Line { current: GrafanaAlertStateWithReason; values?: Record; labels?: Record; + fingerprint?: string; ruleUID?: string; } diff --git a/public/app/features/alerting/unified/mocks/alertRuleApi.ts b/public/app/features/alerting/unified/mocks/alertRuleApi.ts index b4f395f76ca..7e2802c71ed 100644 --- a/public/app/features/alerting/unified/mocks/alertRuleApi.ts +++ b/public/app/features/alerting/unified/mocks/alertRuleApi.ts @@ -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 = { [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', + }, + ], + ], + }, +}); diff --git a/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts b/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts index 7c0d081d2f8..c784061a47e 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts @@ -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((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; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 44c650bb277..84d88cce4f2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 0de710f6910..46369331744 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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": {