Alerting: Central alert history part4 (#90088)

* Implement EventDetails for expanded rows and pagination on the events list

* Add test for getPanelDataForRule function

* prettier

* refactor EventState component

* create interfaces for props

* Add missing translations

* Update some comments

* Add plus button in alertrulename , to add it into the filter

* Add plus button to add filters from the list labels and alert name

* Add clear labels filter button

* run prettier

* fix RBAC checks

* Update AlertLabels onLabelClick functionality

* add limit=0 in useCombinedRule call

* Add filter by state

* remove plus button in labels

* Fix state filter

* Add filter by previous state

* fix some errors after solving conflicts

* Add comments and remove some type assertions

* Update the number of transitions calculation to be for each instance

* Add tests for state filters

* remove type assertion

* Address review comments

* Update returnTo prop in alert list view url

* Update translations

* address review comments

* prettier

* update cursor to pointer

* Address Deyan review comments

* address review pr comments from Deyan

* fix label styles

* Visualize expanded row as a state graph and address some pr review comments

* Add warning when limit of events is reached and rename onClickLabel

* Update texts

* Fix translations

* Update some Labels in the expanded states visualization

* move getPanelDataForRule to a separate file

* Add header to the list of events

* Move HistoryErrorMessage to a separate file

* remove getPanelDataForRule function and test

* add comment

* fitler by instance label results shown inthe state chart

* remove defaults.ini changes

* fix having single event on time state chart

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sonia Aguilar 2024-07-11 12:09:52 +02:00 committed by GitHub
parent 92ada4eb7c
commit c76b490c57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 860 additions and 289 deletions

View File

@ -409,13 +409,15 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
}
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "History",
SubTitle: "History of events that were generated by your Grafana-managed alert rules. Silences and Mute timings are ignored.",
Id: "alerts-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "History",
SubTitle: "View a history of all alert events generated by your Grafana-managed alert rules. All alert events are displayed regardless of whether silences or mute timings are set.",
Id: "alerts-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
}
}
if c.SignedInUser.GetOrgRole() == org.RoleAdmin {

View File

@ -168,10 +168,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
},
{
path: '/alerting/history/',
roles: evaluateAccess([
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstancesExternalRead,
]),
roles: evaluateAccess([AccessControlAction.AlertingRuleRead]),
component: importAlertingComponent(
() =>
import(

View File

@ -15,9 +15,10 @@ interface Props {
labels: Record<string, string>;
commonLabels?: Record<string, string>;
size?: LabelSize;
onClick?: (label: string, value: string) => void;
}
export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
export const AlertLabels = ({ labels, commonLabels = {}, size, onClick }: Props) => {
const styles = useStyles2(getStyles, size);
const [showCommonLabels, setShowCommonLabels] = useState(false);
@ -33,9 +34,19 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
return (
<div className={styles.wrapper} role="list" aria-label="Labels">
{labelsToShow.map(([label, value]) => (
<Label key={label + value} size={size} label={label} value={value} color={getLabelColor(label)} />
))}
{labelsToShow.map(([label, value]) => {
return (
<Label
key={label + value}
size={size}
label={label}
value={value}
color={getLabelColor(label)}
onClick={onClick}
/>
);
})}
{!showCommonLabels && hasCommonLabels && (
<Button
variant="secondary"
@ -67,12 +78,14 @@ function getLabelColor(input: string): string {
return getTagColorsFromName(input).color;
}
const getStyles = (theme: GrafanaTheme2, size?: LabelSize) => ({
wrapper: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
const getStyles = (theme: GrafanaTheme2, size?: LabelSize) => {
return {
wrapper: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: size === 'md' ? theme.spacing() : theme.spacing(0.5),
}),
});
gap: size === 'md' ? theme.spacing() : theme.spacing(0.5),
}),
};
};

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { CSSProperties, ReactNode } from 'react';
import { CSSProperties, ReactNode, useMemo } from 'react';
import tinycolor2 from 'tinycolor2';
import { GrafanaTheme2, IconName } from '@grafana/data';
@ -13,15 +13,18 @@ interface Props {
value: ReactNode;
color?: string;
size?: LabelSize;
onClick?: (label: string, value: string) => void;
}
// TODO allow customization with color prop
const Label = ({ label, value, icon, color, size = 'md' }: Props) => {
const Label = ({ label, value, icon, color, size = 'md', onClick }: Props) => {
const styles = useStyles2(getStyles, color, size);
const ariaLabel = `${label}: ${value}`;
const labelStr = label?.toString() ?? '';
const valueStr = value?.toString() ?? '';
return (
<div className={styles.wrapper} role="listitem" aria-label={ariaLabel} data-testid="label-value">
const innerLabel = useMemo(
() => (
<Stack direction="row" gap={0} alignItems="stretch">
<div className={styles.label}>
<Stack direction="row" gap={0.5} alignItems="center">
@ -37,6 +40,32 @@ const Label = ({ label, value, icon, color, size = 'md' }: Props) => {
{value ?? '-'}
</div>
</Stack>
),
[icon, label, value, styles]
);
return (
<div className={styles.wrapper} role="listitem" aria-label={ariaLabel} data-testid="label-value">
{onClick ? (
<div
className={styles.clickable}
role="button" // role="button" and tabIndex={0} is needed for keyboard navigation
tabIndex={0} // Make it focusable
key={labelStr + valueStr}
onClick={() => onClick(labelStr, valueStr)}
onKeyDown={(e) => {
// needed for accessiblity: handle keyboard navigation
if (e.key === 'Enter') {
onClick(labelStr, valueStr);
e.preventDefault();
}
}}
>
{innerLabel}
</div>
) : (
innerLabel
)}
</div>
);
};
@ -94,6 +123,12 @@ const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => {
borderTopLeftRadius: theme.shape.borderRadius(2),
borderBottomLeftRadius: theme.shape.borderRadius(2),
}),
clickable: css({
'&:hover': {
opacity: 0.8,
cursor: 'pointer',
},
}),
value: css({
color: 'inherit',
padding: padding,

View File

@ -1,6 +1,8 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, VariableHide } from '@grafana/data';
import {
CustomVariable,
EmbeddedScene,
PanelBuilders,
SceneControlsSpacer,
@ -18,12 +20,14 @@ import {
} from '@grafana/scenes';
import { GraphDrawStyle, VisibilityMode } from '@grafana/schema/dist/esm/index';
import {
Button,
GraphGradientMode,
Icon,
LegendDisplayMode,
LineInterpolation,
ScaleDistribution,
StackingMode,
Text,
Tooltip,
TooltipDisplayMode,
useStyles2,
@ -35,7 +39,9 @@ import { DataSourceInformation } from '../../../home/Insights';
import { alertStateHistoryDatasource, useRegisterHistoryRuntimeDataSource } from './CentralHistoryRuntimeDataSource';
import { HistoryEventsListObject } from './EventListSceneObject';
export const LABELS_FILTER = 'filter';
export const LABELS_FILTER = 'labelsFilter';
export const STATE_FILTER_TO = 'stateFilterTo';
export const STATE_FILTER_FROM = 'stateFilterFrom';
/**
*
* This scene shows the history of the alert state changes.
@ -46,20 +52,56 @@ export const LABELS_FILTER = 'filter';
* Both share time range and filter variable from the parent scene.
*/
export const StateFilterValues = {
all: 'all',
firing: 'Alerting',
normal: 'Normal',
pending: 'Pending',
} as const;
export const CentralAlertHistoryScene = () => {
const filterVariable = new TextBoxVariable({
// create the variables for the filters
// textbox variable for filtering by labels
const labelsFilterVariable = new TextBoxVariable({
name: LABELS_FILTER,
label: 'Filter by labels: ',
label: 'Labels: ',
});
//custom variable for filtering by the current state
const transitionsToFilterVariable = new CustomVariable({
name: STATE_FILTER_TO,
value: StateFilterValues.all,
label: 'End state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, To Firing : ${StateFilterValues.firing},To Normal : ${StateFilterValues.normal},To Pending : ${StateFilterValues.pending}`,
});
//custom variable for filtering by the previous state
const transitionsFromFilterVariable = new CustomVariable({
name: STATE_FILTER_FROM,
value: StateFilterValues.all,
label: 'Start state:',
hide: VariableHide.dontHide,
query: `All : ${StateFilterValues.all}, From Firing : ${StateFilterValues.firing},From Normal : ${StateFilterValues.normal},From Pending : ${StateFilterValues.pending}`,
});
useRegisterHistoryRuntimeDataSource(); // register the runtime datasource for the history api.
const scene = new EmbeddedScene({
controls: [
new SceneReactObject({
component: LabelFilter,
}),
new SceneReactObject({
component: FilterInfo,
}),
new VariableValueSelectors({}),
new SceneReactObject({
component: ClearFilterButton,
props: {
labelsFilterVariable,
transitionsToFilterVariable,
transitionsFromFilterVariable,
},
}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
@ -71,7 +113,7 @@ export const CentralAlertHistoryScene = () => {
to: 'now',
}),
$variables: new SceneVariableSet({
variables: [filterVariable],
variables: [labelsFilterVariable, transitionsFromFilterVariable, transitionsToFilterVariable],
}),
body: new SceneFlexLayout({
direction: 'column',
@ -144,8 +186,10 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
return new SceneFlexItem({
minHeight: 300,
body: PanelBuilders.timeseries()
.setTitle('Events')
.setDescription('Alert events during the period of time.')
.setTitle('Alert Events')
.setDescription(
'Each alert event represents an alert instance that changed its state at a particular point in time. The history of the data is displayed over a period of time.'
)
.setData(getSceneQuery(datasource))
.setColor({ mode: 'continuous-BlPu' })
.setCustomFieldConfig('fillOpacity', 100)
@ -167,11 +211,66 @@ export function getEventsScenesFlexItem(datasource: DataSourceInformation) {
.build(),
});
}
/*
* This component shows a button to clear the filters.
* It is shown when the filters are active.
* props:
* labelsFilterVariable: the textbox variable for filtering by labels
* transitionsToFilterVariable: the custom variable for filtering by the current state
* transitionsFromFilterVariable: the custom variable for filtering by the previous state
*/
export const FilterInfo = () => {
function ClearFilterButton({
labelsFilterVariable,
transitionsToFilterVariable,
transitionsFromFilterVariable,
}: {
labelsFilterVariable: TextBoxVariable;
transitionsToFilterVariable: CustomVariable;
transitionsFromFilterVariable: CustomVariable;
}) {
// get the current values of the filters
const valueInLabelsFilter = labelsFilterVariable.getValue();
//todo: use parsePromQLStyleMatcherLooseSafe to validate the label filter and check the lenghtof the result
const valueInTransitionsFilter = transitionsToFilterVariable.getValue();
const valueInTransitionsFromFilter = transitionsFromFilterVariable.getValue();
// if no filter is active, return null
if (
!valueInLabelsFilter &&
valueInTransitionsFilter === StateFilterValues.all &&
valueInTransitionsFromFilter === StateFilterValues.all
) {
return null;
}
const onClearFilter = () => {
labelsFilterVariable.setValue('');
transitionsToFilterVariable.changeValueTo(StateFilterValues.all);
transitionsFromFilterVariable.changeValueTo(StateFilterValues.all);
};
return (
<Tooltip content="Clear filter">
<Button variant={'secondary'} icon="times" onClick={onClearFilter}>
<Trans i18nKey="alerting.central-alert-history.filter.clear">Clear filters</Trans>
</Button>
</Tooltip>
);
}
const LabelFilter = () => {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<div className={styles.filterLabelContainer}>
<Text variant="body" weight="light" color="secondary">
<Trans i18nKey="alerting.central-alert-history.filterBy">Filter by:</Trans>
</Text>
</div>
);
};
const FilterInfo = () => {
const styles = useStyles2(getStyles);
return (
<div className={styles.filterInfoContainer}>
<Tooltip
content={
<div>
@ -180,7 +279,7 @@ export const FilterInfo = () => {
</Trans>
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
<Trans i18nKey="alerting.central-alert-history.filter.info.label2">Invalid use of spaces:</Trans>
<pre>{`{severity= "alerting.critical"}`}</pre>
<pre>{`{severity= "critical"}`}</pre>
<pre>{`{severity ="critical"}`}</pre>
<Trans i18nKey="alerting.central-alert-history.filter.info.label3">Valid use of spaces:</Trans>
<pre>{`{severity=" critical"}`}</pre>
@ -197,9 +296,16 @@ export const FilterInfo = () => {
);
};
const getStyles = () => ({
container: css({
padding: '0',
alignSelf: 'center',
}),
});
const getStyles = (theme: GrafanaTheme2) => {
return {
filterInfoContainer: css({
padding: '0',
alignSelf: 'center',
marginRight: theme.spacing(-1),
}),
filterLabelContainer: css({
padding: '0',
alignSelf: 'center',
}),
};
};

View File

@ -1,33 +1,41 @@
import { css } from '@emotion/css';
import { max, min, uniqBy } from 'lodash';
import { capitalize, groupBy } from 'lodash';
import { useMemo } from 'react';
import { FieldType, GrafanaTheme2, LoadingState, PanelData, dateTime, makeTimeRange } from '@grafana/data';
import { Icon, Stack, Text, useStyles2 } from '@grafana/ui';
import { DataFrame, DataFrameJSON, GrafanaTheme2, TimeRange } from '@grafana/data';
import { Icon, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { CombinedRule } from 'app/types/unified-alerting';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { useCombinedRule } from '../../../hooks/useCombinedRule';
import { labelsMatchMatchers } from '../../../utils/alertmanager';
import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers';
import { parse } from '../../../utils/rule-id';
import { isGrafanaRulerRule } from '../../../utils/rules';
import { MetaText } from '../../MetaText';
import { VizWrapper } from '../../rule-editor/VizWrapper';
import { AnnotationValue } from '../../rule-viewer/tabs/Details';
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 { EventState } from './EventListSceneObject';
import { EventState, FilterType, LIMIT_EVENTS } from './EventListSceneObject';
import { HistoryErrorMessage } from './HistoryErrorMessage';
import { logRecordsToDataFrameForState } from './utils';
interface EventDetailsProps {
record: LogRecord;
logRecords: LogRecord[];
addFilter: (key: string, value: string, type: FilterType) => void;
timeRange: TimeRange;
}
export function EventDetails({ record, logRecords }: EventDetailsProps) {
export function EventDetails({ record, addFilter, timeRange }: EventDetailsProps) {
// get the rule from the ruleUID
const ruleUID = record.line?.ruleUID ?? '';
const labelsInInstance = record.line?.labels;
const identifier = useMemo(() => {
return parse(ruleUID, true);
}, [ruleUID]);
const { error, loading, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
const { error, loading, result: rule } = useCombinedRule({ ruleIdentifier: identifier, limitAlerts: 0 }); // we limit the alerts to 0 as we only need the rule
if (error) {
return (
@ -52,36 +60,144 @@ export function EventDetails({ record, logRecords }: EventDetailsProps) {
);
}
const getTransitionsCountByRuleUID = (ruleUID: string) => {
return logRecords.filter((record) => record.line.ruleUID === ruleUID).length;
};
return (
<Stack direction="column" gap={0.5}>
<Stack direction={'row'} gap={6}>
<StateTransition record={record} />
<StateTransition record={record} addFilter={addFilter} />
<ValueInTransition record={record} />
<NumberTransitions transitions={ruleUID ? getTransitionsCountByRuleUID(ruleUID) : 0} />
</Stack>
<Annotations rule={rule} />
<QueryVizualization rule={rule} ruleUID={ruleUID} logRecords={logRecords} />
<StateVisualization ruleUID={ruleUID} timeRange={timeRange} labels={labelsInInstance ?? {}} />
</Stack>
);
}
function useRuleHistoryRecordsForTheInstance(labelsForTheInstance: string, stateHistory?: DataFrameJSON) {
const theme = useTheme2();
return useMemo(() => {
// merge timestamp with "line"
const tsValues = stateHistory?.data?.values[0] ?? [];
const timestamps: number[] = isNumbers(tsValues) ? tsValues : [];
const lines = stateHistory?.data?.values[1] ?? [];
const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => {
const line = lines[index];
// values property can be undefined for some instance states (e.g. NoData)
if (isLine(line)) {
acc.push({ timestamp, line });
}
return acc;
}, []);
// group all records by alert instance (unique set of labels)
const logRecordsByInstance = groupBy(logRecords, (record: LogRecord) => {
return JSON.stringify(record.line.labels);
});
// filter by instance labels
const filterMatchers = parsePromQLStyleMatcherLooseSafe(labelsForTheInstance);
const filteredGroupedLines = Object.entries(logRecordsByInstance).filter(([key]) => {
const labels = JSON.parse(key);
return labelsMatchMatchers(labels, filterMatchers);
});
// Convert each group of log records to a DataFrame
const dataFrames: DataFrame[] = Object.values(filteredGroupedLines).map<DataFrame>((records) => {
// first element is the linstance labels, the second is the records list
return logRecordsToDataFrameForState(records[1], theme);
});
return {
dataFrames,
};
}, [stateHistory, labelsForTheInstance, theme]);
}
interface StateVisualizationProps {
ruleUID: string;
timeRange: TimeRange;
labels: Record<string, string>;
}
/**
* This component fetches the state history for the given ruleUID and time range, and displays the number of transitions and a State TimelineChart.
* Fetching the state history for the alert rule uid, and labels,
* makes the result to be more accurate, as it might be that we are not showing all the state transitions in the log records.
* @param ruleUID
* @param timeRange
* @param labels
* @returns
*/
function StateVisualization({ ruleUID, timeRange, labels }: StateVisualizationProps) {
const { useGetRuleHistoryQuery } = stateHistoryApi;
const {
currentData: stateHistory,
isLoading,
isError,
error,
} = useGetRuleHistoryQuery(
{
ruleUid: ruleUID,
from: timeRange.from.unix(),
to: timeRange.to.unix(),
limit: LIMIT_EVENTS,
},
{
refetchOnFocus: true,
refetchOnReconnect: true,
}
);
const { dataFrames } = useRuleHistoryRecordsForTheInstance(
labels
? Object.entries(labels)
.map(([key, value]) => `${key}=${value}`)
.join(',')
: '',
stateHistory
);
const { frameSubset, frameTimeRange } = useFrameSubset(dataFrames);
if (isLoading) {
return (
<div>
<Trans i18nKey="alerting.central-alert-history.details.loading">Loading...</Trans>
</div>
);
}
if (isError) {
return <HistoryErrorMessage error={error} />;
}
if (!frameSubset || frameSubset.length === 0) {
return null;
}
const numberOfTransitions = dataFrames[0]?.fields[0]?.values?.length - 1 ?? 0; // we subtract 1 as the first value is the initial state
return (
<>
<NumberTransitions transitions={ruleUID ? numberOfTransitions : 0} />
<LogTimelineViewer frames={frameSubset} timeRange={frameTimeRange} />
</>
);
}
interface StateTransitionProps {
record: LogRecord;
addFilter: (key: string, value: string, type: FilterType) => void;
}
function StateTransition({ record }: StateTransitionProps) {
function StateTransition({ record, addFilter }: StateTransitionProps) {
return (
<Stack gap={0.5} direction={'column'}>
<Text variant="body" weight="light" color="secondary">
<Trans i18nKey="alerting.central-alert-history.details.state-transitions">State transition</Trans>
</Text>
<Stack gap={0.5} direction={'row'} alignItems="center">
<EventState state={record.line.previous} showLabel />
<EventState state={record.line.previous} showLabel addFilter={addFilter} type="from" />
<Icon name="arrow-right" size="lg" />
<EventState state={record.line.current} showLabel />
<EventState state={record.line.current} showLabel addFilter={addFilter} type="to" />
</Stack>
</Stack>
);
@ -93,100 +209,25 @@ interface AnnotationsProps {
const Annotations = ({ rule }: AnnotationsProps) => {
const styles = useStyles2(getStyles);
const annotations = rule.annotations;
if (!annotations) {
if (!annotations || Object.keys(annotations).length === 0) {
return null;
}
return (
<>
<Text variant="body" color="secondary" weight="light">
<Trans i18nKey="alerting.central-alert-history.details.annotations">Annotations</Trans>
</Text>
{Object.keys(annotations).length === 0 ? (
<Text variant="body" weight="light" italic>
<Trans i18nKey="alerting.central-alert-history.details.no-annotations">No annotations</Trans>
</Text>
) : (
<div className={styles.metadataWrapper}>
{Object.entries(annotations).map(([name, value]) => (
<MetaText direction="column" key={name}>
{name}
<div className={styles.metadataWrapper}>
{Object.entries(annotations).map(([name, value]) => {
const capitalizedName = capitalize(name);
return (
<MetaText direction="column" key={capitalizedName}>
{capitalizedName}
<AnnotationValue value={value} />
</MetaText>
))}
</div>
)}
);
})}
</div>
</>
);
};
/**
*
* This component renders the visualization for the rule condition values over the selected time range.
* The visualization is a time series graph with the condition values on the y-axis and time on the x-axis.
* The values are extracted from the log records already fetched from the history api.
* The graph is rendered only if the rule is a Grafana rule.
*
*/
interface QueryVizualizationProps {
ruleUID: string;
rule: CombinedRule;
logRecords: LogRecord[];
}
const QueryVizualization = ({ ruleUID, rule, logRecords }: QueryVizualizationProps) => {
if (!isGrafanaRulerRule(rule?.rulerRule)) {
return (
<Text>
<Trans i18nKey="alerting.central-alert-history.details.not-grafana-rule">Rule is not a Grafana rule</Trans>
</Text>
);
}
// get the condition from the rule
const condition = rule?.rulerRule.grafana_alert?.condition ?? 'A';
// get the panel data for the rule
const panelData = getPanelDataForRule(ruleUID, logRecords, condition);
// render the visualization
return <VizWrapper data={panelData} thresholds={undefined} thresholdsType={undefined} />;
};
/**
* This function returns the time series panel data for the condtion values of the rule, within the selected time range.
* The values are extracted from the log records already fetched from the history api.
* @param ruleUID
* @param logRecords
* @param condition
* @returns PanelData
*/
export function getPanelDataForRule(ruleUID: string, logRecords: LogRecord[], condition: string) {
const ruleLogRecords = logRecords
.filter((record) => record.line.ruleUID === ruleUID)
// sort by timestamp as time series data is expected to be sorted by time
.sort((a, b) => a.timestamp - b.timestamp);
// get unique records by timestamp, as timeseries data should have unique timestamps, and it might be possible to have multiple records with the same timestamp
const uniqueRecords = uniqBy(ruleLogRecords, (record) => record.timestamp);
const timestamps = uniqueRecords.map((record) => record.timestamp);
const values = uniqueRecords.map((record) => (record.line.values ? record.line.values[condition] : 0));
const minTimestamp = min(timestamps);
const maxTimestamp = max(timestamps);
const PanelDataObj: PanelData = {
series: [
{
name: 'Rule condition history',
fields: [
{ name: 'Time', values: timestamps, config: {}, type: FieldType.time },
{ name: 'values', values: values, type: FieldType.number, config: {} },
],
length: timestamps.length,
},
],
state: LoadingState.Done,
timeRange: makeTimeRange(dateTime(minTimestamp), dateTime(maxTimestamp)),
};
return PanelDataObj;
}
interface ValueInTransitionProps {
record: LogRecord;
}
@ -211,17 +252,18 @@ interface NumberTransitionsProps {
transitions: number;
}
function NumberTransitions({ transitions }: NumberTransitionsProps) {
const styles = useStyles2(getStyles);
return (
<Stack gap={0.5} direction={'column'} alignItems="flex-start" justifyContent={'center'}>
<Text variant="body" weight="light" color="secondary">
<div className={styles.transitionsNumber}>
<Text variant="body" weight="bold" color="secondary">
<Trans i18nKey="alerting.central-alert-history.details.number-transitions">
State transitions for selected period
State transitions for selected period:
</Trans>
</Text>
<Text variant="body" weight="light">
{transitions}
</Text>
</Stack>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
@ -232,5 +274,12 @@ const getStyles = (theme: GrafanaTheme2) => {
rowGap: theme.spacing(3),
columnGap: theme.spacing(12),
}),
transitionsNumber: css({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(0.5),
alignItems: 'center',
marginTop: theme.spacing(1.5),
}),
};
};

View File

@ -1,12 +1,18 @@
import { css, cx } from '@emotion/css';
import { ReactElement, useMemo, useState } from 'react';
import { useLocation } from 'react-router';
import { useMeasure } from 'react-use';
import { DataFrameJSON, GrafanaTheme2, IconName, TimeRange } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, TextBoxVariable, VariableValue, sceneGraph } from '@grafana/scenes';
import {
CustomVariable,
SceneComponentProps,
SceneObjectBase,
TextBoxVariable,
VariableValue,
sceneGraph,
} from '@grafana/scenes';
import { Alert, Icon, LoadingBar, Pagination, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { Trans, t } from 'app/core/internationalization';
import {
GrafanaAlertStateWithReason,
@ -18,17 +24,18 @@ import {
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { usePagination } from '../../../hooks/usePagination';
import { labelsMatchMatchers } from '../../../utils/alertmanager';
import { combineMatcherStrings, labelsMatchMatchers } from '../../../utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers';
import { stringifyErrorLike } from '../../../utils/misc';
import { createUrl } from '../../../utils/url';
import { AlertLabels } from '../../AlertLabels';
import { CollapseToggle } from '../../CollapseToggle';
import { LogRecord } from '../state-history/common';
import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords';
import { LABELS_FILTER } from './CentralAlertHistoryScene';
import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues } from './CentralAlertHistoryScene';
import { EventDetails } from './EventDetails';
import { HistoryErrorMessage } from './HistoryErrorMessage';
export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level.
const PAGE_SIZE = 100;
@ -40,10 +47,19 @@ const PAGE_SIZE = 100;
* The list is filtered by the labels in the filter variable and by the time range variable in the scene graph.
*/
interface HistoryEventsListProps {
timeRange?: TimeRange;
valueInfilterTextBox: VariableValue;
timeRange: TimeRange;
valueInLabelFilter: VariableValue;
valueInStateToFilter: VariableValue;
valueInStateFromFilter: VariableValue;
addFilter: (key: string, value: string, type: FilterType) => void;
}
export const HistoryEventsList = ({ timeRange, valueInfilterTextBox }: HistoryEventsListProps) => {
export const HistoryEventsList = ({
timeRange,
valueInLabelFilter,
valueInStateToFilter,
valueInStateFromFilter,
addFilter,
}: HistoryEventsListProps) => {
const from = timeRange?.from.unix();
const to = timeRange?.to.unix();
@ -59,8 +75,10 @@ export const HistoryEventsList = ({ timeRange, valueInfilterTextBox }: HistoryEv
});
const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords(
stateHistory,
valueInfilterTextBox.toString()
valueInLabelFilter.toString(),
valueInStateToFilter.toString(),
valueInStateFromFilter.toString(),
stateHistory
);
const historyRecords = historyRecordsNotSorted.sort((a, b) => b.timestamp - a.timestamp);
@ -69,10 +87,23 @@ export const HistoryEventsList = ({ timeRange, valueInfilterTextBox }: HistoryEv
return <HistoryErrorMessage error={error} />;
}
const maximumEventsReached = !isLoading && stateHistory?.data?.values?.[0]?.length === LIMIT_EVENTS;
return (
<>
{maximumEventsReached && (
<Alert
severity="warning"
title={t('alerting.central-alert-history.too-many-events.title', 'Unable to display all events')}
>
{t(
'alerting.central-alert-history.too-many-events.text',
'The selected time period has too many events to display. Diplaying the latest 5000 events. Try using a shorter time period.'
)}
</Alert>
)}
<LoadingIndicator visible={isLoading} />
<HistoryLogEvents logRecords={historyRecords} />
<HistoryLogEvents logRecords={historyRecords} addFilter={addFilter} timeRange={timeRange} />
</>
);
};
@ -85,18 +116,22 @@ const LoadingIndicator = ({ visible = false }) => {
interface HistoryLogEventsProps {
logRecords: LogRecord[];
addFilter: (key: string, value: string, type: FilterType) => void;
timeRange: TimeRange;
}
function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
function HistoryLogEvents({ logRecords, addFilter, timeRange }: HistoryLogEventsProps) {
const { page, pageItems, numberOfPages, onPageChange } = usePagination(logRecords, 1, PAGE_SIZE);
return (
<Stack direction="column" gap={0}>
<ListHeader />
<ul>
{pageItems.map((record) => {
return (
<EventRow
key={record.timestamp + (record.line.fingerprint ?? '')}
record={record}
logRecords={logRecords}
addFilter={addFilter}
timeRange={timeRange}
/>
);
})}
@ -107,27 +142,48 @@ function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
);
}
interface HistoryErrorMessageProps {
error: unknown;
}
function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
if (isFetchError(error) && error.status === 404) {
return <EntityNotFound entity="History" />;
}
const title = t('alerting.central-alert-history.error', 'Something went wrong loading the alert state history');
const errorStr = stringifyErrorLike(error);
return <Alert title={title}>{errorStr}</Alert>;
function ListHeader() {
const styles = useStyles2(getStyles);
return (
<div className={styles.headerWrapper}>
<div className={styles.mainHeader}>
<div className={styles.timeCol}>
<Text variant="body">
<Trans i18nKey="alerting.central-alert-history.details.header.timestamp">Timestamp</Trans>
</Text>
</div>
<div className={styles.transitionCol}>
<Text variant="body">
<Trans i18nKey="alerting.central-alert-history.details.header.state">State</Trans>
</Text>
</div>
<div className={styles.alertNameCol}>
<Text variant="body">
<Trans i18nKey="alerting.central-alert-history.details.header.alert-rule">Alert rule</Trans>
</Text>
</div>
<div className={styles.labelsCol}>
<Text variant="body">
<Trans i18nKey="alerting.central-alert-history.details.header.instance">Instance</Trans>
</Text>
</div>
</div>
</div>
);
}
interface EventRowProps {
record: LogRecord;
logRecords: LogRecord[];
addFilter: (key: string, value: string, type: FilterType) => void;
timeRange: TimeRange;
}
function EventRow({ record, logRecords }: EventRowProps) {
function EventRow({ record, addFilter, timeRange }: EventRowProps) {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
function onLabelClick(label: string, value: string) {
addFilter(label, value, 'label');
}
return (
<Stack direction="column" gap={0}>
<div
@ -145,19 +201,19 @@ function EventRow({ record, logRecords }: EventRowProps) {
<Timestamp time={record.timestamp} />
</div>
<div className={styles.transitionCol}>
<EventTransition previous={record.line.previous} current={record.line.current} />
<EventTransition previous={record.line.previous} current={record.line.current} addFilter={addFilter} />
</div>
<div className={styles.alertNameCol}>
{record.line.labels ? <AlertRuleName labels={record.line.labels} ruleUID={record.line.ruleUID} /> : null}
</div>
<div className={styles.labelsCol}>
<AlertLabels labels={record.line.labels ?? {}} size="xs" />
<AlertLabels labels={record.line.labels ?? {}} size="xs" onClick={onLabelClick} />
</div>
</Stack>
</div>
{!isCollapsed && (
<div className={styles.expandedRow}>
<EventDetails record={record} logRecords={logRecords} />
<EventDetails record={record} addFilter={addFilter} timeRange={timeRange} />
</div>
)}
</Stack>
@ -170,21 +226,23 @@ interface AlertRuleNameProps {
}
function AlertRuleName({ labels, ruleUID }: AlertRuleNameProps) {
const styles = useStyles2(getStyles);
const { pathname, search } = useLocation();
const returnTo = `${pathname}${search}`;
const alertRuleName = labels['alertname'];
if (!ruleUID) {
return (
<Text>
<Trans i18nKey="alerting.central-alert-history.details.unknown-rule">Unknown</Trans>
{alertRuleName}
</Text>
);
}
const ruleViewUrl = createUrl(`/alerting/${GRAFANA_RULES_SOURCE_NAME}/${ruleUID}/view`, {
tab: 'history',
returnTo,
});
return (
<Tooltip content={alertRuleName ?? ''}>
<a
href={`/alerting/${GRAFANA_RULES_SOURCE_NAME}/${ruleUID}/view?returnTo=${encodeURIComponent('/alerting/history')}`}
className={styles.alertName}
>
<a href={ruleViewUrl} className={styles.alertName}>
{alertRuleName}
</a>
</Tooltip>
@ -194,13 +252,14 @@ function AlertRuleName({ labels, ruleUID }: AlertRuleNameProps) {
interface EventTransitionProps {
previous: GrafanaAlertStateWithReason;
current: GrafanaAlertStateWithReason;
addFilter: (key: string, value: string, type: FilterType) => void;
}
function EventTransition({ previous, current }: EventTransitionProps) {
function EventTransition({ previous, current, addFilter }: EventTransitionProps) {
return (
<Stack gap={0.5} direction={'row'}>
<EventState state={previous} />
<EventState state={previous} addFilter={addFilter} type="from" />
<Icon name="arrow-right" size="lg" />
<EventState state={current} />
<EventState state={current} addFilter={addFilter} type="to" />
</Stack>
);
}
@ -213,7 +272,7 @@ interface StateIconProps {
showLabel: boolean;
}
const StateIcon = ({ iconName, iconColor, tooltipContent, labelText, showLabel }: StateIconProps) => (
<Tooltip content={tooltipContent}>
<Tooltip content={tooltipContent} placement="top">
<Stack gap={0.5} direction={'row'} alignItems="center">
<Icon name={iconName} size="md" className={iconColor} />
{showLabel && (
@ -228,8 +287,10 @@ const StateIcon = ({ iconName, iconColor, tooltipContent, labelText, showLabel }
interface EventStateProps {
state: GrafanaAlertStateWithReason;
showLabel?: boolean;
addFilter: (key: string, value: string, type: FilterType) => void;
type: 'from' | 'to';
}
export function EventState({ state, showLabel = false }: EventStateProps) {
export function EventState({ state, showLabel = false, addFilter, type }: EventStateProps) {
const styles = useStyles2(getStyles);
const toolTip = t('alerting.central-alert-history.details.no-recognized-state', 'No recognized state');
if (!isGrafanaAlertState(state) && !isAlertStateWithReason(state)) {
@ -238,7 +299,7 @@ export function EventState({ state, showLabel = false }: EventStateProps) {
iconName="exclamation-triangle"
tooltipContent={toolTip}
labelText={<Trans i18nKey="alerting.central-alert-history.details.unknown-event-state">Unknown</Trans>}
showLabel={Boolean(showLabel)}
showLabel={showLabel}
iconColor={styles.warningColor}
/>
);
@ -286,9 +347,26 @@ export function EventState({ state, showLabel = false }: EventStateProps) {
labelText: <Trans i18nKey="alerting.central-alert-history.details.state.pending">Pending</Trans>,
},
};
function onStateClick() {
addFilter('state', baseState, type === 'from' ? 'stateFrom' : 'stateTo');
}
const config = stateConfig[baseState] || { iconName: 'exclamation-triangle', tooltipContent: 'Unknown State' };
return <StateIcon {...config} showLabel={showLabel} />;
return (
<div
onClick={onStateClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onStateClick();
}
}}
className={styles.state}
role="button"
tabIndex={0}
>
<StateIcon {...config} showLabel={showLabel} />
</div>
);
}
interface TimestampProps {
@ -382,6 +460,30 @@ export const getStyles = (theme: GrafanaTheme2) => {
marginLeft: theme.spacing(2),
borderLeft: `1px solid ${theme.colors.border.weak}`,
}),
colorIcon: css({
color: theme.colors.primary.text,
'&:hover': {
opacity: 0.8,
},
}),
state: css({
'&:hover': {
opacity: 0.8,
cursor: 'pointer',
},
}),
headerWrapper: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
mainHeader: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'nowrap',
marginLeft: '30px',
padding: `${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0`,
gap: theme.spacing(0.5),
}),
};
};
@ -396,24 +498,65 @@ export class HistoryEventsListObject extends SceneObjectBase {
}
}
export type FilterType = 'label' | 'stateFrom' | 'stateTo';
export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps<HistoryEventsListObject>) {
const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph
const filtersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model)!;
// eslint-disable-next-line
const labelsFiltersVariable = sceneGraph.lookupVariable(LABELS_FILTER, model)! as TextBoxVariable;
// eslint-disable-next-line
const stateToFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_TO, model)! as CustomVariable;
// eslint-disable-next-line
const stateFromFilterVariable = sceneGraph.lookupVariable(STATE_FILTER_FROM, model)! as CustomVariable;
const valueInfilterTextBox: VariableValue = !(filtersVariable instanceof TextBoxVariable)
? ''
: filtersVariable.getValue();
const valueInfilterTextBox: VariableValue = labelsFiltersVariable.getValue();
const valueInStateToFilter = stateToFilterVariable.getValue();
const valueInStateFromFilter = stateFromFilterVariable.getValue();
return <HistoryEventsList timeRange={timeRange} valueInfilterTextBox={valueInfilterTextBox} />;
const addFilter = (key: string, value: string, type: FilterType) => {
const newFilterToAdd = `${key}=${value}`;
if (type === 'stateTo') {
stateToFilterVariable.changeValueTo(value);
}
if (type === 'stateFrom') {
stateFromFilterVariable.changeValueTo(value);
}
if (type === 'label') {
const finalFilter = combineMatcherStrings(valueInfilterTextBox.toString(), newFilterToAdd);
labelsFiltersVariable.setValue(finalFilter);
}
};
return (
<HistoryEventsList
timeRange={timeRange}
valueInLabelFilter={valueInfilterTextBox}
addFilter={addFilter}
valueInStateToFilter={valueInStateToFilter}
valueInStateFromFilter={valueInStateFromFilter}
/>
);
}
function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) {
/**
* This hook filters the history records based on the label, stateTo and stateFrom filters.
* @param filterInLabel
* @param filterInStateTo
* @param filterInStateFrom
* @param stateHistory the original history records
* @returns the filtered history records
*/
function useRuleHistoryRecords(
filterInLabel: string,
filterInStateTo: string,
filterInStateFrom: string,
stateHistory?: DataFrameJSON
) {
return useMemo(() => {
if (!stateHistory?.data) {
return { historyRecords: [] };
}
const filterMatchers = filter ? parsePromQLStyleMatcherLooseSafe(filter) : [];
const filterMatchers = filterInLabel ? parsePromQLStyleMatcherLooseSafe(filterInLabel) : [];
const [tsValues, lines] = stateHistory.data.values;
const timestamps = isNumbers(tsValues) ? tsValues : [];
@ -424,10 +567,16 @@ function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) {
if (!isLine(line)) {
return acc;
}
// values property can be undefined for some instance states (e.g. NoData)
const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers);
if (filterMatch) {
if (!isGrafanaAlertState(line.current) || !isGrafanaAlertState(line.previous)) {
return acc;
}
const baseStateTo = mapStateWithReasonToBaseState(line.current);
const baseStateFrom = mapStateWithReasonToBaseState(line.previous);
const stateToMatch = filterInStateTo !== StateFilterValues.all ? filterInStateTo === baseStateTo : true;
const stateFromMatch = filterInStateFrom !== StateFilterValues.all ? filterInStateFrom === baseStateFrom : true;
if (filterMatch && stateToMatch && stateFromMatch) {
acc.push({ timestamp, line });
}
@ -437,5 +586,5 @@ function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) {
return {
historyRecords: logRecords,
};
}, [stateHistory, filter]);
}, [stateHistory, filterInLabel, filterInStateTo, filterInStateFrom]);
}

View File

@ -0,0 +1,20 @@
import { isFetchError } from '@grafana/runtime';
import { Alert } from '@grafana/ui';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { t } from 'app/core/internationalization';
import { stringifyErrorLike } from '../../../utils/misc';
export interface HistoryErrorMessageProps {
error: unknown;
}
export function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
if (isFetchError(error) && error.status === 404) {
return <EntityNotFound entity="History" />;
}
const title = t('alerting.central-alert-history.error', 'Something went wrong loading the alert state history');
const errorStr = stringifyErrorLike(error);
return <Alert title={title}>{errorStr}</Alert>;
}

View File

@ -1,8 +1,11 @@
import { render, waitFor } from 'test/test-utils';
import { byLabelText, byTestId } from 'testing-library-selector';
import { getDefaultTimeRange } from '@grafana/data';
import { setupMswServer } from '../../../mockApi';
import { StateFilterValues } from './CentralAlertHistoryScene';
import { HistoryEventsList } from './EventListSceneObject';
setupMswServer();
@ -12,12 +15,36 @@ setupMswServer();
const ui = {
rowHeader: byTestId('event-row-header'),
loadingBar: byLabelText('Loading bar'),
};
describe('HistoryEventsList', () => {
it('should render the list correctly filtered by label in filter variable', async () => {
render(<HistoryEventsList valueInfilterTextBox={'alertname=alert1'} />);
it('should render the full list correctly when no filters are applied', async () => {
render(
<HistoryEventsList
valueInLabelFilter={''}
valueInStateToFilter={StateFilterValues.all}
valueInStateFromFilter={StateFilterValues.all}
addFilter={jest.fn()}
timeRange={getDefaultTimeRange()}
/>
);
await waitFor(() => {
expect(byLabelText('Loading bar').query()).not.toBeInTheDocument();
expect(ui.loadingBar.query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.getAll()).toHaveLength(4);
});
it('should render the list correctly filtered by label in filter variable', async () => {
render(
<HistoryEventsList
valueInLabelFilter={'alertname=alert1'}
valueInStateToFilter={StateFilterValues.all}
valueInStateFromFilter={StateFilterValues.all}
addFilter={jest.fn()}
timeRange={getDefaultTimeRange()}
/>
);
await waitFor(() => {
expect(ui.loadingBar.query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.getAll()).toHaveLength(2); // 2 events for alert1
expect(ui.rowHeader.getAll()[0]).toHaveTextContent(
@ -27,4 +54,98 @@ describe('HistoryEventsList', () => {
'June 14 at 06:38:30alert1alertnamealert1grafana_folderFOLDER Ahandler/alerting/*'
);
});
it('should render the list correctly filtered by from and to state ', async () => {
render(
<HistoryEventsList
valueInLabelFilter={''}
valueInStateFromFilter={StateFilterValues.firing}
valueInStateToFilter={StateFilterValues.normal}
addFilter={jest.fn()}
timeRange={getDefaultTimeRange()}
/>
);
await waitFor(() => {
expect(ui.loadingBar.query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.getAll()).toHaveLength(1);
expect(ui.rowHeader.getAll()[0]).toHaveTextContent(
'June 14 at 06:38:30alert2alertnamealert2grafana_folderFOLDER Ahandler/alerting/*'
);
});
it('should render the list correctly filtered by state to', async () => {
render(
<HistoryEventsList
valueInLabelFilter={''}
valueInStateToFilter={StateFilterValues.firing}
valueInStateFromFilter={StateFilterValues.all}
addFilter={jest.fn()}
timeRange={getDefaultTimeRange()}
/>
);
await waitFor(() => {
expect(ui.loadingBar.query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.getAll()).toHaveLength(2);
expect(ui.rowHeader.getAll()[0]).toHaveTextContent(
'June 14 at 06:39:00alert2alertnamealert2grafana_folderFOLDER Ahandler/alerting/*'
);
expect(ui.rowHeader.getAll()[1]).toHaveTextContent(
'June 14 at 06:38:30alert1alertnamealert1grafana_folderFOLDER Ahandler/alerting/*'
);
});
it('should render the list correctly filtered by state from', async () => {
render(
<HistoryEventsList
valueInLabelFilter={''}
valueInStateFromFilter={StateFilterValues.firing}
valueInStateToFilter={StateFilterValues.all}
addFilter={jest.fn()}
timeRange={getDefaultTimeRange()}
/>
);
await waitFor(() => {
expect(ui.loadingBar.query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.getAll()).toHaveLength(1);
expect(ui.rowHeader.getAll()[0]).toHaveTextContent(
'June 14 at 06:38:30alert2alertnamealert2grafana_folderFOLDER Ahandler/alerting/*'
);
});
it('should render the list correctly filtered by label and state to', async () => {
render(
<HistoryEventsList
valueInLabelFilter={'alertname=alert1'}
valueInStateToFilter={StateFilterValues.firing}
valueInStateFromFilter={StateFilterValues.all}
addFilter={jest.fn()}
timeRange={getDefaultTimeRange()}
/>
);
await waitFor(() => {
expect(ui.loadingBar.query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.getAll()).toHaveLength(1);
expect(ui.rowHeader.getAll()[0]).toHaveTextContent(
'June 14 at 06:38:30alert1alertnamealert1grafana_folderFOLDER Ahandler/alerting/*'
);
});
it('should handle an empty list when no events match the filter criteria', async () => {
render(
<HistoryEventsList
valueInLabelFilter={'nonexistentlabel=xyz'}
valueInStateToFilter={StateFilterValues.all}
valueInStateFromFilter={StateFilterValues.all}
addFilter={jest.fn()}
timeRange={getDefaultTimeRange()}
/>
);
await waitFor(() => {
expect(ui.loadingBar.query()).not.toBeInTheDocument();
});
expect(ui.rowHeader.query()).not.toBeInTheDocument();
});
});

View File

@ -1,53 +0,0 @@
import { dateTime } from '@grafana/data';
import { LogRecord } from '../state-history/common';
import { getPanelDataForRule } from './EventDetails';
const initialTimeStamp = 1000000;
const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; // actually, it doesn't matter what is here
const records: LogRecord[] = [
{
timestamp: initialTimeStamp,
line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 1 } },
},
{
timestamp: initialTimeStamp + 1000,
line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID2' },
},
{
timestamp: initialTimeStamp + 2000,
line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID3' },
},
// not sorted by timestamp
{
timestamp: initialTimeStamp + 4000,
line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 8 } },
},
{
timestamp: initialTimeStamp + 3000,
line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } },
},
//duplicate record in the same timestamp
{
timestamp: initialTimeStamp + 3000,
line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } },
},
{
timestamp: initialTimeStamp + 5000,
line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } },
},
];
describe('getPanelDataForRule', () => {
it('should return correct panel data for a given rule (sorted by time and unique)', () => {
const result = getPanelDataForRule('ruleUID1', records, 'C');
expect(result.series[0].fields[0].values).toEqual([1000000, 1003000, 1004000, 1005000]);
expect(result.series[0].fields[1].values).toEqual([1, 0, 8, 0]);
expect(result.series[0].fields[0].type).toEqual('time');
expect(result.series[0].fields[1].type).toEqual('number');
expect(result.state).toEqual('Done');
expect(result.timeRange.from).toEqual(dateTime(1000000));
expect(result.timeRange.to).toEqual(dateTime(1005000));
});
});

View File

@ -1,33 +1,64 @@
import { groupBy } from 'lodash';
import { DataFrame, Field as DataFrameField, DataFrameJSON, Field, FieldType } from '@grafana/data';
import {
DataFrame,
Field as DataFrameField,
DataFrameJSON,
Field,
FieldType,
GrafanaTheme2,
MappingType,
ThresholdsMode,
getDisplayProcessor,
} from '@grafana/data';
import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers';
import { isGrafanaAlertState, 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 { LABELS_FILTER } from './CentralAlertHistoryScene';
import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues } from './CentralAlertHistoryScene';
const GROUPING_INTERVAL = 10 * 1000; // 10 seconds
const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL
/*
* This function is used to convert the history response to a DataFrame list and filter the data by labels.
* This function is used to convert the history response to a DataFrame list and filter the data by labels and states
* The response is a list of log records, each log record has a timestamp and a line.
* 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 in the groupDataFramesByTime function.
* This allows us to be able to filter by labels and states in the groupDataFramesByTime function.
*/
export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] {
// Get the labels and states filters from the URL
const stateToInQueryParams = getStateFilterToInQueryParams();
const stateFromInQueryParams = getStateFilterFromInQueryParams();
const stateToFilterValue = stateToInQueryParams === '' ? StateFilterValues.all : stateToInQueryParams;
const stateFromFilterValue = stateFromInQueryParams === '' ? StateFilterValues.all : stateFromInQueryParams;
// Extract timestamps and lines from the response
const tsValues = data?.data?.values[0] ?? [];
const timestamps: number[] = isNumbers(tsValues) ? tsValues : [];
const lines = data?.data?.values[1] ?? [];
// Filter log records by state and create a list of log records with the timestamp and line
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 });
if (!isGrafanaAlertState(line.current)) {
return acc;
}
// we have to filter out by state at that point , because we are going to group by timestamp and these states are going to be lost
const baseStateTo = mapStateWithReasonToBaseState(line.current);
const baseStateFrom = mapStateWithReasonToBaseState(line.previous);
const stateToMatch = stateToFilterValue !== StateFilterValues.all ? stateToFilterValue === baseStateTo : true;
const stateFromMatch =
stateFromFilterValue !== StateFilterValues.all ? stateFromFilterValue === baseStateFrom : true;
// filter by state
if (stateToMatch && stateFromMatch) {
acc.push({ timestamp, line });
}
}
return acc;
}, []);
@ -48,21 +79,30 @@ export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] {
}
// Scenes sync variables in the URL adding a prefix to the variable name.
function getFilterInQueryParams() {
function getLabelsFilterInQueryParams() {
const queryParams = new URLSearchParams(window.location.search);
return queryParams.get(`${QUERY_PARAM_PREFIX}${LABELS_FILTER}`) ?? '';
}
function getStateFilterToInQueryParams() {
const queryParams = new URLSearchParams(window.location.search);
return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_TO}`) ?? '';
}
function getStateFilterFromInQueryParams() {
const queryParams = new URLSearchParams(window.location.search);
return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_FROM}`) ?? '';
}
/*
* This function groups the data frames by time and filters them by labels.
* The interval is set to 10 seconds.
* */
function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[]): DataFrame[] {
// Filter data frames by labels. This is used to filter out the data frames that do not match the query.
const filterValue = getFilterInQueryParams();
const labelsFilterValue = getLabelsFilterInQueryParams();
const dataframesFiltered = dataFrames.filter((frame) => {
const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified
const matchers = Boolean(filterValue) ? parsePromQLStyleMatcherLooseSafe(filterValue) : [];
const matchers = Boolean(labelsFilterValue) ? parsePromQLStyleMatcherLooseSafe(labelsFilterValue) : [];
return labelsMatchMatchers(labels, matchers);
});
// Extract time fields from filtered data frames
@ -137,3 +177,77 @@ function logRecordsToDataFrame(instanceLabels: string, records: LogRecord[]): Da
return frame;
}
/*
* This function is used to convert the log records to a DataFrame.
* The DataFrame has two fields: time and value.
* The time field is the timestamp of the log record.
* The value field is the state of the log record.
* The state is converted to a string and color is assigned based on the state.
* The state can be Alerting, Pending, Normal, or NoData.
*
* */
export function logRecordsToDataFrameForState(records: LogRecord[], theme: GrafanaTheme2): DataFrame {
const timeField: DataFrameField = {
name: 'time',
type: FieldType.time,
values: [...records.map((record) => record.timestamp), Date.now()],
config: { displayName: 'Time', custom: { fillOpacity: 100 } },
};
// Sort time field values
const timeIndex = timeField.values.map((_, index) => index);
timeIndex.sort(fieldIndexComparer(timeField));
const stateValues = [...records.map((record) => record.line.current), records.at(-1)?.line.current];
// Create DataFrame with time and value fields
const frame: DataFrame = {
fields: [
{
...timeField,
values: timeField.values.map((_, i) => timeField.values[timeIndex[i]]),
},
{
name: 'State',
type: FieldType.string,
values: stateValues.map((_, i) => stateValues[timeIndex[i]]),
config: {
displayName: 'State',
color: { mode: 'thresholds' },
custom: { fillOpacity: 100 },
mappings: [
{
type: MappingType.ValueToText,
options: {
Alerting: {
color: theme.colors.error.main,
},
Pending: {
color: theme.colors.warning.main,
},
Normal: {
color: theme.colors.success.main,
},
NoData: {
color: theme.colors.info.main,
},
},
},
],
thresholds: {
mode: ThresholdsMode.Absolute,
steps: [],
},
},
},
],
length: timeField.values.length,
name: '',
};
frame.fields.forEach((field) => {
field.display = getDisplayProcessor({ field, theme });
});
return frame;
}

View File

@ -40,8 +40,8 @@ export const LogTimelineViewer = memo(({ frames, timeRange }: LogTimelineViewerP
legendItems={[
{ label: 'Normal', color: theme.colors.success.main, yAxis: 1 },
{ label: 'Pending', color: theme.colors.warning.main, yAxis: 1 },
{ label: 'Alerting', color: theme.colors.error.main, yAxis: 1 },
{ label: 'NoData', color: theme.colors.info.main, yAxis: 1 },
{ label: 'Firing', color: theme.colors.error.main, yAxis: 1 },
{ label: 'No Data', color: theme.colors.info.main, yAxis: 1 },
{ label: 'Mixed', color: theme.colors.text.secondary, yAxis: 1 },
]}
replaceVariables={replaceVariables}

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { fromPairs, isEmpty, sortBy, take, uniq } from 'lodash';
import { useCallback, useMemo, useRef, useState } from 'react';
import * as React from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { DataFrame, dateTime, GrafanaTheme2, TimeRange } from '@grafana/data';
@ -149,7 +149,7 @@ const LokiStateHistory = ({ ruleUID }: Props) => {
);
};
function useFrameSubset(frames: DataFrame[]) {
export function useFrameSubset(frames: DataFrame[]) {
return useMemo(() => {
const frameSubset = take(frames, MAX_TIMELINE_SERIES);
const frameSubsetTimestamps = sortBy(uniq(frameSubset.flatMap((frame) => frame.fields[0].values)));

View File

@ -25,7 +25,7 @@ describe('logRecordsToDataFrame', () => {
expect(timeField.name).toBe('time');
expect(timeField.type).toBe(FieldType.time);
expect(stateChangeField.name).toBe('state');
expect(stateChangeField.name).toBe('State');
expect(stateChangeField.type).toBe(FieldType.string);
// There should be an artificial element at the end meaning Date.now()
// It exist to draw the state change from when it happened to the current time

View File

@ -3,8 +3,8 @@ import { useMemo } from 'react';
import {
DataFrame,
DataFrameJSON,
Field as DataFrameField,
DataFrameJSON,
FieldType,
getDisplayProcessor,
GrafanaTheme2,
@ -109,7 +109,7 @@ export function logRecordsToDataFrame(
values: timeField.values.map((_, i) => timeField.values[timeIndex[i]]),
},
{
name: 'state',
name: 'State',
type: FieldType.string,
values: stateValues.map((_, i) => stateValues[timeIndex[i]]),
config: {

View File

@ -142,8 +142,8 @@ export const getHistoryResponse = (times: number[]) => ({
},
{
schemaVersion: 1,
previous: 'Pending',
current: 'Alerting',
previous: 'Alerting',
current: 'Normal',
value: {
A: 1,
B: 1,
@ -152,7 +152,7 @@ export const getHistoryResponse = (times: number[]) => ({
condition: 'C',
dashboardUID: '',
panelID: 0,
fingerprint: '141da2d491f61029',
fingerprint: '141da2d491f61030',
ruleTitle: 'alert2',
ruleID: 3,
ruleUID: 'adna1xso80hdsd',
@ -164,8 +164,8 @@ export const getHistoryResponse = (times: number[]) => ({
},
{
schemaVersion: 1,
previous: 'Pending',
current: 'Alerting',
previous: 'Normal',
current: 'Pending',
value: {
A: 1,
B: 1,
@ -175,7 +175,7 @@ export const getHistoryResponse = (times: number[]) => ({
dashboardUID: '',
panelID: 0,
fingerprint: '141da2d491f61029',
fingerprint: '141da2d491f61031',
ruleTitle: 'alert1',
ruleID: 7,
ruleUID: 'adnpo0g62bg1sb',

View File

@ -58,15 +58,18 @@
"alerting": {
"central-alert-history": {
"details": {
"annotations": "Annotations",
"error": "Error loading rule for this event.",
"header": {
"alert-rule": "Alert rule",
"instance": "Instance",
"state": "State",
"timestamp": "Timestamp"
},
"loading": "Loading...",
"no-annotations": "No annotations",
"no-recognized-state": "No recognized state",
"no-values": "No values",
"not-found": "Rule not found for this event.",
"not-grafana-rule": "Rule is not a Grafana rule",
"number-transitions": "State transitions for selected period",
"number-transitions": "State transitions for selected period:",
"state": {
"alerting": "Alerting",
"error": "Error",
@ -81,12 +84,18 @@
},
"error": "Something went wrong loading the alert state history",
"filter": {
"clear": "Clear filters",
"info": {
"label1": "Filter events using label querying without spaces, ex:",
"label2": "Invalid use of spaces:",
"label3": "Valid use of spaces:",
"label4": "Filter alerts using label querying without braces, ex:"
}
},
"filterBy": "Filter by:",
"too-many-events": {
"text": "The selected time period has too many events to display. Diplaying the latest 5000 events. Try using a shorter time period.",
"title": "Unable to display all events"
}
},
"contact-points": {

View File

@ -58,15 +58,18 @@
"alerting": {
"central-alert-history": {
"details": {
"annotations": "Åʼnʼnőŧäŧįőʼnş",
"error": "Ēřřőř ľőäđįʼnģ řūľę ƒőř ŧĥįş ęvęʼnŧ.",
"header": {
"alert-rule": "Åľęřŧ řūľę",
"instance": "Ĩʼnşŧäʼnčę",
"state": "Ŝŧäŧę",
"timestamp": "Ŧįmęşŧämp"
},
"loading": "Ŀőäđįʼnģ...",
"no-annotations": "Ńő äʼnʼnőŧäŧįőʼnş",
"no-recognized-state": "Ńő řęčőģʼnįžęđ şŧäŧę",
"no-values": "Ńő väľūęş",
"not-found": "Ŗūľę ʼnőŧ ƒőūʼnđ ƒőř ŧĥįş ęvęʼnŧ.",
"not-grafana-rule": "Ŗūľę įş ʼnőŧ ä Ğřäƒäʼnä řūľę",
"number-transitions": "Ŝŧäŧę ŧřäʼnşįŧįőʼnş ƒőř şęľęčŧęđ pęřįőđ",
"number-transitions": "Ŝŧäŧę ŧřäʼnşįŧįőʼnş ƒőř şęľęčŧęđ pęřįőđ:",
"state": {
"alerting": "Åľęřŧįʼnģ",
"error": "Ēřřőř",
@ -81,12 +84,18 @@
},
"error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
"filter": {
"clear": "Cľęäř ƒįľŧęřş",
"info": {
"label1": "Fįľŧęř ęvęʼnŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ şpäčęş, ęχ:",
"label2": "Ĩʼnväľįđ ūşę őƒ şpäčęş:",
"label3": "Väľįđ ūşę őƒ şpäčęş:",
"label4": "Fįľŧęř äľęřŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ þřäčęş, ęχ:"
}
},
"filterBy": "Fįľŧęř þy:",
"too-many-events": {
"text": "Ŧĥę şęľęčŧęđ ŧįmę pęřįőđ ĥäş ŧőő mäʼny ęvęʼnŧş ŧő đįşpľäy. Đįpľäyįʼnģ ŧĥę ľäŧęşŧ 5000 ęvęʼnŧş. Ŧřy ūşįʼnģ ä şĥőřŧęř ŧįmę pęřįőđ.",
"title": "Ůʼnäþľę ŧő đįşpľäy äľľ ęvęʼnŧş"
}
},
"contact-points": {