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:
co-authored by
Gilles De Mey
parent
86edf28082
commit
8a827b5b05
+2
-16
@@ -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) => {
|
||||
|
||||
+8
-19
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+3
-16
@@ -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) => {
|
||||
|
||||
+145
-1
@@ -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;
|
||||
}
|
||||
|
||||
+2
-23
@@ -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({
|
||||
|
||||
+213
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user