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 <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik
2025-10-14 14:58:16 +02:00
committed by GitHub
co-authored by Gilles De Mey
parent 86edf28082
commit 8a827b5b05
16 changed files with 861 additions and 138 deletions
@@ -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) => {
@@ -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,
};
}
@@ -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<LogRecord[]>((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) => {
@@ -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({});
});
});
@@ -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;
}
@@ -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(
@@ -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({
@@ -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<HTMLDivElement>();
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 (
<Drawer title={t('alerting.triage.instance-details', 'Instance Details')} onClose={onClose} size="md">
<ErrorContent error={error} />
</Drawer>
);
}
if (loading || !rule) {
return (
<Drawer title={t('alerting.triage.instance-details', 'Instance Details')} onClose={onClose} size="md">
<div>{t('alerting.common.loading', 'Loading...')}</div>
</Drawer>
);
}
return (
<Drawer
title={t('alerting.instance-details-drawer.title-instance-details', 'Instance Details')}
onClose={onClose}
size="lg"
>
<Stack direction="column" gap={3}>
<Stack justifyContent="flex-end">
<TimeRangePicker />
</Stack>
{dataQueries.length > 0 && (
<Box>
<Stack direction="column" gap={2}>
{dataQueries.map((query, index) => (
<QueryVisualization
key={query.refId || `query-${index}`}
query={query}
instanceLabels={instanceLabels}
thresholds={thresholds}
annotations={annotations}
/>
))}
</Stack>
</Box>
)}
<Box>
<AlertLabels labels={instanceLabels} />
</Box>
<Box ref={ref}>
<Text variant="h5">{t('alerting.instance-details.state-history', 'Recent State Changes')}</Text>
{stateHistoryFetching && <LoadingBar width={loadingBarWidth} />}
{stateHistoryError && (
<Alert
severity="error"
title={t('alerting.instance-details.history-error', 'Failed to load state history')}
>
{t(
'alerting.instance-details.history-error-desc',
'Unable to fetch state transition history for this instance.'
)}
</Alert>
)}
{!stateHistoryFetching && !stateHistoryError && (
<Stack direction="column" gap={1}>
{historyRecords.length > 0 ? (
<InstanceStateTransitions records={historyRecords} />
) : (
<Text color="secondary">{t('alerting.instance-details.no-history', 'No recent state changes')}</Text>
)}
</Stack>
)}
</Box>
</Stack>
</Drawer>
);
}
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 (
<div className={styles.container}>
{sortedRecords.map((record, index) => (
<Fragment key={`${record.timestamp}-${index}`}>
<Text color="secondary" variant="bodySmall">
{formatTimestamp(record.timestamp)}
</Text>
<EventState state={record.line.previous} showLabel addFilter={() => {}} type="from" />
<Icon name="arrow-right" size="sm" />
<EventState state={record.line.current} showLabel addFilter={() => {}} type="to" />
</Fragment>
))}
</div>
);
}
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 (
<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>
);
}
@@ -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<typeof getThresholdsForQueries>;
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 (
<Box key={query.refId} height={36}>
<VizPanel
title={query.refId}
viz={vizConfig}
dataProvider={dataProvider}
displayMode="transparent"
collapsible={false}
/>
</Box>
);
}
// 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;
}
@@ -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];
}
@@ -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 (
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={
<WithReturnButton
component={
<TextLink
inline={false}
href={rulesNav.detailsPageLink('grafana', {
ruleSourceName: 'grafana',
uid: row.metadata.ruleUID,
})}
>
{row.metadata.title}
</TextLink>
}
/>
}
metadata={
<Stack direction="row" gap={0.5} alignItems="center">
<MetaText icon="folder" />
<Text variant="bodySmall" color="secondary">
{row.metadata.folder}
</Text>
</Stack>
}
content={<AlertRuleSummary ruleUID={row.metadata.ruleUID} />}
depth={depth}
>
<AlertRuleInstances ruleUID={row.metadata.ruleUID} depth={depth + 1} />
</GenericRow>
<>
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={<Text variant="body">{title}</Text>}
actions={
<IconButton
style={{ transform: 'rotate(180deg)' }}
name="web-section-alt"
aria-label={t('alerting.triage.open-rule-details', 'Open rule details')}
onClick={handleDrawerOpen}
/>
}
metadata={
<Stack direction="row" gap={0.5} alignItems="center">
<MetaText icon="folder" />
<Text variant="bodySmall" color="secondary">
{folder}
</Text>
</Stack>
}
content={<AlertRuleSummary ruleUID={ruleUID} />}
depth={depth}
>
<AlertRuleInstances ruleUID={ruleUID} depth={depth + 1} />
</GenericRow>
{isDrawerOpen && <RuleDetailsDrawer ruleUID={ruleUID} onClose={handleDrawerClose} />}
</>
);
};
@@ -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 (
<GenericRow
width={leftColumnWidth}
title={
isEmpty(instance.labels) ? (
<div className={styles.wrapper}>
<Text color="secondary" variant="bodySmall">
<Trans i18nKey="alerting.triage.no-labels">No labels</Trans>
</Text>
</div>
) : (
<AlertLabels
labels={instance.labels}
displayCommonLabels={true}
labelSets={[instance.labels, commonLabels]}
size="xs"
<>
<GenericRow
width={leftColumnWidth}
title={
isEmpty(instance.labels) ? (
<div className={styles.wrapper}>
<Text color="secondary" variant="bodySmall">
<Trans i18nKey="alerting.triage.no-labels">No labels</Trans>
</Text>
</div>
) : (
<AlertLabels
labels={instance.labels}
displayCommonLabels={true}
labelSets={[instance.labels, commonLabels]}
size="xs"
/>
)
}
actions={
<IconButton
style={{ transform: 'rotate(180deg)' }}
name="web-section-alt"
aria-label={t('alerting.triage.open-in-sidebar', 'Open in sidebar')}
onClick={handleDrawerOpen}
/>
)
}
content={
<VizPanel title="" hoverHeader={true} viz={chartConfig} dataProvider={dataProvider} displayMode="transparent" />
}
depth={depth}
/>
}
content={
<VizPanel
title=""
hoverHeader={true}
viz={chartConfig}
dataProvider={dataProvider}
displayMode="transparent"
/>
}
depth={depth}
/>
{isDrawerOpen && (
<InstanceDetailsDrawer ruleUID={ruleUID} instanceLabels={instance.labels} onClose={handleDrawerClose} />
)}
</>
);
}
@@ -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>(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 (
<Drawer title={t('alerting.triage.rule-details.title', 'Rule Details')} onClose={onClose} size="lg">
<ErrorContent error={error} />
</Drawer>
);
}
if (loading || !rule) {
return (
<Drawer title={t('alerting.triage.rule-details.title', 'Rule Details')} onClose={onClose} size="lg">
<div>{t('alerting.common.loading', 'Loading...')}</div>
</Drawer>
);
}
const { rulerRule, promRule } = rule;
const isPaused = rulerRuleType.grafana.rule(rulerRule) && isPausedRule(rulerRule);
const ruleOrigin = rulerRule ? getRulePluginOrigin(rulerRule) : getRulePluginOrigin(promRule);
return (
<Drawer
onClose={onClose}
subtitle={`HELLO`}
title={
<Stack direction="column">
<Stack direction="row" alignItems="center">
<Title
name={rule.name}
paused={isPaused}
state={prometheusRuleType.alertingRule(promRule) ? promRule.state : undefined}
health={promRule?.health}
ruleType={promRule?.type}
ruleOrigin={ruleOrigin}
/>
<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>
);
}
@@ -90,6 +90,7 @@ export function AlertRuleInstances({ ruleUID, depth = 0 }: AlertRuleInstancesPro
commonLabels={commonLabels}
leftColumnWidth={leftColumnWidth}
timeRange={timeRange}
ruleUID={ruleUID}
depth={depth}
/>
))}
@@ -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');
+29 -1
View File
@@ -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"