diff --git a/public/app/features/alerting/state/alertDef.ts b/public/app/features/alerting/state/alertDef.ts index 50af697cef3..7ce82133ac5 100644 --- a/public/app/features/alerting/state/alertDef.ts +++ b/public/app/features/alerting/state/alertDef.ts @@ -1,7 +1,7 @@ import { isArray, reduce } from 'lodash'; import { IconName } from '@grafana/ui'; -import { QueryPart, QueryPartDef } from 'app/features/alerting/state/query_part'; +import { QueryPartDef, QueryPart } from 'app/features/alerting/state/query_part'; const alertQueryDef = new QueryPartDef({ type: 'query', @@ -84,7 +84,7 @@ function createReducerPart(model: any) { // state can also contain a "Reason", ie. "Alerting (NoData)" which indicates that the actual state is "Alerting" but // the reason it is set to "Alerting" is "NoData"; a lack of data points to evaluate. -export function normalizeAlertState(state: string) { +function normalizeAlertState(state: string) { return state.toLowerCase().replace(/_/g, '').split(' ')[0]; } diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index 99649b2db29..b7f57cfa723 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -17,7 +17,6 @@ export const LogMessages = { cancelSavingAlertRule: 'user canceled alert rule creation', successSavingAlertRule: 'alert rule saved successfully', unknownMessageFromError: 'unknown messageFromError', - errorGettingLokiHistory: 'error getting Loki history', }; // logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent correctly diff --git a/public/app/features/alerting/unified/components/rules/state-history/common.test.ts b/public/app/features/alerting/unified/components/rules/state-history/common.test.ts index b4f1d0e69dc..a061efccb70 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/common.test.ts +++ b/public/app/features/alerting/unified/components/rules/state-history/common.test.ts @@ -1,23 +1,4 @@ -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; - -import { - AlertState, - DataFrameJSON, - FieldType, - getDefaultTimeRange, - LoadingState, - PanelData, - toDataFrame, -} from '@grafana/data'; -import { setBackendSrv } from '@grafana/runtime'; -import { backendSrv } from 'app/core/services/backend_srv'; - -import 'whatwg-fetch'; -import { StateHistoryImplementation } from '../../../hooks/useStateHistoryModal'; - -import * as common from './common'; -import { extractCommonLabels, Label, omitLabels, updatePanelDataWithASHFromLoki } from './common'; +import { extractCommonLabels, Label, omitLabels } from './common'; test('extractCommonLabels', () => { const labels: Label[][] = [ @@ -67,120 +48,3 @@ test('omitLabels with no common labels', () => { expect(omitLabels(labels, commonLabels)).toStrictEqual(labels); }); - -const server = setupServer(); - -beforeAll(() => { - setBackendSrv(backendSrv); - server.listen({ onUnhandledRequest: 'error' }); - - server.use( - rest.get('/api/v1/rules/history', (req, res, ctx) => - res( - ctx.json({ - data: { - values: [ - [1681739580000, 1681739580000, 1681739580000], - [ - { - previous: 'Normal', - current: 'Pending', - values: { - B: 0.010344684900897919, - C: 1, - }, - labels: { - handler: '/api/prometheus/grafana/api/v1/rules', - }, - }, - { - previous: 'Normal', - current: 'Pending', - values: { - B: 0.010344684900897919, - C: 1, - }, - dashboardUID: '', - panelID: 0, - labels: { - handler: '/api/live/ws', - }, - }, - { - previous: 'Normal', - current: 'Pending', - values: { - B: 0.010344684900897919, - C: 1, - }, - labels: { - handler: '/api/folders/:uid/', - }, - }, - ], - ], - }, - }) - ) - ) - ); -}); - -afterAll(() => { - server.close(); -}); - -jest.spyOn(common, 'getHistoryImplementation').mockImplementation(() => StateHistoryImplementation.Loki); -const getHistoryImplementationMock = common.getHistoryImplementation as jest.MockedFunction< - typeof common.getHistoryImplementation ->; -const timeRange = getDefaultTimeRange(); -const panelDataProcessed: PanelData = { - alertState: { - id: 1, - dashboardId: 1, - panelId: 1, - state: AlertState.Alerting, - }, - series: [ - toDataFrame({ - fields: [ - { name: 'time', type: FieldType.time }, - { name: 'score', type: FieldType.number }, - ], - }), - ], - annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([{ id: 'dashData' }])], - state: LoadingState.Done, - timeRange, -}; - -describe('updatePanelDataWithASHFromLoki', () => { - it('should return the same panelData if not using Loki as implementation', async () => { - getHistoryImplementationMock.mockImplementation(() => StateHistoryImplementation.Annotations); - - const panelData = await updatePanelDataWithASHFromLoki(panelDataProcessed); - - expect(panelData).toStrictEqual(panelDataProcessed); - expect(panelData.annotations).toHaveLength(2); - }); - - it('should return the correct panelData if using Loki as implementation', async () => { - getHistoryImplementationMock.mockImplementation(() => StateHistoryImplementation.Loki); - - const panelData = await updatePanelDataWithASHFromLoki(panelDataProcessed); - - expect(panelData.annotations).toHaveLength(5); - }); - - it('should return the same panelData if Loki call throws an error', async () => { - getHistoryImplementationMock.mockImplementation(() => StateHistoryImplementation.Loki); - - server.use(rest.get('/api/v1/rules/history', (req, res, ctx) => res(ctx.status(500)))); - - const panelData = await updatePanelDataWithASHFromLoki(panelDataProcessed); - - expect(panelData).toStrictEqual(panelDataProcessed); - expect(panelData.annotations).toHaveLength(2); - }); -}); 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 7dd2299e89c..f797fa1f8a5 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 @@ -1,15 +1,7 @@ -import { cloneDeep, groupBy, isEqual, uniqBy } from 'lodash'; -import { lastValueFrom } from 'rxjs'; +import { isEqual, uniqBy } from 'lodash'; -import { DataFrame, DataFrameJSON, PanelData } from '@grafana/data'; -import { config, getBackendSrv } from '@grafana/runtime'; import { GrafanaAlertStateWithReason } from 'app/types/unified-alerting-dto'; -import { logInfo, LogMessages } from '../../../Analytics'; -import { StateHistoryImplementation } from '../../../hooks/useStateHistoryModal'; - -import { isLine, isNumbers, logRecordsToDataFrameForPanel } from './useRuleHistoryRecords'; - export interface Line { previous: GrafanaAlertStateWithReason; current: GrafanaAlertStateWithReason; @@ -45,108 +37,3 @@ export function extractCommonLabels(labels: Label[][]): Label[] { return commonLabels; } - -export const getLogRecordsByInstances = (stateHistory?: DataFrameJSON) => { - // merge timestamp with "line" - const tsValues = stateHistory?.data?.values[0] ?? []; - const timestamps: number[] = isNumbers(tsValues) ? tsValues : []; - const lines = stateHistory?.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 all records by alert instance (unique set of labels) - const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => { - return JSON.stringify(record.line.labels); - }); - - return { logRecordsByInstance, logRecords }; -}; - -export function getRuleHistoryRecordsForPanel(stateHistory?: DataFrameJSON) { - if (!stateHistory) { - return { dataFrames: [] }; - } - const theme = config.theme2; - - const { logRecordsByInstance } = getLogRecordsByInstances(stateHistory); - - const groupedLines = Object.entries(logRecordsByInstance); - - const dataFrames: DataFrame[] = groupedLines.map(([key, records]) => { - return logRecordsToDataFrameForPanel(key, records, theme); - }); - - return { - dataFrames, - }; -} - -export const getHistoryImplementation = () => { - // can be "loki", "multiple" or "annotations" - const stateHistoryBackend = config.unifiedAlerting.alertStateHistoryBackend; - // can be "loki" or "annotations" - const stateHistoryPrimary = config.unifiedAlerting.alertStateHistoryPrimary; - - // if "loki" is either the backend or the primary, show the new state history implementation - const usingNewAlertStateHistory = [stateHistoryBackend, stateHistoryPrimary].some( - (implementation) => implementation === StateHistoryImplementation.Loki - ); - const implementation = usingNewAlertStateHistory - ? StateHistoryImplementation.Loki - : StateHistoryImplementation.Annotations; - return implementation; -}; - -export const updatePanelDataWithASHFromLoki = async (panelDataProcessed: PanelData) => { - //--- check if alert state history uses Loki as implementation, if so, fetch data from Loki state history and concat it to annotations - const historyImplementation = getHistoryImplementation(); - const usingLokiAsImplementation = historyImplementation === StateHistoryImplementation.Loki; - - const notShouldFetchLokiAsh = - !usingLokiAsImplementation || - !panelDataProcessed.alertState?.dashboardId || - !panelDataProcessed.alertState?.panelId; - - if (notShouldFetchLokiAsh) { - return panelDataProcessed; - } - - try { - // fetch data from Loki state history - let annotationsWithHistory = await lastValueFrom( - getBackendSrv().fetch({ - url: '/api/v1/rules/history', - method: 'GET', - params: { - panelID: panelDataProcessed.request?.panelId, - dashboardUID: panelDataProcessed.request?.dashboardUID, - from: panelDataProcessed.timeRange.from.unix(), - to: panelDataProcessed.timeRange.to.unix(), - limit: 250, - }, - showErrorAlert: false, - showSuccessAlert: false, - }) - ); - const records = getRuleHistoryRecordsForPanel(annotationsWithHistory.data); - const clonedPanel = cloneDeep(panelDataProcessed); - // annotations can be undefined - clonedPanel.annotations = panelDataProcessed.annotations - ? panelDataProcessed.annotations.concat(records.dataFrames) - : panelDataProcessed.annotations; - return clonedPanel; - } catch (error) { - logInfo(LogMessages.errorGettingLokiHistory, { - error: error instanceof Error ? error.message : 'Unknown error getting Loki ash', - }); - return panelDataProcessed; - } -}; diff --git a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.test.tsx index 9c0654613f9..fdcdc7e9d31 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.test.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.test.tsx @@ -1,11 +1,11 @@ import { createTheme, FieldType } from '@grafana/data'; import { LogRecord } from './common'; -import { logRecordsToDataFrame, logRecordsToDataFrameForPanel } from './useRuleHistoryRecords'; - -const theme = createTheme(); +import { logRecordsToDataFrame } from './useRuleHistoryRecords'; describe('logRecordsToDataFrame', () => { + const theme = createTheme(); + it('should convert instance history records into a data frame', () => { const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; const records: LogRecord[] = [ @@ -102,67 +102,3 @@ describe('logRecordsToDataFrame', () => { expect(frame.fields[1].config.displayName).toBe('severity=critical'); }); }); - -describe('logRecordsToDataFrameForPanel', () => { - it('should return correct data frame records', () => { - const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; - const records: LogRecord[] = [ - { - timestamp: 1000000, - line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, values: { A: 10, B: 90 } }, - }, - { - timestamp: 1000050, - line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels }, - }, - ]; - - const frame = logRecordsToDataFrameForPanel(JSON.stringify(instanceLabels), records, theme); - - expect(frame.fields).toHaveLength(6); - expect(frame).toHaveLength(2); - expect(frame.fields[0]).toMatchObject({ - name: 'time', - type: FieldType.time, - values: [1000000, 1000050], - }); - expect(frame.fields[1]).toMatchObject({ - name: 'alertId', - type: FieldType.string, - values: [1, 1], - }); - expect(frame.fields[2]).toMatchObject({ - name: 'newState', - type: FieldType.string, - values: ['Alerting', 'Normal'], - }); - expect(frame.fields[3]).toMatchObject({ - name: 'prevState', - type: FieldType.string, - values: ['Normal', 'Alerting'], - }); - expect(frame.fields[4]).toMatchObject({ - name: 'color', - type: FieldType.string, - values: [theme.colors.error.main, theme.colors.success.main], - }); - expect(frame.fields[5]).toMatchObject({ - name: 'data', - type: FieldType.other, - values: [ - [ - { metric: 'foo', value: 'bar' }, - { metric: 'severity', value: 'critical' }, - { metric: 'cluster', value: 'dev-us' }, - { metric: ' Values', value: '{A= 10, B= 90}' }, - ], - [ - { metric: 'foo', value: 'bar' }, - { metric: 'severity', value: 'critical' }, - { metric: 'cluster', value: 'dev-us' }, - { metric: '', value: '' }, - ], - ], - }); - }); -}); diff --git a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx index 8cf124300f5..67efa185b3a 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx @@ -1,3 +1,4 @@ +import { groupBy } from 'lodash'; import { useMemo } from 'react'; import { @@ -11,17 +12,34 @@ import { import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; import { MappingType, ThresholdsMode } from '@grafana/schema'; import { useTheme2 } from '@grafana/ui'; -import { normalizeAlertState } from 'app/features/alerting/state/alertDef'; import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; -import { extractCommonLabels, getLogRecordsByInstances, Line, LogRecord, omitLabels } from './common'; +import { extractCommonLabels, Line, LogRecord, omitLabels } from './common'; export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) { const theme = useTheme2(); return useMemo(() => { - const { logRecordsByInstance, logRecords } = getLogRecordsByInstances(stateHistory); + // merge timestamp with "line" + const tsValues = stateHistory?.data?.values[0] ?? []; + const timestamps: number[] = isNumbers(tsValues) ? tsValues : []; + const lines = stateHistory?.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 all records by alert instance (unique set of labels) + const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => { + return JSON.stringify(record.line.labels); + }); // CommonLabels should not be affected by the filter // find common labels so we can extract those from the instances @@ -135,132 +153,3 @@ export function logRecordsToDataFrame( return frame; } - -interface MetricValuePair { - metric: string; - value: string; -} - -function logRecordToData(record: LogRecord) { - let labelsInLogs: MetricValuePair[] = []; - let valuesInLogs: MetricValuePair = { metric: '', value: '' }; - if (record.line.labels) { - const { labels } = record.line; - const labelsArray = Object.entries(labels); - labelsInLogs = labelsArray.map(([key, value]) => ({ metric: key, value })); - } - - let values = record.line.values; - if (values) { - const valuesArray = Object.entries(values); - const valuesData = valuesArray.map(([key, value]) => ({ metric: key, value: value.toString() })); - //convert valuesInloGS to a one Data entry - valuesInLogs = valuesData.reduce( - (acc, cur) => { - acc.value = acc.value.length > 0 ? acc.value + ', ' : acc.value; - acc.value = cur.metric.length > 0 ? acc.value + cur.metric + '= ' + cur.value : acc.value; - return acc; - }, - { metric: ' Values', value: '' } - ); - if (valuesInLogs.value.length > 0) { - valuesInLogs.value = '{' + valuesInLogs.value + '}'; - return [...labelsInLogs, valuesInLogs]; - } else { - return labelsInLogs; - } - } - return [...labelsInLogs, valuesInLogs]; -} - -// Convert log records to data frame for panel -export function logRecordsToDataFrameForPanel( - instanceLabels: string, - records: LogRecord[], - theme: GrafanaTheme2 -): DataFrame { - const timeField: DataFrameField = { - name: 'time', - type: FieldType.time, - values: records.map((record) => record.timestamp), - config: { displayName: 'Time', custom: { fillOpacity: 100 } }, - }; - - const timeIndex = timeField.values.map((_, index) => index); - timeIndex.sort(fieldIndexComparer(timeField)); - - const frame: DataFrame = { - fields: [ - { - ...timeField, - values: timeField.values.map((_, i) => timeField.values[timeIndex[i]]), - }, - { - name: 'alertId', - type: FieldType.string, - values: records.map((_) => 1), - config: { - displayName: 'AlertId', - custom: { fillOpacity: 100 }, - }, - }, - { - name: 'newState', - type: FieldType.string, - values: records.map((record) => record.line.current), - config: { - displayName: 'newState', - custom: { fillOpacity: 100 }, - }, - }, - { - name: 'prevState', - type: FieldType.string, - values: records.map((record) => record.line.previous), - config: { - displayName: 'prevState', - custom: { fillOpacity: 100 }, - }, - }, - { - name: 'color', - type: FieldType.string, - values: records.map((record) => { - const normalizedState = normalizeAlertState(record.line.current); - switch (normalizedState) { - case 'firing': - case 'alerting': - case 'error': - return theme.colors.error.main; - case 'pending': - return theme.colors.warning.main; - case 'normal': - return theme.colors.success.main; - case 'nodata': - return theme.colors.info.main; - case 'paused': - return theme.colors.text.disabled; - default: - return theme.colors.info.main; - } - }), - config: {}, - }, - { - name: 'data', - type: FieldType.other, - values: records.map((record) => { - return logRecordToData(record); - }), - config: {}, - }, - ], - length: timeField.values.length, - name: instanceLabels, - }; - - frame.fields.forEach((field) => { - field.display = getDisplayProcessor({ field, theme }); - }); - return frame; -} diff --git a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx index bbafd19dd8b..2d08ec52875 100644 --- a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx +++ b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx @@ -2,15 +2,14 @@ import { css } from '@emotion/css'; import React, { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Modal, useStyles2 } from '@grafana/ui'; import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; -import { getHistoryImplementation } from '../components/rules/state-history/common'; - const AnnotationsStateHistory = lazy(() => import('../components/rules/state-history/StateHistory')); const LokiStateHistory = lazy(() => import('../components/rules/state-history/LokiStateHistory')); -export enum StateHistoryImplementation { +enum StateHistoryImplementation { Loki = 'loki', Annotations = 'annotations', } @@ -21,7 +20,18 @@ function useStateHistoryModal() { const styles = useStyles2(getStyles); - const implementation = getHistoryImplementation(); + // can be "loki", "multiple" or "annotations" + const stateHistoryBackend = config.unifiedAlerting.alertStateHistoryBackend; + // can be "loki" or "annotations" + const stateHistoryPrimary = config.unifiedAlerting.alertStateHistoryPrimary; + + // if "loki" is either the backend or the primary, show the new state history implementation + const usingNewAlertStateHistory = [stateHistoryBackend, stateHistoryPrimary].some( + (implementation) => implementation === StateHistoryImplementation.Loki + ); + const implementation = usingNewAlertStateHistory + ? StateHistoryImplementation.Loki + : StateHistoryImplementation.Annotations; const dismissModal = useCallback(() => { setRule(undefined); diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index a625f0a1662..7ffe08a09ba 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -3,7 +3,6 @@ import { Observable, of, ReplaySubject, Unsubscribable } from 'rxjs'; import { map, mergeMap, catchError } from 'rxjs/operators'; import { - ApplyFieldOverrideOptions, applyFieldOverrides, compareArrayValues, compareDataFrameStructures, @@ -20,18 +19,18 @@ import { getDefaultTimeRange, LoadingState, PanelData, - preProcessPanelData, rangeUtil, ScopedVars, - StreamingDataFrame, TimeRange, TimeZone, toDataFrame, transformDataFrame, + preProcessPanelData, + ApplyFieldOverrideOptions, + StreamingDataFrame, } from '@grafana/data'; import { getTemplateSrv, toDataQueryError } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; -import { updatePanelDataWithASHFromLoki } from 'app/features/alerting/unified/components/rules/state-history/common'; import { isStreamingDataFrame } from 'app/features/live/data/utils'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -331,11 +330,8 @@ export class PanelQueryRunner { } this.subscription = panelData.subscribe({ - next: async (data) => { - this.lastResult = skipPreProcess - ? data - : await updatePanelDataWithASHFromLoki(preProcessPanelData(data, this.lastResult)); - + next: (data) => { + this.lastResult = skipPreProcess ? data : preProcessPanelData(data, this.lastResult); // Store preprocessed query results for applying overrides later on in the pipeline this.subject.next(this.lastResult); },