mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
92ada4eb7c
commit
c76b490c57
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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)));
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
Loading…
Reference in New Issue
Block a user