diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index c73fe2f00cf..2ab9070db16 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -74,6 +74,8 @@ export interface GrafanaJavascriptAgentConfig { export interface UnifiedAlertingConfig { minInterval: string; + // will be undefined if alerStateHistory is not enabled + alertStateHistoryBackend?: string; } /** Supported OAuth services diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 1c184974d8a..9bcaaddc3d0 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -133,7 +133,7 @@ export class GrafanaBootConfig implements GrafanaConfig { geomapDefaultBaseLayerConfig?: MapLayerOptions; geomapDisableCustomBaseLayer?: boolean; unifiedAlertingEnabled = false; - unifiedAlerting = { minInterval: '' }; + unifiedAlerting = { minInterval: '', alertStateHistoryBackend: undefined }; applicationInsightsConnectionString?: string; applicationInsightsEndpointUrl?: string; recordedQueries = { diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 73ecd137331..4eeaeae98f2 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -61,7 +61,8 @@ type FrontendSettingsReportingDTO struct { } type FrontendSettingsUnifiedAlertingDTO struct { - MinInterval string `json:"minInterval"` + MinInterval string `json:"minInterval"` + AlertStateHistoryBackend string `json:"alertStateHistoryBackend,omitempty"` } // Enterprise-only diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 91f9830de86..98c3da08cee 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -218,6 +218,10 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro SnapshotEnabled: hs.Cfg.SnapshotEnabled, } + if hs.Cfg.UnifiedAlerting.StateHistory.Enabled { + frontendSettings.UnifiedAlerting.AlertStateHistoryBackend = hs.Cfg.UnifiedAlerting.StateHistory.Backend + } + if hs.Cfg.UnifiedAlerting.Enabled != nil { frontendSettings.UnifiedAlertingEnabled = *hs.Cfg.UnifiedAlerting.Enabled } diff --git a/public/app/features/alerting/unified/AlertsFolderView.tsx b/public/app/features/alerting/unified/AlertsFolderView.tsx index 32b7b55e684..060ed9b3708 100644 --- a/public/app/features/alerting/unified/AlertsFolderView.tsx +++ b/public/app/features/alerting/unified/AlertsFolderView.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { isEqual, orderBy, uniqWith } from 'lodash'; +import { orderBy } from 'lodash'; import React, { useEffect, useState } from 'react'; import { useDebounce } from 'react-use'; @@ -15,7 +15,7 @@ import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; import { usePagination } from './hooks/usePagination'; import { useURLSearchParams } from './hooks/useURLSearchParams'; import { fetchPromRulesAction, fetchRulerRulesAction } from './state/actions'; -import { labelsMatchMatchers, matchersToString, parseMatcher, parseMatchers } from './utils/alertmanager'; +import { combineMatcherStrings, labelsMatchMatchers, parseMatchers } from './utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { createViewLink } from './utils/misc'; @@ -38,10 +38,7 @@ export const AlertsFolderView = ({ folder }: Props) => { const dispatch = useDispatch(); const onTagClick = (tagName: string) => { - const matchers = parseMatchers(labelFilter); - const tagMatcherField = parseMatcher(tagName); - const uniqueMatchers = uniqWith([...matchers, tagMatcherField], isEqual); - const matchersString = matchersToString(uniqueMatchers); + const matchersString = combineMatcherStrings(labelFilter, tagName); setLabelFilter(matchersString); }; diff --git a/public/app/features/alerting/unified/api/stateHistoryApi.ts b/public/app/features/alerting/unified/api/stateHistoryApi.ts new file mode 100644 index 00000000000..1374595da4b --- /dev/null +++ b/public/app/features/alerting/unified/api/stateHistoryApi.ts @@ -0,0 +1,16 @@ +import { getUnixTime } from 'date-fns'; + +import { DataFrameJSON } from '@grafana/data'; + +import { alertingApi } from './alertingApi'; + +export const stateHistoryApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + getRuleHistory: build.query({ + query: ({ ruleUid, from, to = getUnixTime(new Date()) }) => ({ + url: '/api/v1/rules/history', + params: { ruleUID: ruleUid, from, to }, + }), + }), + }), +}); diff --git a/public/app/features/alerting/unified/components/StateTag.tsx b/public/app/features/alerting/unified/components/StateTag.tsx index d60a2af4f9a..e3e03d6ad63 100644 --- a/public/app/features/alerting/unified/components/StateTag.tsx +++ b/public/app/features/alerting/unified/components/StateTag.tsx @@ -9,12 +9,17 @@ export type State = 'good' | 'bad' | 'warning' | 'neutral' | 'info'; type Props = { state: State; size?: 'md' | 'sm'; + muted?: boolean; }; -export const StateTag = ({ children, state, size = 'md' }: React.PropsWithChildren) => { +export const StateTag = ({ children, state, size = 'md', muted = false }: React.PropsWithChildren) => { const styles = useStyles2(getStyles); - return {children || state}; + return ( + + {children || state} + + ); }; const getStyles = (theme: GrafanaTheme2) => ({ @@ -61,4 +66,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(0.3, 0.5)}; min-width: 52px; `, + muted: css` + opacity: 0.5; + `, }); diff --git a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx index 0a2ac6281d7..e33609d38ab 100644 --- a/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx +++ b/public/app/features/alerting/unified/components/rules/AlertStateTag.tsx @@ -9,10 +9,12 @@ interface Props { state: PromAlertingRuleState | GrafanaAlertState | GrafanaAlertStateWithReason | AlertState; size?: 'md' | 'sm'; isPaused?: boolean; + muted?: boolean; } -export const AlertStateTag = ({ state, isPaused = false, size = 'md' }: Props) => ( - +export const AlertStateTag = React.memo(({ state, isPaused = false, size = 'md', muted = false }: Props) => ( + {alertStateToReadable(state)} {isPaused ? ' (Paused)' : ''} -); +)); +AlertStateTag.displayName = 'AlertStateTag'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 025850f592f..adfdbe9504e 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -37,8 +37,7 @@ interface Props { export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Props) => { const style = useStyles2(getStyles); const { namespace, group, rulerRule } = rule; - const alertId = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.id ?? '' : ''; - const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(alertId); + const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(); const dispatch = useDispatch(); const location = useLocation(); const notifyApp = useAppNotification(); @@ -159,10 +158,14 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop ); } - if (alertId) { + if (isGrafanaRulerRule(rule.rulerRule)) { buttons.push( - {StateHistoryModal} diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx new file mode 100644 index 00000000000..48bd6548508 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx @@ -0,0 +1,35 @@ +import { getByTestId, render } from '@testing-library/react'; +import React from 'react'; +import { byRole } from 'testing-library-selector'; + +import { LogRecordViewerByTimestamp } from './LogRecordViewer'; +import { LogRecord } from './common'; + +const ui = { + log: byRole('list', { name: 'State history by timestamp' }), +}; + +describe('LogRecordViewerByTimestamp', () => { + it('should group the same timestamps into one group', () => { + const records: LogRecord[] = [ + { timestamp: 1681739580000, line: { current: 'Alerting', previous: 'Pending', labels: { foo: 'bar' } } }, + { timestamp: 1681739580000, line: { current: 'Alerting', previous: 'Pending', labels: { severity: 'warning' } } }, + { timestamp: 1681739600000, line: { current: 'Normal', previous: 'Alerting', labels: { foo: 'bar' } } }, + { timestamp: 1681739600000, line: { current: 'Normal', previous: 'Alerting', labels: { severity: 'warning' } } }, + ]; + + render(); + + const logElement = ui.log.get(); + expect(logElement).toBeInTheDocument(); + + const entry1 = getByTestId(logElement, 1681739580000); + const entry2 = getByTestId(logElement, 1681739600000); + + expect(entry1).toHaveTextContent('foo=bar'); + expect(entry1).toHaveTextContent('severity=warning'); + + expect(entry2).toHaveTextContent('foo=bar'); + expect(entry2).toHaveTextContent('severity=warning'); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx new file mode 100644 index 00000000000..45fe627aa0c --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx @@ -0,0 +1,174 @@ +import { css } from '@emotion/css'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { groupBy, uniqueId } from 'lodash'; +import React, { useEffect } from 'react'; + +import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; +import { Icon, TagList, useStyles2 } from '@grafana/ui'; + +import { Label } from '../../Label'; +import { AlertStateTag } from '../AlertStateTag'; + +import { LogRecord, omitLabels } from './common'; + +interface LogRecordViewerProps { + records: LogRecord[]; + commonLabels: Array<[string, string]>; + onRecordsRendered?: (timestampRefs: Map) => void; + onLabelClick?: (label: string) => void; +} + +export const LogRecordViewerByTimestamp = React.memo( + ({ records, commonLabels, onLabelClick, onRecordsRendered }: LogRecordViewerProps) => { + const styles = useStyles2(getStyles); + + // groupBy has been replaced by the reduce to avoid back and forth conversion of timestamp from number to string + const groupedLines = records.reduce((acc, current) => { + const tsGroup = acc.get(current.timestamp); + if (tsGroup) { + tsGroup.push(current); + } else { + acc.set(current.timestamp, [current]); + } + + return acc; + }, new Map()); + + const timestampRefs = new Map(); + useEffect(() => { + onRecordsRendered && onRecordsRendered(timestampRefs); + }); + + return ( +
    + {Array.from(groupedLines.entries()).map(([key, records]) => { + return ( +
  • element && timestampRefs.set(key, element)} + > + +
    + {records.map(({ line }) => ( + + + + + {line.values && } +
    + {line.labels && ( + `${key}=${value}` + )} + onClick={onLabelClick} + /> + )} +
    +
    + ))} +
    +
  • + ); + })} +
