mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Central alert state history part3 (#89842)
* 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 * prettier and extract translations * remove unnecessary translations * update translations * address review comments * address review pr comments
This commit is contained in:
parent
b174c1310a
commit
7f4a1469e8
@ -140,7 +140,7 @@ interface AnnotationValueProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function AnnotationValue({ value }: AnnotationValueProps) {
|
||||
export function AnnotationValue({ value }: AnnotationValueProps) {
|
||||
const needsExternalLink = value && value.startsWith('http');
|
||||
const tokenizeValue = <Tokenize input={value} delimiter={['{{', '}}']} />;
|
||||
|
||||
|
@ -64,7 +64,12 @@ export const CentralAlertHistoryScene = () => {
|
||||
new SceneTimePicker({}),
|
||||
new SceneRefreshPicker({}),
|
||||
],
|
||||
$timeRange: new SceneTimeRange({}), //needed for using the time range sync in the url
|
||||
// use default time range as from 1 hour ago to now, as the limit of the history api is 5000 events,
|
||||
// and using a wider time range might lead to showing gaps in the events list and the chart.
|
||||
$timeRange: new SceneTimeRange({
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
}),
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [filterVariable],
|
||||
}),
|
||||
@ -170,16 +175,16 @@ export const FilterInfo = () => {
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label1">
|
||||
<Trans i18nKey="alerting.central-alert-history.filter.info.label1">
|
||||
Filter events using label querying without spaces, ex:
|
||||
</Trans>
|
||||
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label2">Invalid use of spaces:</Trans>
|
||||
<pre>{`{severity= "critical"}`}</pre>
|
||||
<Trans i18nKey="alerting.central-alert-history.filter.info.label2">Invalid use of spaces:</Trans>
|
||||
<pre>{`{severity= "alerting.critical"}`}</pre>
|
||||
<pre>{`{severity ="critical"}`}</pre>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label3">Valid use of spaces:</Trans>
|
||||
<Trans i18nKey="alerting.central-alert-history.filter.info.label3">Valid use of spaces:</Trans>
|
||||
<pre>{`{severity=" critical"}`}</pre>
|
||||
<Trans i18nKey="central-alert-history.filter.info.label4">
|
||||
<Trans i18nKey="alerting.central-alert-history.filter.info.label4">
|
||||
Filter alerts using label querying without braces, ex:
|
||||
</Trans>
|
||||
<pre>{`severity="critical", instance=~"cluster-us-.+"`}</pre>
|
||||
|
@ -0,0 +1,236 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { max, min, uniqBy } 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 { Trans, t } from 'app/core/internationalization';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { useCombinedRule } from '../../../hooks/useCombinedRule';
|
||||
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 { LogRecord } from '../state-history/common';
|
||||
|
||||
import { EventState } from './EventListSceneObject';
|
||||
|
||||
interface EventDetailsProps {
|
||||
record: LogRecord;
|
||||
logRecords: LogRecord[];
|
||||
}
|
||||
export function EventDetails({ record, logRecords }: EventDetailsProps) {
|
||||
// get the rule from the ruleUID
|
||||
const ruleUID = record.line?.ruleUID ?? '';
|
||||
const identifier = useMemo(() => {
|
||||
return parse(ruleUID, true);
|
||||
}, [ruleUID]);
|
||||
const { error, loading, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Text>
|
||||
<Trans i18nKey="alerting.central-alert-history.details.error">Error loading rule for this event.</Trans>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<Text>
|
||||
<Trans i18nKey="alerting.central-alert-history.details.loading">Loading...</Trans>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rule) {
|
||||
return (
|
||||
<Text>
|
||||
<Trans i18nKey="alerting.central-alert-history.details.not-found">Rule not found for this event.</Trans>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
<ValueInTransition record={record} />
|
||||
<NumberTransitions transitions={ruleUID ? getTransitionsCountByRuleUID(ruleUID) : 0} />
|
||||
</Stack>
|
||||
<Annotations rule={rule} />
|
||||
<QueryVizualization rule={rule} ruleUID={ruleUID} logRecords={logRecords} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface StateTransitionProps {
|
||||
record: LogRecord;
|
||||
}
|
||||
function StateTransition({ record }: 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 />
|
||||
<Icon name="arrow-right" size="lg" />
|
||||
<EventState state={record.line.current} showLabel />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnnotationsProps {
|
||||
rule: CombinedRule;
|
||||
}
|
||||
const Annotations = ({ rule }: AnnotationsProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const annotations = rule.annotations;
|
||||
if (!annotations) {
|
||||
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}
|
||||
<AnnotationValue value={value} />
|
||||
</MetaText>
|
||||
))}
|
||||
</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;
|
||||
}
|
||||
function ValueInTransition({ record }: ValueInTransitionProps) {
|
||||
const values = record?.line?.values
|
||||
? JSON.stringify(record.line.values)
|
||||
: t('alerting.central-alert-history.details.no-values', 'No values');
|
||||
return (
|
||||
<Stack gap={0.5} direction={'column'}>
|
||||
<Text variant="body" weight="light" color="secondary">
|
||||
<Trans i18nKey="alerting.central-alert-history.details.value-in-transition">Value in transition</Trans>
|
||||
</Text>
|
||||
<Stack gap={0.5} direction={'row'} alignItems="center">
|
||||
<Text variant="body" weight="light">
|
||||
{values}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
interface NumberTransitionsProps {
|
||||
transitions: number;
|
||||
}
|
||||
function NumberTransitions({ transitions }: NumberTransitionsProps) {
|
||||
return (
|
||||
<Stack gap={0.5} direction={'column'} alignItems="flex-start" justifyContent={'center'}>
|
||||
<Text variant="body" weight="light" color="secondary">
|
||||
<Trans i18nKey="alerting.central-alert-history.details.number-transitions">
|
||||
State transitions for selected period
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text variant="body" weight="light">
|
||||
{transitions}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
metadataWrapper: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto auto',
|
||||
rowGap: theme.spacing(3),
|
||||
columnGap: theme.spacing(12),
|
||||
}),
|
||||
};
|
||||
};
|
@ -1,13 +1,13 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { ReactElement, useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { DataFrameJSON, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { DataFrameJSON, GrafanaTheme2, IconName, TimeRange } from '@grafana/data';
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase, TextBoxVariable, VariableValue, sceneGraph } from '@grafana/scenes';
|
||||
import { Alert, Icon, LoadingBar, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { Alert, Icon, LoadingBar, Pagination, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui';
|
||||
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import {
|
||||
GrafanaAlertStateWithReason,
|
||||
isAlertStateWithReason,
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { stateHistoryApi } from '../../../api/stateHistoryApi';
|
||||
import { usePagination } from '../../../hooks/usePagination';
|
||||
import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
import { stringifyErrorLike } from '../../../utils/misc';
|
||||
@ -26,8 +27,10 @@ import { LogRecord } from '../state-history/common';
|
||||
import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords';
|
||||
|
||||
import { LABELS_FILTER } from './CentralAlertHistoryScene';
|
||||
import { EventDetails } from './EventDetails';
|
||||
|
||||
export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level.
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
*
|
||||
@ -35,13 +38,11 @@ export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE leve
|
||||
* It fetches the events from the history api and displays them in a list.
|
||||
* The list is filtered by the labels in the filter variable and by the time range variable in the scene graph.
|
||||
*/
|
||||
export const HistoryEventsList = ({
|
||||
timeRange,
|
||||
valueInfilterTextBox,
|
||||
}: {
|
||||
interface HistoryEventsListProps {
|
||||
timeRange?: TimeRange;
|
||||
valueInfilterTextBox: VariableValue;
|
||||
}) => {
|
||||
}
|
||||
export const HistoryEventsList = ({ timeRange, valueInfilterTextBox }: HistoryEventsListProps) => {
|
||||
const from = timeRange?.from.unix();
|
||||
const to = timeRange?.to.unix();
|
||||
|
||||
@ -85,12 +86,23 @@ interface HistoryLogEventsProps {
|
||||
logRecords: LogRecord[];
|
||||
}
|
||||
function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) {
|
||||
const { page, pageItems, numberOfPages, onPageChange } = usePagination(logRecords, 1, PAGE_SIZE);
|
||||
return (
|
||||
<ul>
|
||||
{logRecords.map((record) => {
|
||||
return <EventRow key={record.timestamp + (record.line.fingerprint ?? '')} record={record} />;
|
||||
})}
|
||||
</ul>
|
||||
<Stack direction="column" gap={0}>
|
||||
<ul>
|
||||
{pageItems.map((record) => {
|
||||
return (
|
||||
<EventRow
|
||||
key={record.timestamp + (record.line.fingerprint ?? '')}
|
||||
record={record}
|
||||
logRecords={logRecords}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* This paginations improves the performance considerably , making the page load faster */}
|
||||
<Pagination currentPage={page} numberOfPages={numberOfPages} onNavigate={onPageChange} hideWhenSinglePage />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@ -102,17 +114,25 @@ function HistoryErrorMessage({ error }: HistoryErrorMessageProps) {
|
||||
if (isFetchError(error) && error.status === 404) {
|
||||
return <EntityNotFound entity="History" />;
|
||||
}
|
||||
const title = t('central-alert-history.error', 'Something went wrong loading the alert state 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}>{stringifyErrorLike(error)}</Alert>;
|
||||
return <Alert title={title}>{errorStr}</Alert>;
|
||||
}
|
||||
|
||||
function EventRow({ record }: { record: LogRecord }) {
|
||||
interface EventRowProps {
|
||||
record: LogRecord;
|
||||
logRecords: LogRecord[];
|
||||
}
|
||||
function EventRow({ record, logRecords }: EventRowProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.header} data-testid="event-row-header">
|
||||
<Stack direction="column" gap={0}>
|
||||
<div
|
||||
className={cx(styles.header, isCollapsed ? styles.collapsedHeader : styles.notCollapsedHeader)}
|
||||
data-testid="event-row-header"
|
||||
>
|
||||
<CollapseToggle
|
||||
size="sm"
|
||||
className={styles.collapseToggle}
|
||||
@ -134,15 +154,29 @@ function EventRow({ record }: { record: LogRecord }) {
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className={styles.expandedRow}>
|
||||
<EventDetails record={record} logRecords={logRecords} />
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertRuleName({ labels, ruleUID }: { labels: Record<string, string>; ruleUID?: string }) {
|
||||
interface AlertRuleNameProps {
|
||||
labels: Record<string, string>;
|
||||
ruleUID?: string;
|
||||
}
|
||||
function AlertRuleName({ labels, ruleUID }: AlertRuleNameProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const alertRuleName = labels['alertname'];
|
||||
if (!ruleUID) {
|
||||
return <Text>{alertRuleName}</Text>;
|
||||
return (
|
||||
<Text>
|
||||
<Trans i18nKey="alerting.central-alert-history.details.unknown-rule">Unknown</Trans>
|
||||
{alertRuleName}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip content={alertRuleName ?? ''}>
|
||||
@ -170,55 +204,90 @@ function EventTransition({ previous, current }: EventTransitionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function EventState({ state }: { state: GrafanaAlertStateWithReason }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
interface StateIconProps {
|
||||
iconName: IconName;
|
||||
iconColor: string;
|
||||
tooltipContent: string;
|
||||
labelText: ReactElement;
|
||||
showLabel: boolean;
|
||||
}
|
||||
const StateIcon = ({ iconName, iconColor, tooltipContent, labelText, showLabel }: StateIconProps) => (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<Stack gap={0.5} direction={'row'} alignItems="center">
|
||||
<Icon name={iconName} size="md" className={iconColor} />
|
||||
{showLabel && (
|
||||
<Text variant="body" weight="light">
|
||||
{labelText}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
interface EventStateProps {
|
||||
state: GrafanaAlertStateWithReason;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
export function EventState({ state, showLabel = false }: EventStateProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const toolTip = t('alerting.central-alert-history.details.no-recognized-state', 'No recognized state');
|
||||
if (!isGrafanaAlertState(state) && !isAlertStateWithReason(state)) {
|
||||
return (
|
||||
<Tooltip content={'No recognized state'}>
|
||||
<Icon name="exclamation-triangle" size="md" />
|
||||
</Tooltip>
|
||||
<StateIcon
|
||||
iconName="exclamation-triangle"
|
||||
tooltipContent={toolTip}
|
||||
labelText={<Trans i18nKey="alerting.central-alert-history.details.unknown-event-state">Unknown</Trans>}
|
||||
showLabel={Boolean(showLabel)}
|
||||
iconColor={styles.warningColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const baseState = mapStateWithReasonToBaseState(state);
|
||||
const reason = mapStateWithReasonToReason(state);
|
||||
|
||||
switch (baseState) {
|
||||
case 'Normal':
|
||||
return (
|
||||
<Tooltip content={Boolean(reason) ? `Normal (${reason})` : 'Normal'}>
|
||||
<Icon name="check-circle" size="md" className={Boolean(reason) ? styles.warningColor : styles.normalColor} />
|
||||
</Tooltip>
|
||||
);
|
||||
case 'Alerting':
|
||||
return (
|
||||
<Tooltip content={'Alerting'}>
|
||||
<Icon name="exclamation-circle" size="md" className={styles.alertingColor} />
|
||||
</Tooltip>
|
||||
);
|
||||
case 'NoData': //todo:change icon
|
||||
return (
|
||||
<Tooltip content={'Insufficient data'}>
|
||||
<Icon name="exclamation-triangle" size="md" className={styles.warningColor} />
|
||||
{/* no idea which icon to use */}
|
||||
</Tooltip>
|
||||
);
|
||||
case 'Error':
|
||||
return (
|
||||
<Tooltip content={'Error'}>
|
||||
<Icon name="exclamation-circle" size="md" />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case 'Pending':
|
||||
return (
|
||||
<Tooltip content={Boolean(reason) ? `Pending (${reason})` : 'Pending'}>
|
||||
<Icon name="circle" size="md" className={styles.warningColor} />
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return <Icon name="exclamation-triangle" size="md" />;
|
||||
interface StateConfig {
|
||||
iconName: IconName;
|
||||
iconColor: string;
|
||||
tooltipContent: string;
|
||||
labelText: ReactElement;
|
||||
}
|
||||
interface StateConfigMap {
|
||||
[key: string]: StateConfig;
|
||||
}
|
||||
const stateConfig: StateConfigMap = {
|
||||
Normal: {
|
||||
iconName: 'check-circle',
|
||||
iconColor: Boolean(reason) ? styles.warningColor : styles.normalColor,
|
||||
tooltipContent: Boolean(reason) ? `Normal (${reason})` : 'Normal',
|
||||
labelText: <Trans i18nKey="alerting.central-alert-history.details.state.normal">Normal</Trans>,
|
||||
},
|
||||
Alerting: {
|
||||
iconName: 'exclamation-circle',
|
||||
iconColor: styles.alertingColor,
|
||||
tooltipContent: 'Alerting',
|
||||
labelText: <Trans i18nKey="alerting.central-alert-history.details.state.alerting">Alerting</Trans>,
|
||||
},
|
||||
NoData: {
|
||||
iconName: 'exclamation-triangle',
|
||||
iconColor: styles.warningColor,
|
||||
tooltipContent: 'Insufficient data',
|
||||
labelText: <Trans i18nKey="alerting.central-alert-history.details.state.no-data">No data</Trans>,
|
||||
},
|
||||
Error: {
|
||||
iconName: 'exclamation-circle',
|
||||
tooltipContent: 'Error',
|
||||
iconColor: styles.warningColor,
|
||||
labelText: <Trans i18nKey="alerting.central-alert-history.details.state.error">Error</Trans>,
|
||||
},
|
||||
Pending: {
|
||||
iconName: 'circle',
|
||||
iconColor: styles.warningColor,
|
||||
tooltipContent: Boolean(reason) ? `Pending (${reason})` : 'Pending',
|
||||
labelText: <Trans i18nKey="alerting.central-alert-history.details.state.pending">Pending</Trans>,
|
||||
},
|
||||
};
|
||||
|
||||
const config = stateConfig[baseState] || { iconName: 'exclamation-triangle', tooltipContent: 'Unknown State' };
|
||||
return <StateIcon {...config} showLabel={showLabel} />;
|
||||
}
|
||||
|
||||
interface TimestampProps {
|
||||
@ -253,12 +322,16 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
alignItems: 'center',
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0`,
|
||||
flexWrap: 'nowrap',
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.components.table.rowHoverBackground,
|
||||
},
|
||||
}),
|
||||
collapsedHeader: css({
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
notCollapsedHeader: css({
|
||||
borderBottom: 'none',
|
||||
}),
|
||||
|
||||
collapseToggle: css({
|
||||
background: 'none',
|
||||
@ -303,6 +376,11 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
display: 'block',
|
||||
color: theme.colors.text.link,
|
||||
}),
|
||||
expandedRow: css({
|
||||
padding: theme.spacing(2),
|
||||
marginLeft: theme.spacing(2),
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,53 @@
|
||||
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));
|
||||
});
|
||||
});
|
@ -47,9 +47,15 @@ export function mapStateWithReasonToReason(state: GrafanaAlertStateWithReason):
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
type StateWithReasonToBaseStateReturnType<T> = T extends GrafanaAlertStateWithReason
|
||||
? GrafanaAlertState
|
||||
: T extends PromAlertingRuleState
|
||||
? PromAlertingRuleState
|
||||
: never;
|
||||
|
||||
export function mapStateWithReasonToBaseState(
|
||||
state: GrafanaAlertStateWithReason | PromAlertingRuleState
|
||||
): GrafanaAlertState | PromAlertingRuleState {
|
||||
): StateWithReasonToBaseStateReturnType<GrafanaAlertStateWithReason | PromAlertingRuleState> {
|
||||
if (isAlertStateWithReason(state)) {
|
||||
const fields = state.split(' ');
|
||||
return fields[0] as GrafanaAlertState;
|
||||
|
@ -56,6 +56,39 @@
|
||||
"pause": "Pause evaluation"
|
||||
},
|
||||
"alerting": {
|
||||
"central-alert-history": {
|
||||
"details": {
|
||||
"annotations": "Annotations",
|
||||
"error": "Error loading rule for this event.",
|
||||
"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",
|
||||
"state": {
|
||||
"alerting": "Alerting",
|
||||
"error": "Error",
|
||||
"no-data": "No data",
|
||||
"normal": "Normal",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"state-transitions": "State transition",
|
||||
"unknown-event-state": "Unknown",
|
||||
"unknown-rule": "Unknown",
|
||||
"value-in-transition": "Value in transition"
|
||||
},
|
||||
"error": "Something went wrong loading the alert state history",
|
||||
"filter": {
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact-points": {
|
||||
"telegram": {
|
||||
"parse-mode-warning-body": "If you use a <1>parse_mode</1> option other than <3>None</3>, truncation may result in an invalid message, causing the notification to fail. For longer messages, we recommend using an alternative contact method.",
|
||||
@ -173,17 +206,6 @@
|
||||
"text": "No results found for your query"
|
||||
}
|
||||
},
|
||||
"central-alert-history": {
|
||||
"error": "Something went wrong loading the alert state history",
|
||||
"filter": {
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clipboard-button": {
|
||||
"inline-toast": {
|
||||
"success": "Copied"
|
||||
|
@ -56,6 +56,39 @@
|
||||
"pause": "Päūşę ęväľūäŧįőʼn"
|
||||
},
|
||||
"alerting": {
|
||||
"central-alert-history": {
|
||||
"details": {
|
||||
"annotations": "Åʼnʼnőŧäŧįőʼnş",
|
||||
"error": "Ēřřőř ľőäđįʼnģ řūľę ƒőř ŧĥįş ęvęʼnŧ.",
|
||||
"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ęřįőđ",
|
||||
"state": {
|
||||
"alerting": "Åľęřŧįʼnģ",
|
||||
"error": "Ēřřőř",
|
||||
"no-data": "Ńő đäŧä",
|
||||
"normal": "Ńőřmäľ",
|
||||
"pending": "Pęʼnđįʼnģ"
|
||||
},
|
||||
"state-transitions": "Ŝŧäŧę ŧřäʼnşįŧįőʼn",
|
||||
"unknown-event-state": "Ůʼnĸʼnőŵʼn",
|
||||
"unknown-rule": "Ůʼnĸʼnőŵʼn",
|
||||
"value-in-transition": "Väľūę įʼn ŧřäʼnşįŧįőʼn"
|
||||
},
|
||||
"error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
|
||||
"filter": {
|
||||
"info": {
|
||||
"label1": "Fįľŧęř ęvęʼnŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ şpäčęş, ęχ:",
|
||||
"label2": "Ĩʼnväľįđ ūşę őƒ şpäčęş:",
|
||||
"label3": "Väľįđ ūşę őƒ şpäčęş:",
|
||||
"label4": "Fįľŧęř äľęřŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ þřäčęş, ęχ:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact-points": {
|
||||
"telegram": {
|
||||
"parse-mode-warning-body": "Ĩƒ yőū ūşę ä <1>päřşę_mőđę</1> őpŧįőʼn őŧĥęř ŧĥäʼn <3>Ńőʼnę</3>, ŧřūʼnčäŧįőʼn mäy řęşūľŧ įʼn äʼn įʼnväľįđ męşşäģę, čäūşįʼnģ ŧĥę ʼnőŧįƒįčäŧįőʼn ŧő ƒäįľ. Főř ľőʼnģęř męşşäģęş, ŵę řęčőmmęʼnđ ūşįʼnģ äʼn äľŧęřʼnäŧįvę čőʼnŧäčŧ męŧĥőđ.",
|
||||
@ -173,17 +206,6 @@
|
||||
"text": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy"
|
||||
}
|
||||
},
|
||||
"central-alert-history": {
|
||||
"error": "Ŝőmęŧĥįʼnģ ŵęʼnŧ ŵřőʼnģ ľőäđįʼnģ ŧĥę äľęřŧ şŧäŧę ĥįşŧőřy",
|
||||
"filter": {
|
||||
"info": {
|
||||
"label1": "Fįľŧęř ęvęʼnŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ şpäčęş, ęχ:",
|
||||
"label2": "Ĩʼnväľįđ ūşę őƒ şpäčęş:",
|
||||
"label3": "Väľįđ ūşę őƒ şpäčęş:",
|
||||
"label4": "Fįľŧęř äľęřŧş ūşįʼnģ ľäþęľ qūęřyįʼnģ ŵįŧĥőūŧ þřäčęş, ęχ:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clipboard-button": {
|
||||
"inline-toast": {
|
||||
"success": "Cőpįęđ"
|
||||
|
Loading…
Reference in New Issue
Block a user