+
{
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": {