+ ); + } +); +LogRecordViewerByTimestamp.displayName = 'LogRecordViewerByTimestamp'; + +export function LogRecordViewerByInstance({ records, commonLabels }: LogRecordViewerProps) { + const styles = useStyles2(getStyles); + + const groupedLines = groupBy(records, (record: LogRecord) => { + return JSON.stringify(record.line.labels); + }); + + return ( + <> + {Object.entries(groupedLines).map(([key, records]) => { + return ( + +

+ `${key}=${value}` + )} + /> +

+
+ {records.map(({ line, timestamp }) => ( +
+ + + + {line.values && } +
{dateTimeFormat(timestamp)}
+
+ ))} +
+
+ ); + })} + + ); +} + +interface TimestampProps { + time: number; // epoch timestamp +} + +const Timestamp = ({ time }: TimestampProps) => { + const dateTime = new Date(time); + const styles = useStyles2(getStyles); + + return ( +
+ + + {dateTimeFormat(dateTime)} + ({formatDistanceToNowStrict(dateTime)} ago) + +
+ ); +}; + +const AlertInstanceValues = React.memo(({ record }: { record: Record }) => { + const values = Object.entries(record); + + return ( + <> + {values.map(([key, value]) => ( + + } + > + } + suffix={ + showClearFilterSuffix && ( + + ) + } + placeholder="Filter instances" + ref={ref} + {...rest} + /> + + ); + } +); +SearchFieldInput.displayName = 'SearchFieldInput'; + +function getDefaultTimeRange(): TimeRange { + const fromDateTime = dateTime().subtract(1, 'h'); + const toDateTime = dateTime(); + return { + from: fromDateTime, + to: toDateTime, + raw: { from: fromDateTime, to: toDateTime }, + }; +} + +export const getStyles = (theme: GrafanaTheme2) => ({ + fullSize: css` + min-width: 100%; + height: 100%; + + display: flex; + flex-direction: column; + `, + graphWrapper: css` + padding: ${theme.spacing()} 0; + `, + emptyState: css` + color: ${theme.colors.text.secondary}; + + display: flex; + flex-direction: column; + gap: ${theme.spacing(2)}; + align-items: center; + margin: auto auto; + `, + moreInstancesWarning: css` + color: ${theme.colors.warning.text}; + padding: ${theme.spacing()}; + `, + commonLabels: css` + display: grid; + grid-template-columns: max-content auto; + `, +}); + +export default LokiStateHistory; diff --git a/public/app/features/alerting/unified/components/rules/StateHistory.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/StateHistory.test.tsx similarity index 100% rename from public/app/features/alerting/unified/components/rules/StateHistory.test.tsx rename to public/app/features/alerting/unified/components/rules/state-history/StateHistory.test.tsx diff --git a/public/app/features/alerting/unified/components/rules/StateHistory.tsx b/public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx similarity index 96% rename from public/app/features/alerting/unified/components/rules/StateHistory.tsx rename to public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx index e484cb412e3..cf9e78e7a56 100644 --- a/public/app/features/alerting/unified/components/rules/StateHistory.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx @@ -8,11 +8,10 @@ import { Alert, Field, Icon, Input, Label, LoadingPlaceholder, Tooltip, useStyle import { StateHistoryItem, StateHistoryItemData } from 'app/types/unified-alerting'; import { GrafanaAlertStateWithReason, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; -import { useManagedAlertStateHistory } from '../../hooks/useManagedAlertStateHistory'; -import { AlertLabel } from '../AlertLabel'; -import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; - -import { AlertStateTag } from './AlertStateTag'; +import { useManagedAlertStateHistory } from '../../../hooks/useManagedAlertStateHistory'; +import { AlertLabel } from '../../AlertLabel'; +import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../../DynamicTable'; +import { AlertStateTag } from '../AlertStateTag'; type StateHistoryRowItem = { id: string; @@ -202,4 +201,4 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, }); -export { StateHistory }; +export default StateHistory; diff --git a/public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap b/public/app/features/alerting/unified/components/rules/state-history/__snapshots__/StateHistory.test.tsx.snap similarity index 100% rename from public/app/features/alerting/unified/components/rules/__snapshots__/StateHistory.test.tsx.snap rename to public/app/features/alerting/unified/components/rules/state-history/__snapshots__/StateHistory.test.tsx.snap 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 new file mode 100644 index 00000000000..a061efccb70 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/common.test.ts @@ -0,0 +1,50 @@ +import { extractCommonLabels, Label, omitLabels } from './common'; + +test('extractCommonLabels', () => { + const labels: Label[][] = [ + [ + ['foo', 'bar'], + ['baz', 'qux'], + ], + [ + ['foo', 'bar'], + ['baz', 'qux'], + ['potato', 'tomato'], + ], + ]; + + expect(extractCommonLabels(labels)).toStrictEqual([ + ['foo', 'bar'], + ['baz', 'qux'], + ]); +}); + +test('extractCommonLabels with no common labels', () => { + const labels: Label[][] = [[['foo', 'bar']], [['potato', 'tomato']]]; + + expect(extractCommonLabels(labels)).toStrictEqual([]); +}); + +test('omitLabels', () => { + const labels: Label[] = [ + ['foo', 'bar'], + ['baz', 'qux'], + ['potato', 'tomato'], + ]; + const commonLabels: Label[] = [ + ['foo', 'bar'], + ['baz', 'qux'], + ]; + + expect(omitLabels(labels, commonLabels)).toStrictEqual([['potato', 'tomato']]); +}); + +test('omitLabels with no common labels', () => { + const labels: Label[] = [['potato', 'tomato']]; + const commonLabels: Label[] = [ + ['foo', 'bar'], + ['baz', 'qux'], + ]; + + expect(omitLabels(labels, commonLabels)).toStrictEqual(labels); +}); 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 new file mode 100644 index 00000000000..f797fa1f8a5 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/common.ts @@ -0,0 +1,39 @@ +import { isEqual, uniqBy } from 'lodash'; + +import { GrafanaAlertStateWithReason } from 'app/types/unified-alerting-dto'; + +export interface Line { + previous: GrafanaAlertStateWithReason; + current: GrafanaAlertStateWithReason; + values?: Record; + labels?: Record; +} + +export interface LogRecord { + timestamp: number; + line: Line; +} + +export type Label = [string, string]; + +// omit "common" labels from "labels" +export function omitLabels(labels: Label[], common: Label[]): Label[] { + return labels.filter((label) => { + return !common.find((commonLabel) => JSON.stringify(commonLabel) === JSON.stringify(label)); + }); +} + +// find all common labels by looking at which ones occur in every record, then create a unique array of items for those +export function extractCommonLabels(labels: Label[][]): Label[] { + const flatLabels = labels.flatMap((label) => label); + + const commonLabels = uniqBy( + flatLabels.filter((label) => { + const count = flatLabels.filter((l) => isEqual(label, l)).length; + return count === Object.keys(labels).length; + }), + (label) => JSON.stringify(label) + ); + + return commonLabels; +} 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 new file mode 100644 index 00000000000..fdcdc7e9d31 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.test.tsx @@ -0,0 +1,104 @@ +import { createTheme, FieldType } from '@grafana/data'; + +import { LogRecord } from './common'; +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[] = [ + { + timestamp: 1000000, + line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels }, + }, + ]; + + const frame = logRecordsToDataFrame(JSON.stringify(instanceLabels), records, [], theme); + + expect(frame.fields).toHaveLength(2); + + const timeField = frame.fields[0]; + const stateChangeField = frame.fields[1]; + + expect(timeField.name).toBe('time'); + expect(timeField.type).toBe(FieldType.time); + + expect(stateChangeField.name).toBe('state'); + expect(stateChangeField.type).toBe(FieldType.string); + // There should be an artificial element at the end meaning Date.now() + // It exist to draw the state change from when it happened to the current time + expect(timeField.values).toHaveLength(2); + expect(timeField.values[0]).toBe(1000000); + + expect(stateChangeField.values).toHaveLength(2); + expect(stateChangeField.values).toEqual(['Alerting', 'Alerting']); + }); + + it('should configure value to color mappings', () => { + const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; + const records: LogRecord[] = [ + { + timestamp: 1000000, + line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels }, + }, + ]; + + const frame = logRecordsToDataFrame(JSON.stringify(instanceLabels), records, [], theme); + + const stateField = frame.fields[1]; + expect(stateField.config.mappings).toHaveLength(1); + expect(stateField.config.mappings![0].options).toMatchObject({ + Alerting: { + color: theme.colors.error.main, + }, + Pending: { + color: theme.colors.warning.main, + }, + Normal: { + color: theme.colors.success.main, + }, + NoData: { + color: theme.colors.info.main, + }, + }); + }); + + it('should return correct data frame summary', () => { + const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; + const records: LogRecord[] = [ + { + timestamp: 1000000, + line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels }, + }, + ]; + + const frame = logRecordsToDataFrame(JSON.stringify(instanceLabels), records, [], theme); + + expect(frame.fields).toHaveLength(2); + expect(frame).toHaveLength(2); + }); + + it('should have only unique labels in display name', () => { + const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; + const records: LogRecord[] = [ + { + timestamp: 1000000, + line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels }, + }, + ]; + + const frame = logRecordsToDataFrame( + JSON.stringify(instanceLabels), + records, + [ + ['foo', 'bar'], + ['cluster', 'dev-us'], + ], + theme + ); + + expect(frame.fields[1].config.displayName).toBe('severity=critical'); + }); +}); 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 new file mode 100644 index 00000000000..67efa185b3a --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx @@ -0,0 +1,155 @@ +import { groupBy } from 'lodash'; +import { useMemo } from 'react'; + +import { + DataFrame, + DataFrameJSON, + Field as DataFrameField, + FieldType, + getDisplayProcessor, + GrafanaTheme2, +} from '@grafana/data'; +import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; +import { MappingType, ThresholdsMode } from '@grafana/schema'; +import { useTheme2 } from '@grafana/ui'; + +import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; + +import { extractCommonLabels, Line, LogRecord, omitLabels } from './common'; + +export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) { + const theme = useTheme2(); + + return useMemo(() => { + // 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 + const groupLabels = Object.keys(logRecordsByInstance); + const groupLabelsArray: Array> = groupLabels.map((label) => { + return Object.entries(JSON.parse(label)); + }); + + const commonLabels = extractCommonLabels(groupLabelsArray); + + const filterMatchers = filter ? parseMatchers(filter) : []; + const filteredGroupedLines = Object.entries(logRecordsByInstance).filter(([key]) => { + const labels = JSON.parse(key); + return labelsMatchMatchers(labels, filterMatchers); + }); + + const dataFrames: DataFrame[] = filteredGroupedLines.map(([key, records]) => { + return logRecordsToDataFrame(key, records, commonLabels, theme); + }); + + return { + historyRecords: logRecords.filter(({ line }) => line.labels && labelsMatchMatchers(line.labels, filterMatchers)), + dataFrames, + commonLabels, + totalRecordsCount: logRecords.length, + }; + }, [stateHistory, filter, theme]); +} + +export function isNumbers(value: unknown[]): value is number[] { + return value.every((v) => typeof v === 'number'); +} + +export function isLine(value: unknown): value is Line { + return typeof value === 'object' && value !== null && 'current' in value && 'previous' in value; +} + +// Each alert instance is represented by a data frame +// Each frame consists of two fields: timestamp and state change +export function logRecordsToDataFrame( + instanceLabels: string, + records: LogRecord[], + commonLabels: Array<[string, string]>, + theme: GrafanaTheme2 +): DataFrame { + const parsedInstanceLabels = Object.entries(JSON.parse(instanceLabels)); + + // There is an artificial element at the end meaning Date.now() + // It exist to draw the state change from when it happened to the current time + const timeField: DataFrameField = { + name: 'time', + type: FieldType.time, + values: [...records.map((record) => record.timestamp), Date.now()], + config: { displayName: 'Time', custom: { fillOpacity: 100 } }, + }; + + const timeIndex = timeField.values.map((_, index) => index); + timeIndex.sort(fieldIndexComparer(timeField)); + + const stateValues = [...records.map((record) => record.line.current), records.at(-1)?.line.current]; + + const frame: DataFrame = { + fields: [ + { + ...timeField, + values: timeField.values.map((_, i) => timeField.values[timeIndex[i]]), + }, + { + name: 'state', + type: FieldType.string, + values: stateValues.map((_, i) => stateValues[timeIndex[i]]), + config: { + displayName: omitLabels(parsedInstanceLabels, commonLabels) + .map(([key, label]) => `${key}=${label}`) + .join(', '), + color: { mode: 'thresholds' }, + custom: { fillOpacity: 100 }, + mappings: [ + { + type: MappingType.ValueToText, + options: { + Alerting: { + color: theme.colors.error.main, + }, + Pending: { + color: theme.colors.warning.main, + }, + Normal: { + color: theme.colors.success.main, + }, + NoData: { + color: theme.colors.info.main, + }, + }, + }, + ], + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [], + }, + }, + }, + ], + 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 1e0a5a425f9..c3c66d175a2 100644 --- a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx +++ b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx @@ -1,32 +1,83 @@ -import React, { useMemo, useState } from 'react'; +import { css } from '@emotion/css'; +import React, { lazy, Suspense, useCallback, useMemo, useState } from 'react'; -import { Modal } from '@grafana/ui'; +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 { StateHistory } from '../components/rules/StateHistory'; +const AnnotationsStateHistory = lazy(() => import('../components/rules/state-history/StateHistory')); +const LokiStateHistory = lazy(() => import('../components/rules/state-history/LokiStateHistory')); -function useStateHistoryModal(alertId: string) { +enum StateHistoryImplementation { + Loki = 'loki', + Annotations = 'annotations', +} + +function useStateHistoryModal() { const [showModal, setShowModal] = useState(false); + const [rule, setRule] = useState(); - const StateHistoryModal = useMemo( - () => ( + const styles = useStyles2(getStyles); + + const implementation = + config.unifiedAlerting.alertStateHistoryBackend === StateHistoryImplementation.Loki + ? StateHistoryImplementation.Loki + : StateHistoryImplementation.Annotations; + + const dismissModal = useCallback(() => { + setRule(undefined); + setShowModal(false); + }, []); + + const openModal = useCallback((rule: RulerGrafanaRuleDTO) => { + setRule(rule); + setShowModal(true); + }, []); + + const StateHistoryModal = useMemo(() => { + if (!rule) { + return null; + } + + return ( setShowModal(false)} + onDismiss={dismissModal} closeOnBackdropClick={true} closeOnEscape={true} title="State history" + className={styles.modal} + contentClassName={styles.modalContent} > - + + {implementation === StateHistoryImplementation.Loki && } + {implementation === StateHistoryImplementation.Annotations && ( + + )} + - ), - [alertId, showModal] - ); + ); + }, [rule, showModal, dismissModal, implementation, styles]); return { StateHistoryModal, - showStateHistoryModal: () => setShowModal(true), - hideStateHistoryModal: () => setShowModal(false), + showStateHistoryModal: openModal, + hideStateHistoryModal: dismissModal, }; } +const getStyles = (theme: GrafanaTheme2) => ({ + modal: css` + width: 80%; + height: 80%; + min-width: 800px; + `, + modalContent: css` + height: 100%; + width: 100%; + padding: ${theme.spacing(2)}; + `, +}); + export { useStateHistoryModal }; diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 836efd206e9..578549da500 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -1,3 +1,5 @@ +import { isEqual, uniqWith } from 'lodash'; + import { SelectableValue } from '@grafana/data'; import { AlertManagerCortexConfig, @@ -173,7 +175,7 @@ export function parseMatchers(matcherQueryString: string): Matcher[] { const isRegex = operator === MatcherOperator.regex || operator === MatcherOperator.notRegex; matchers.push({ name: key, - value: value.trim(), + value: isRegex ? getValidRegexString(value.trim()) : value.trim(), isEqual, isRegex, }); @@ -183,6 +185,16 @@ export function parseMatchers(matcherQueryString: string): Matcher[] { return matchers; } +function getValidRegexString(regex: string): string { + // Regexes provided by users might be invalid, so we need to catch the error + try { + new RegExp(regex); + return regex; + } catch (error) { + return ''; + } +} + export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean { return matchers.every(({ name, value, isRegex, isEqual }) => { return Object.entries(labels).some(([labelKey, labelValue]) => { @@ -206,6 +218,12 @@ export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolea }); } +export function combineMatcherStrings(...matcherStrings: string[]): string { + const matchers = matcherStrings.map(parseMatchers).flat(); + const uniqueMatchers = uniqWith(matchers, isEqual); + return matchersToString(uniqueMatchers); +} + export function getAllAlertmanagerDataSources() { return getAllDataSources().filter((ds) => ds.type === DataSourceType.Alertmanager); }