mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Show annotations markers in TimeSeries panel when using Loki as … (#72084)
* WIP: Show annotations markers in TimeSeries panel when using Loki as alert state history * WIP changes * Fix converting log records to data frame for panel * Move fetching alert state history with Loki to the PannelQueryRunner to keep the panel flow * use dasboardUID and panelUID for requesting Loki ash * fix wrong prettier change * Only request loki ash when having alertstate * Use panelID as param in history query * Refactor: move getRuleHistoryRecordsForPanel and remove filtering code as is not used * Adress PR review comments * Add try catch for ash request * Add tests for updatePanelDataWithASHFromLoki method * Address PR review suggestions * review suggestion * Add test for logRecordsToDataFrameForPanel method * pr Review nit suggestion * Dont show toast messages from Loki request
This commit is contained in:
parent
00f0ff038e
commit
de6ef53c8a
@ -1,7 +1,7 @@
|
|||||||
import { isArray, reduce } from 'lodash';
|
import { isArray, reduce } from 'lodash';
|
||||||
|
|
||||||
import { IconName } from '@grafana/ui';
|
import { IconName } from '@grafana/ui';
|
||||||
import { QueryPartDef, QueryPart } from 'app/features/alerting/state/query_part';
|
import { QueryPart, QueryPartDef } from 'app/features/alerting/state/query_part';
|
||||||
|
|
||||||
const alertQueryDef = new QueryPartDef({
|
const alertQueryDef = new QueryPartDef({
|
||||||
type: 'query',
|
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
|
// 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.
|
// the reason it is set to "Alerting" is "NoData"; a lack of data points to evaluate.
|
||||||
function normalizeAlertState(state: string) {
|
export function normalizeAlertState(state: string) {
|
||||||
return state.toLowerCase().replace(/_/g, '').split(' ')[0];
|
return state.toLowerCase().replace(/_/g, '').split(' ')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ export const LogMessages = {
|
|||||||
cancelSavingAlertRule: 'user canceled alert rule creation',
|
cancelSavingAlertRule: 'user canceled alert rule creation',
|
||||||
successSavingAlertRule: 'alert rule saved successfully',
|
successSavingAlertRule: 'alert rule saved successfully',
|
||||||
unknownMessageFromError: 'unknown messageFromError',
|
unknownMessageFromError: 'unknown messageFromError',
|
||||||
|
errorGettingLokiHistory: 'error getting Loki history',
|
||||||
};
|
};
|
||||||
|
|
||||||
// logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent correctly
|
// logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent correctly
|
||||||
|
@ -1,4 +1,23 @@
|
|||||||
import { extractCommonLabels, Label, omitLabels } from './common';
|
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';
|
||||||
|
|
||||||
test('extractCommonLabels', () => {
|
test('extractCommonLabels', () => {
|
||||||
const labels: Label[][] = [
|
const labels: Label[][] = [
|
||||||
@ -48,3 +67,120 @@ test('omitLabels with no common labels', () => {
|
|||||||
|
|
||||||
expect(omitLabels(labels, commonLabels)).toStrictEqual(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<DataFrameJSON>({
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -1,7 +1,15 @@
|
|||||||
import { isEqual, uniqBy } from 'lodash';
|
import { cloneDeep, groupBy, isEqual, uniqBy } from 'lodash';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { DataFrame, DataFrameJSON, PanelData } from '@grafana/data';
|
||||||
|
import { config, getBackendSrv } from '@grafana/runtime';
|
||||||
import { GrafanaAlertStateWithReason } from 'app/types/unified-alerting-dto';
|
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 {
|
export interface Line {
|
||||||
previous: GrafanaAlertStateWithReason;
|
previous: GrafanaAlertStateWithReason;
|
||||||
current: GrafanaAlertStateWithReason;
|
current: GrafanaAlertStateWithReason;
|
||||||
@ -37,3 +45,108 @@ export function extractCommonLabels(labels: Label[][]): Label[] {
|
|||||||
|
|
||||||
return commonLabels;
|
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<DataFrame>(([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<DataFrameJSON>({
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { createTheme, FieldType } from '@grafana/data';
|
import { createTheme, FieldType } from '@grafana/data';
|
||||||
|
|
||||||
import { LogRecord } from './common';
|
import { LogRecord } from './common';
|
||||||
import { logRecordsToDataFrame } from './useRuleHistoryRecords';
|
import { logRecordsToDataFrame, logRecordsToDataFrameForPanel } from './useRuleHistoryRecords';
|
||||||
|
|
||||||
|
const theme = createTheme();
|
||||||
|
|
||||||
describe('logRecordsToDataFrame', () => {
|
describe('logRecordsToDataFrame', () => {
|
||||||
const theme = createTheme();
|
|
||||||
|
|
||||||
it('should convert instance history records into a data frame', () => {
|
it('should convert instance history records into a data frame', () => {
|
||||||
const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' };
|
const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' };
|
||||||
const records: LogRecord[] = [
|
const records: LogRecord[] = [
|
||||||
@ -102,3 +102,67 @@ describe('logRecordsToDataFrame', () => {
|
|||||||
expect(frame.fields[1].config.displayName).toBe('severity=critical');
|
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: '' },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { groupBy } from 'lodash';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -12,34 +11,17 @@ import {
|
|||||||
import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers';
|
import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers';
|
||||||
import { MappingType, ThresholdsMode } from '@grafana/schema';
|
import { MappingType, ThresholdsMode } from '@grafana/schema';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
|
import { normalizeAlertState } from 'app/features/alerting/state/alertDef';
|
||||||
|
|
||||||
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
|
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
|
||||||
|
|
||||||
import { extractCommonLabels, Line, LogRecord, omitLabels } from './common';
|
import { extractCommonLabels, getLogRecordsByInstances, Line, LogRecord, omitLabels } from './common';
|
||||||
|
|
||||||
export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) {
|
export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
// merge timestamp with "line"
|
const { logRecordsByInstance, logRecords } = getLogRecordsByInstances(stateHistory);
|
||||||
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
|
// CommonLabels should not be affected by the filter
|
||||||
// find common labels so we can extract those from the instances
|
// find common labels so we can extract those from the instances
|
||||||
@ -153,3 +135,132 @@ export function logRecordsToDataFrame(
|
|||||||
|
|
||||||
return frame;
|
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<MetricValuePair>(
|
||||||
|
(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;
|
||||||
|
}
|
||||||
|
@ -2,14 +2,15 @@ import { css } from '@emotion/css';
|
|||||||
import React, { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
import React, { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Modal, useStyles2 } from '@grafana/ui';
|
import { Modal, useStyles2 } from '@grafana/ui';
|
||||||
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
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 AnnotationsStateHistory = lazy(() => import('../components/rules/state-history/StateHistory'));
|
||||||
const LokiStateHistory = lazy(() => import('../components/rules/state-history/LokiStateHistory'));
|
const LokiStateHistory = lazy(() => import('../components/rules/state-history/LokiStateHistory'));
|
||||||
|
|
||||||
enum StateHistoryImplementation {
|
export enum StateHistoryImplementation {
|
||||||
Loki = 'loki',
|
Loki = 'loki',
|
||||||
Annotations = 'annotations',
|
Annotations = 'annotations',
|
||||||
}
|
}
|
||||||
@ -20,18 +21,7 @@ function useStateHistoryModal() {
|
|||||||
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
// can be "loki", "multiple" or "annotations"
|
const implementation = getHistoryImplementation();
|
||||||
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(() => {
|
const dismissModal = useCallback(() => {
|
||||||
setRule(undefined);
|
setRule(undefined);
|
||||||
|
@ -3,6 +3,7 @@ import { Observable, of, ReplaySubject, Unsubscribable } from 'rxjs';
|
|||||||
import { map, mergeMap } from 'rxjs/operators';
|
import { map, mergeMap } from 'rxjs/operators';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ApplyFieldOverrideOptions,
|
||||||
applyFieldOverrides,
|
applyFieldOverrides,
|
||||||
compareArrayValues,
|
compareArrayValues,
|
||||||
compareDataFrameStructures,
|
compareDataFrameStructures,
|
||||||
@ -19,18 +20,18 @@ import {
|
|||||||
getDefaultTimeRange,
|
getDefaultTimeRange,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PanelData,
|
PanelData,
|
||||||
|
preProcessPanelData,
|
||||||
rangeUtil,
|
rangeUtil,
|
||||||
ScopedVars,
|
ScopedVars,
|
||||||
|
StreamingDataFrame,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
TimeZone,
|
TimeZone,
|
||||||
toDataFrame,
|
toDataFrame,
|
||||||
transformDataFrame,
|
transformDataFrame,
|
||||||
preProcessPanelData,
|
|
||||||
ApplyFieldOverrideOptions,
|
|
||||||
StreamingDataFrame,
|
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getTemplateSrv, toDataQueryError } from '@grafana/runtime';
|
import { getTemplateSrv, toDataQueryError } from '@grafana/runtime';
|
||||||
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
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 { isStreamingDataFrame } from 'app/features/live/data/utils';
|
||||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
|
||||||
@ -324,8 +325,11 @@ export class PanelQueryRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.subscription = panelData.subscribe({
|
this.subscription = panelData.subscribe({
|
||||||
next: (data) => {
|
next: async (data) => {
|
||||||
this.lastResult = skipPreProcess ? data : preProcessPanelData(data, this.lastResult);
|
this.lastResult = skipPreProcess
|
||||||
|
? data
|
||||||
|
: await updatePanelDataWithASHFromLoki(preProcessPanelData(data, this.lastResult));
|
||||||
|
|
||||||
// Store preprocessed query results for applying overrides later on in the pipeline
|
// Store preprocessed query results for applying overrides later on in the pipeline
|
||||||
this.subject.next(this.lastResult);
|
this.subject.next(this.lastResult);
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user