From 8a827b5b05258edb58cfb535c3ad5e95c8c7d79f Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 14 Oct 2025 14:58:16 +0200 Subject: [PATCH] Alerting: Triage rule details drawer (#112055) * Add basic drawers for rules and instances * Add query visualization for instances * Display threshold on graphs when available * Add basic history state transitions * Query and annotations chart for alert instance * Use SceneDataNode to merge query and annotations * Split drawer components into more files * move the drawer to the workbench so we can persist its state with pagination updates the drawer contents to align closer to what we have for the detail view * Don't collapse summary on empty values * Improve data loading in InstanceDetailsDrawer * Refactor history data conversion * Tidy up state history data conversion * Replace rule name link with a dedicated drawer button * remove filter text * Improve history time series handling * Improve rule details header * Update translations * Use custom filter function for instance series filtering * Fix instances matching algorithm * make very long rule names span multiple rows --------- Co-authored-by: Gilles De Mey --- .../central-state-history/EventDetails.tsx | 18 +- .../useRuleHistoryRecords.ts | 27 +-- .../rules/central-state-history/utils.ts | 19 +- .../rules/state-history/common.test.ts | 146 +++++++++++- .../components/rules/state-history/common.ts | 35 +++ .../state-history/useRuleHistoryRecords.tsx | 25 +- .../alerting/unified/triage/Workbench.tsx | 1 + .../InstanceDetailsDrawer.tsx | 213 ++++++++++++++++++ .../instance-details/QueryVisualization.tsx | 113 ++++++++++ .../instance-details/stateHistoryUtils.ts | 50 ++++ .../unified/triage/rows/AlertRuleRow.tsx | 79 ++++--- .../unified/triage/rows/InstanceRow.tsx | 89 ++++++-- .../triage/rule-details/RuleDetailsDrawer.tsx | 149 ++++++++++++ .../triage/scene/AlertRuleInstances.tsx | 1 + .../unified/triage/scene/SummaryStats.tsx | 4 +- public/locales/en-US/grafana.json | 30 ++- 16 files changed, 861 insertions(+), 138 deletions(-) create mode 100644 public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx create mode 100644 public/app/features/alerting/unified/triage/instance-details/QueryVisualization.tsx create mode 100644 public/app/features/alerting/unified/triage/instance-details/stateHistoryUtils.ts create mode 100644 public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx index 2711e59a5a7..5a998199eba 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx @@ -19,8 +19,7 @@ import { AnnotationValue } from '../../rule-viewer/tabs/Details'; import { ErrorMessageRow } from '../state-history/ErrorMessageRow'; import { LogTimelineViewer } from '../state-history/LogTimelineViewer'; import { useFrameSubset } from '../state-history/LokiStateHistory'; -import { LogRecord } from '../state-history/common'; -import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; +import { LogRecord, historyDataFrameToLogRecords } from '../state-history/common'; import { EventState, FilterType, LIMIT_EVENTS } from './EventListSceneObject'; import { HistoryErrorMessage } from './HistoryErrorMessage'; @@ -87,20 +86,7 @@ function useRuleHistoryRecordsForTheInstance(labelsForTheInstance: string, state 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; - }, []); + const logRecords = historyDataFrameToLogRecords(stateHistory); // group all records by alert instance (unique set of labels) const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => { diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts index b56b2a81f24..afa3989cd8a 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts @@ -5,8 +5,7 @@ import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; import { labelsMatchMatchers } from '../../../utils/alertmanager'; import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; -import { LogRecord } from '../state-history/common'; -import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; +import { historyDataFrameToLogRecords } from '../state-history/common'; import { StateFilterValues } from './constants'; @@ -39,35 +38,25 @@ export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filters: His export function ruleHistoryToRecords(stateHistory?: DataFrameJSON, filters: HistoryRecordFilters = emptyFilters) { const { labels, stateFrom = StateFilterValues.all, stateTo = StateFilterValues.all } = filters; - if (!stateHistory?.data) { + const allLogRecords = historyDataFrameToLogRecords(stateHistory); + + if (allLogRecords.length === 0) { return { historyRecords: [] }; } const filterMatchers = labels ? parsePromQLStyleMatcherLooseSafe(labels) : []; - 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; - } + const filteredRecords = allLogRecords.filter(({ line }) => { // values property can be undefined for some instance states (e.g. NoData) const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers); const baseStateTo = mapStateWithReasonToBaseState(line.current); const baseStateFrom = mapStateWithReasonToBaseState(line.previous); const stateToMatch = stateTo !== StateFilterValues.all ? stateTo === baseStateTo : true; const stateFromMatch = stateFrom !== StateFilterValues.all ? stateFrom === baseStateFrom : true; - if (filterMatch && stateToMatch && stateFromMatch) { - acc.push({ timestamp, line }); - } - - return acc; - }, []); + return filterMatch && stateToMatch && stateFromMatch; + }); return { - historyRecords: logRecords, + historyRecords: filteredRecords, }; } 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 index 968253301a5..e21f7c98e54 100644 --- 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 @@ -15,8 +15,7 @@ import { fieldIndexComparer } from '@grafana/data/internal'; import { labelsMatchMatchers } from '../../../utils/alertmanager'; import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; -import { LogRecord } from '../state-history/common'; -import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; +import { LogRecord, historyDataFrameToLogRecords } from '../state-history/common'; import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO } from './CentralAlertHistoryScene'; import { StateFilterValues } from './constants'; @@ -55,20 +54,8 @@ const emptyFilters: HistoryFilters = { * 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 and states in the groupDataFramesByTime function. */ -export function historyResultToDataFrame({ data }: DataFrameJSON, filters = emptyFilters): DataFrame[] { - // Extract timestamps and lines from the response - const [tsValues = [], lines = []] = data?.values ?? []; - const timestamps = isNumbers(tsValues) ? tsValues : []; - - const logRecords = timestamps.reduce((acc, timestamp: number, index: number) => { - const line = lines[index]; - if (!isLine(line)) { - return acc; - } - - acc.push({ timestamp, line }); - return acc; - }, []); +export function historyResultToDataFrame(stateHistory: DataFrameJSON, filters = emptyFilters): DataFrame[] { + const logRecords = historyDataFrameToLogRecords(stateHistory); // Group log records by alert instance const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => { 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 947098e60ec..96b4bea0f25 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,4 +1,6 @@ -import { Label, extractCommonLabels, omitLabels } from './common'; +import { DataFrameJSON } from '@grafana/data'; + +import { Label, extractCommonLabels, historyDataFrameToLogRecords, omitLabels } from './common'; test('extractCommonLabels', () => { const labels: Label[][] = [ @@ -48,3 +50,145 @@ test('omitLabels with no common labels', () => { expect(omitLabels(labels, commonLabels)).toStrictEqual(labels); }); + +describe('historyDataFrameToLogRecords', () => { + test('should return empty array when stateHistory is undefined', () => { + expect(historyDataFrameToLogRecords(undefined)).toEqual([]); + }); + + test('should return empty array when stateHistory.data is undefined', () => { + const stateHistory: DataFrameJSON = {} as DataFrameJSON; + expect(historyDataFrameToLogRecords(stateHistory)).toEqual([]); + }); + + test('should return empty array when stateHistory.data.values is empty', () => { + const stateHistory: DataFrameJSON = { + data: { values: [] }, + schema: { fields: [] }, + }; + expect(historyDataFrameToLogRecords(stateHistory)).toEqual([]); + }); + + test('should convert valid state history to log records', () => { + const stateHistory: DataFrameJSON = { + data: { + values: [ + [1681739580000, 1681739590000, 1681739600000], + [ + { + previous: 'Normal', + current: 'Alerting', + values: { B: 1 }, + labels: { alertname: 'test-rule', grafana_folder: 'folder-one' }, + }, + { + previous: 'Alerting', + current: 'Normal', + values: { B: 0 }, + labels: { alertname: 'test-rule', grafana_folder: 'folder-one' }, + }, + { + previous: 'Normal', + current: 'Pending', + values: { B: 0.5 }, + labels: { alertname: 'test-rule', grafana_folder: 'folder-one' }, + }, + ], + ], + }, + schema: { fields: [] }, + }; + + const result = historyDataFrameToLogRecords(stateHistory); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + timestamp: 1681739580000, + line: { + previous: 'Normal', + current: 'Alerting', + values: { B: 1 }, + labels: { alertname: 'test-rule', grafana_folder: 'folder-one' }, + }, + }); + expect(result[1]).toEqual({ + timestamp: 1681739590000, + line: { + previous: 'Alerting', + current: 'Normal', + values: { B: 0 }, + labels: { alertname: 'test-rule', grafana_folder: 'folder-one' }, + }, + }); + expect(result[2]).toEqual({ + timestamp: 1681739600000, + line: { + previous: 'Normal', + current: 'Pending', + values: { B: 0.5 }, + labels: { alertname: 'test-rule', grafana_folder: 'folder-one' }, + }, + }); + }); + + test('should skip invalid line objects that are missing current or previous', () => { + const stateHistory: DataFrameJSON = { + data: { + values: [ + [1681739580000, 1681739590000, 1681739600000], + [ + { + previous: 'Normal', + current: 'Alerting', + values: { B: 1 }, + labels: { alertname: 'test' }, + }, + { + // Missing 'current' field - invalid + previous: 'Alerting', + values: { B: 0 }, + labels: { alertname: 'test' }, + }, + { + previous: 'Normal', + current: 'Pending', + values: { B: 0.5 }, + labels: { alertname: 'test' }, + }, + ], + ], + }, + schema: { fields: [] }, + }; + + const result = historyDataFrameToLogRecords(stateHistory); + + expect(result).toHaveLength(2); + expect(result[0].timestamp).toBe(1681739580000); + expect(result[1].timestamp).toBe(1681739600000); + }); + + test('should handle lines with empty values', () => { + const stateHistory: DataFrameJSON = { + data: { + values: [ + [1681739580000], + [ + { + previous: 'Alerting', + current: 'Normal', + values: {}, + labels: { alertname: 'test' }, + }, + ], + ], + }, + schema: { fields: [] }, + }; + + const result = historyDataFrameToLogRecords(stateHistory); + + expect(result).toHaveLength(1); + expect(result[0].line.values).toEqual({}); + }); +}); 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 4076dde37ea..3765863936b 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,5 +1,6 @@ import { isEqual, uniqBy } from 'lodash'; +import { DataFrameJSON } from '@grafana/data'; import { GrafanaAlertStateWithReason } from 'app/types/unified-alerting-dto'; export interface Line { @@ -40,3 +41,37 @@ export function extractCommonLabels(labels: Label[][]): Label[] { return commonLabels; } + +export function historyDataFrameToLogRecords(stateHistory?: DataFrameJSON): LogRecord[] { + if (!stateHistory?.data || !stateHistory.data.values || !Array.isArray(stateHistory.data.values)) { + return []; + } + + const [tsValues, lines] = stateHistory.data.values; + + if (!Array.isArray(tsValues) || !Array.isArray(lines) || tsValues.length !== lines.length) { + return []; + } + + 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; + } + acc.push({ timestamp, line }); + return acc; + }, []); + + return logRecords; +} + +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; +} 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 90d989fdcfc..a5586862e56 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 @@ -16,26 +16,13 @@ import { useTheme2 } from '@grafana/ui'; import { labelsMatchMatchers } from '../../../utils/alertmanager'; import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; -import { Line, LogRecord, extractCommonLabels, omitLabels } from './common'; +import { LogRecord, extractCommonLabels, historyDataFrameToLogRecords, 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; - }, []); + const logRecords = historyDataFrameToLogRecords(stateHistory); // group all records by alert instance (unique set of labels) const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => { @@ -70,14 +57,6 @@ export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: str }, [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( diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx index 98c07cc8de6..85a4788a706 100644 --- a/public/app/features/alerting/unified/triage/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -192,6 +192,7 @@ export const getStyles = (theme: GrafanaTheme2) => { virtualizedContainer: css({ display: 'flex', flex: 1, + wordBreak: 'break-all', // make very long rule names render higher rows overflow: 'hidden', // Let AutoSizer handle the overflow }), summaryContainer: css({ diff --git a/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx new file mode 100644 index 00000000000..704c4d54c47 --- /dev/null +++ b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx @@ -0,0 +1,213 @@ +import { css } from '@emotion/css'; +import { orderBy } from 'lodash'; +import { Fragment, useMemo } from 'react'; +import { useMeasure } from 'react-use'; + +import { AlertLabels } from '@grafana/alerting/unstable'; +import { GrafanaTheme2, Labels } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { isFetchError } from '@grafana/runtime'; +import { TimeRangePicker, useTimeRange } from '@grafana/scenes-react'; +import { Alert, Box, Drawer, Icon, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui'; +import { AlertQuery, GrafanaRuleDefinition } from 'app/types/unified-alerting-dto'; + +import { alertRuleApi } from '../../api/alertRuleApi'; +import { stateHistoryApi } from '../../api/stateHistoryApi'; +import { getThresholdsForQueries } from '../../components/rule-editor/util'; +import { EventState } from '../../components/rules/central-state-history/EventListSceneObject'; +import { LogRecord, historyDataFrameToLogRecords } from '../../components/rules/state-history/common'; +import { isAlertQueryOfAlertData } from '../../rule-editor/formProcessing'; +import { stringifyErrorLike } from '../../utils/misc'; + +import { QueryVisualization } from './QueryVisualization'; +import { convertStateHistoryToAnnotations } from './stateHistoryUtils'; + +const { useGetAlertRuleQuery } = alertRuleApi; +const { useGetRuleHistoryQuery } = stateHistoryApi; + +interface InstanceDetailsDrawerProps { + ruleUID: string; + instanceLabels: Labels; + onClose: () => void; +} + +export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: InstanceDetailsDrawerProps) { + const [ref, { width: loadingBarWidth }] = useMeasure(); + const [timeRange] = useTimeRange(); + + const { data: rule, isLoading: loading, error } = useGetAlertRuleQuery({ uid: ruleUID }); + + const { dataQueries, thresholds } = useMemo(() => { + if (rule) { + return extractQueryDetails(rule.grafana_alert); + } + return { dataQueries: [], thresholds: {} }; + }, [rule]); + + // Fetch state history for this specific instance + const { + data: stateHistoryData, + isFetching: stateHistoryFetching, + isError: stateHistoryError, + } = useGetRuleHistoryQuery({ + ruleUid: ruleUID, + labels: instanceLabels, + from: timeRange.from.unix(), + to: timeRange.to.unix(), + }); + + // Convert state history to LogRecords and filter by instance labels + const { historyRecords, annotations } = useMemo(() => { + const historyRecords = historyDataFrameToLogRecords(stateHistoryData); + const annotations = convertStateHistoryToAnnotations(historyRecords); + + return { historyRecords, annotations }; + }, [stateHistoryData]); + + if (error) { + return ( + + + + ); + } + + if (loading || !rule) { + return ( + +
{t('alerting.common.loading', 'Loading...')}
+
+ ); + } + + return ( + + + + + + {dataQueries.length > 0 && ( + + + {dataQueries.map((query, index) => ( + + ))} + + + )} + + + + + + + {t('alerting.instance-details.state-history', 'Recent State Changes')} + {stateHistoryFetching && } + {stateHistoryError && ( + + {t( + 'alerting.instance-details.history-error-desc', + 'Unable to fetch state transition history for this instance.' + )} + + )} + {!stateHistoryFetching && !stateHistoryError && ( + + {historyRecords.length > 0 ? ( + + ) : ( + {t('alerting.instance-details.no-history', 'No recent state changes')} + )} + + )} + + + + ); +} + +function extractQueryDetails(rule: GrafanaRuleDefinition) { + const dataQueries = rule.data.filter((query: AlertQuery) => isAlertQueryOfAlertData(query)); + + const allQueries = rule.data; + const condition = rule.condition; + + const thresholds = getThresholdsForQueries(allQueries, condition); + + return { dataQueries, thresholds }; +} + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +function formatTimestamp(timestamp: number) { + return dateFormatter.format(new Date(timestamp)); +} + +function InstanceStateTransitions({ records }: { records: LogRecord[] }) { + const styles = useStyles2(stateTransitionStyles); + const sortedRecords = orderBy(records, (r) => r.timestamp, 'desc'); + + return ( +
+ {sortedRecords.map((record, index) => ( + + + {formatTimestamp(record.timestamp)} + + {}} type="from" /> + + {}} type="to" /> + + ))} +
+ ); +} + +const stateTransitionStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'grid', + gridTemplateColumns: 'max-content max-content max-content max-content', + gap: theme.spacing(1, 2), + alignItems: 'center', + padding: theme.spacing(1, 0), + }), +}); + +interface ErrorContentProps { + error: unknown; +} + +function ErrorContent({ error }: ErrorContentProps) { + if (isFetchError(error) && error.status === 404) { + return ( + + {t('alerting.triage.rule-not-found.description', 'The requested rule could not be found.')} + + ); + } + + return ( + + {stringifyErrorLike(error)} + + ); +} diff --git a/public/app/features/alerting/unified/triage/instance-details/QueryVisualization.tsx b/public/app/features/alerting/unified/triage/instance-details/QueryVisualization.tsx new file mode 100644 index 00000000000..65f25894c34 --- /dev/null +++ b/public/app/features/alerting/unified/triage/instance-details/QueryVisualization.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; + +import { DataFrame, Labels, LoadingState } from '@grafana/data'; +import { SceneDataNode, VizConfigBuilders } from '@grafana/scenes'; +import { VizPanel, useQueryRunner, useTimeRange } from '@grafana/scenes-react'; +import { GraphDrawStyle, LegendDisplayMode, TooltipDisplayMode, VisibilityMode } from '@grafana/schema'; +import { Box } from '@grafana/ui'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { getThresholdsForQueries } from '../../components/rule-editor/util'; + +interface QueryVisualizationProps { + query: AlertQuery; + instanceLabels: Labels; + thresholds?: ReturnType; + annotations?: DataFrame[]; +} + +export function QueryVisualization({ query, instanceLabels, thresholds, annotations = [] }: QueryVisualizationProps) { + const [timeRange] = useTimeRange(); + // Convert query to range query for visualization + const visualizationQuery = useMemo(() => { + const model = { ...query.model, refId: query.refId }; + + // For Prometheus queries, ensure we use range queries for better visualization + if ('instant' in model) { + model.instant = false; + model.range = true; + } + + return model; + }, [query.model, query.refId]); + + // Create the base data provider + const baseDataProvider = useQueryRunner({ + datasource: { uid: query.datasourceUid }, + queries: [visualizationQuery], + }); + + // Get data and filter by instance labels + const { data } = baseDataProvider.useState(); + + // Filter frames to only include those matching the instance labels + const filteredSeries = useMemo(() => { + if (!data?.series || Object.keys(instanceLabels).length === 0) { + return data?.series || []; + } + + return data.series.filter((frame) => frameMatchesInstanceLabels(frame, instanceLabels)); + }, [data?.series, instanceLabels]); + + // Create a data provider with filtered series + const dataProvider = new SceneDataNode({ + data: { + series: filteredSeries, + state: data?.state || LoadingState.NotStarted, + timeRange: timeRange, + annotations: annotations, + }, + }); + + // Create visualization config with thresholds if available + const vizConfig = useMemo(() => { + const baseConfig = VizConfigBuilders.timeseries() + .setCustomFieldConfig('drawStyle', GraphDrawStyle.Line) + .setCustomFieldConfig('showPoints', VisibilityMode.Auto) + .setOption('tooltip', { mode: TooltipDisplayMode.Multi }) + .setOption('legend', { showLegend: false, displayMode: LegendDisplayMode.Hidden }); + + // Apply thresholds if available for this query + const queryThresholds = thresholds?.[query.refId]; + if (queryThresholds) { + baseConfig + .setThresholds(queryThresholds.config) + .setCustomFieldConfig('thresholdsStyle', { mode: queryThresholds.mode }); + } + + return baseConfig.build(); + }, [query.refId, thresholds]); + + return ( + + + + ); +} + +// Helper function to check if frame labels match instance labels +function frameMatchesInstanceLabels(frame: DataFrame, instanceLabels: Labels): boolean { + // Check if any field in the frame has labels that are a subset of instance labels + // (i.e., all field labels exist in instanceLabels with matching values) + // + // Note: We check if field.labels ⊆ instanceLabels (subset) rather than equality because + // the alert rule evaluation engine might apply additional rule-specific labels coming from + // rule labels and labels templating. Currently, we have no way of knowing these additional + // labels when querying the datasource directly, so instanceLabels may contain more labels + // than what appears in the raw query results. + for (const field of frame.fields) { + if (field.labels) { + const allFieldLabelsMatch = Object.entries(field.labels).every(([key, value]) => instanceLabels[key] === value); + if (allFieldLabelsMatch) { + return true; + } + } + } + return false; +} diff --git a/public/app/features/alerting/unified/triage/instance-details/stateHistoryUtils.ts b/public/app/features/alerting/unified/triage/instance-details/stateHistoryUtils.ts new file mode 100644 index 00000000000..8ef8457991e --- /dev/null +++ b/public/app/features/alerting/unified/triage/instance-details/stateHistoryUtils.ts @@ -0,0 +1,50 @@ +import { AnnotationEvent, DataFrame, DataTopic, arrayToDataFrame } from '@grafana/data'; + +import { LogRecord } from '../../components/rules/state-history/common'; + +// Function to get color based on alert state - matching EventState component colors +function getStateColor(state: string): string { + const stateStr = String(state).toLowerCase(); + if (stateStr.includes('normal')) { + return '#73BF69'; // Green + } else if (stateStr.includes('alerting')) { + return '#F2495C'; // Red + } else if (stateStr.includes('pending')) { + return '#FF9830'; // Orange + } else if (stateStr.includes('recovering')) { + return '#FF9830'; // Orange + } else if (stateStr.includes('nodata')) { + return '#5794F2'; // Blue + } + return '#8e8e8e'; // Gray for unknown states +} + +/** + * Converts log records to annotation DataFrames + * @param logRecords Array of LogRecord objects + * @returns Array of annotation DataFrames (empty array if no data) + */ +export function convertStateHistoryToAnnotations(logRecords: LogRecord[]): DataFrame[] { + if (!logRecords || logRecords.length === 0) { + return []; + } + + const annotationEvents: AnnotationEvent[] = logRecords.map((record) => { + const { timestamp, line } = record; + return { + time: timestamp, + title: `${line.previous} → ${line.current}`, + text: `State changed from ${line.previous} to ${line.current}`, + tags: ['state-transition'], + color: getStateColor(line.current), + }; + }); + + if (annotationEvents.length === 0) { + return []; + } + + const annotationFrame = arrayToDataFrame(annotationEvents); + annotationFrame.meta = { dataTopic: DataTopic.Annotations }; + return [annotationFrame]; +} diff --git a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx index fefd37258f9..539dc2c4ed1 100644 --- a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx @@ -1,10 +1,10 @@ -import React from 'react'; +import { useState } from 'react'; -import { Stack, Text, TextLink } from '@grafana/ui'; +import { t } from '@grafana/i18n'; +import { IconButton, Stack, Text } from '@grafana/ui'; import { MetaText } from '../../components/MetaText'; -import { WithReturnButton } from '../../components/WithReturnButton'; -import { rulesNav } from '../../utils/navigation'; +import { RuleDetailsDrawer } from '../rule-details/RuleDetailsDrawer'; import { AlertRuleInstances } from '../scene/AlertRuleInstances'; import { AlertRuleSummary } from '../scene/AlertRuleSummary'; import { AlertRuleRow as AlertRuleRowType } from '../types'; @@ -19,37 +19,46 @@ interface AlertRuleRowProps { } export const AlertRuleRow = ({ row, leftColumnWidth, rowKey, depth = 0 }: AlertRuleRowProps) => { + const { ruleUID, folder, title } = row.metadata; + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const handleDrawerOpen = () => { + setIsDrawerOpen(true); + }; + + const handleDrawerClose = () => { + setIsDrawerOpen(false); + }; + return ( - - {row.metadata.title} - - } - /> - } - metadata={ - - - - {row.metadata.folder} - - - } - content={} - depth={depth} - > - - + <> + {title}} + actions={ + + } + metadata={ + + + + {folder} + + + } + content={} + depth={depth} + > + + + + {isDrawerOpen && } + ); }; diff --git a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx index 7480d6f8a7e..e18898aa48e 100644 --- a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx @@ -1,16 +1,17 @@ import { css } from '@emotion/css'; import { isEmpty } from 'lodash'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { AlertLabels } from '@grafana/alerting/unstable'; import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; +import { Trans, t } from '@grafana/i18n'; import { SceneDataNode, VizConfigBuilders } from '@grafana/scenes'; import { VizPanel } from '@grafana/scenes-react'; import { GraphDrawStyle, VisibilityMode } from '@grafana/schema'; import { AxisPlacement, BarAlignment, + IconButton, LegendDisplayMode, StackingMode, Text, @@ -19,6 +20,7 @@ import { } from '@grafana/ui'; import { overrideToFixedColor } from '../../home/Insights'; +import { InstanceDetailsDrawer } from '../instance-details/InstanceDetailsDrawer'; import { GenericRow } from './GenericRow'; @@ -32,6 +34,7 @@ interface InstanceRowProps { commonLabels: Labels; leftColumnWidth: number; timeRange: TimeRange; + ruleUID: string; depth?: number; } @@ -61,8 +64,24 @@ const chartConfig = VizConfigBuilders.timeseries() ) .build(); -export function InstanceRow({ instance, commonLabels, leftColumnWidth, timeRange, depth = 0 }: InstanceRowProps) { +export function InstanceRow({ + instance, + commonLabels, + leftColumnWidth, + timeRange, + ruleUID, + depth = 0, +}: InstanceRowProps) { const styles = useStyles2(getStyles); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const handleDrawerOpen = () => { + setIsDrawerOpen(true); + }; + + const handleDrawerClose = () => { + setIsDrawerOpen(false); + }; const dataProvider = useMemo( () => @@ -77,29 +96,49 @@ export function InstanceRow({ instance, commonLabels, leftColumnWidth, timeRange ); return ( - - - No labels - - - ) : ( - + + + No labels + + + ) : ( + + ) + } + actions={ + - ) - } - content={ - - } - depth={depth} - /> + } + content={ + + } + depth={depth} + /> + + {isDrawerOpen && ( + + )} + ); } diff --git a/public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx b/public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx new file mode 100644 index 00000000000..69687456341 --- /dev/null +++ b/public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx @@ -0,0 +1,149 @@ +import { useMemo, useState } from 'react'; + +import { Trans, t } from '@grafana/i18n'; +import { isFetchError } from '@grafana/runtime'; +import { Alert, Box, Drawer, LinkButton, Stack, Tab, TabContent, TabsBar, Text } from '@grafana/ui'; +import { GrafanaRuleIdentifier } from 'app/types/unified-alerting'; + +import { Spacer } from '../../components/Spacer'; +import { WithReturnButton } from '../../components/WithReturnButton'; +import { Title } from '../../components/rule-viewer/RuleViewer'; +import { Details } from '../../components/rule-viewer/tabs/Details'; +import { QueryResults } from '../../components/rule-viewer/tabs/Query'; +import { useCombinedRule } from '../../hooks/useCombinedRule'; +import { stringifyErrorLike } from '../../utils/misc'; +import { rulesNav } from '../../utils/navigation'; +import { getRulePluginOrigin, isPausedRule, prometheusRuleType, rulerRuleType } from '../../utils/rules'; + +interface RuleDetailsDrawerProps { + ruleUID: string; + onClose: () => void; +} + +enum DrawerTab { + Query = 'query', + Details = 'details', +} + +export function RuleDetailsDrawer({ ruleUID, onClose }: RuleDetailsDrawerProps) { + const [activeTab, setActiveTab] = useState(DrawerTab.Query); + + // Create rule identifier for Grafana managed rules + const ruleIdentifier: GrafanaRuleIdentifier = useMemo( + () => ({ + uid: ruleUID, + ruleSourceName: 'grafana', + }), + [ruleUID] + ); + + // Fetch rule data + const { + loading, + error, + result: rule, + } = useCombinedRule({ + ruleIdentifier, + }); + + if (error) { + return ( + + + + ); + } + + if (loading || !rule) { + return ( + +
{t('alerting.common.loading', 'Loading...')}
+
+ ); + } + + const { rulerRule, promRule } = rule; + const isPaused = rulerRuleType.grafana.rule(rulerRule) && isPausedRule(rulerRule); + const ruleOrigin = rulerRule ? getRulePluginOrigin(rulerRule) : getRulePluginOrigin(promRule); + + return ( + + + + <Spacer /> + <Box marginRight={4}> + <WithReturnButton + component={ + <LinkButton + icon="eye" + variant="secondary" + href={rulesNav.detailsPageLink('grafana', { + ruleSourceName: 'grafana', + uid: rule.uid ?? '', + })} + target="_blank" + size="sm" + > + <Trans i18nKey="alerting.rule-details-drawer.go-to-detail-view">View alert rule</Trans> + </LinkButton> + } + /> + </Box> + </Stack> + <Text color="secondary">{t('alerting.triage.rule-details.subtitle', 'Rule details and conditions')}</Text> + </Stack> + } + size="lg" + tabs={ + <TabsBar> + <Tab + label={t('alerting.rule-viewer.tab.query-conditions', 'Query and conditions')} + active={activeTab === DrawerTab.Query} + onChangeTab={() => setActiveTab(DrawerTab.Query)} + /> + <Tab + label={t('alerting.rule-viewer.tab.details', 'Details')} + active={activeTab === DrawerTab.Details} + onChangeTab={() => setActiveTab(DrawerTab.Details)} + /> + </TabsBar> + } + > + <TabContent> + {activeTab === DrawerTab.Query && <QueryResults rule={rule} />} + {activeTab === DrawerTab.Details && <Details rule={rule} />} + </TabContent> + </Drawer> + ); +} + +interface ErrorContentProps { + error: unknown; +} + +function ErrorContent({ error }: ErrorContentProps) { + if (isFetchError(error) && error.status === 404) { + return ( + <Alert title={t('alerting.triage.rule-not-found.title', 'Rule not found')} severity="error"> + {t('alerting.triage.rule-not-found.description', 'The requested rule could not be found.')} + </Alert> + ); + } + + return ( + <Alert title={t('alerting.triage.error-loading-rule', 'Error loading rule')} severity="error"> + {stringifyErrorLike(error)} + </Alert> + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx index 9d2889d9cae..6ac104d2a76 100644 --- a/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx @@ -90,6 +90,7 @@ export function AlertRuleInstances({ ruleUID, depth = 0 }: AlertRuleInstancesPro commonLabels={commonLabels} leftColumnWidth={leftColumnWidth} timeRange={timeRange} + ruleUID={ruleUID} depth={depth} /> ))} diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx index add4f86934e..73957f85789 100644 --- a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx @@ -32,12 +32,12 @@ export function SummaryStatsReact() { const firstFrame = data?.series?.at(0); if (isLoading || !firstFrame) { - return null; + return <div />; } const dfv = new DataFrameView<Frame>(firstFrame); if (dfv.length === 0) { - return null; + return <div />; } const firingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'firing'); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f3afe2eb828..df3dc82617e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1655,6 +1655,15 @@ "inspector-yaml-tab": { "apply": "Apply" }, + "instance-details": { + "history-error": "Failed to load state history", + "history-error-desc": "Unable to fetch state transition history for this instance.", + "no-history": "No recent state changes", + "state-history": "Recent State Changes" + }, + "instance-details-drawer": { + "title-instance-details": "Instance Details" + }, "instance-match": { "non-matching-labels": "Non-matching labels", "notification-policy": "View route" @@ -2314,6 +2323,9 @@ "rule-details-data-sources": { "label-data-source": "Data source" }, + "rule-details-drawer": { + "go-to-detail-view": "View alert rule" + }, "rule-details-expression": { "label-expression": "Expression" }, @@ -2560,6 +2572,10 @@ "alert-message": "Alert rule has been added or updated. Changes may take up to a minute to appear on the Alert rules list view.", "alert-title": "Update in progress" }, + "tab": { + "details": "Details", + "query-conditions": "Query and conditions" + }, "title-something-wrong-evaluating-alert": "Something went wrong when evaluating this alert rule" }, "rules": { @@ -2930,10 +2946,22 @@ }, "triage": { "alert-instances": "Alert instances", + "error-loading-rule": "Error loading rule", "firing-instances-count": "{{firingCount}} firing instances", + "instance-details": "Instance Details", "no-instances-found": "No alert instances found for rule: {ruleUID}", "no-labels": "No labels", - "pending-instances-count": "{{pendingCount}} pending instances" + "open-in-sidebar": "Open in sidebar", + "open-rule-details": "Open rule details", + "pending-instances-count": "{{pendingCount}} pending instances", + "rule-details": { + "subtitle": "Rule details and conditions", + "title": "Rule Details" + }, + "rule-not-found": { + "description": "The requested rule could not be found.", + "title": "Rule not found" + } }, "type-selector-button": { "add-expression": "Add expression"