Files
grafana/public/app/features/alerting/unified/components/expressions/Expression.tsx
Konrad Lalik 282a3f9a66 Alerting: Allow copying error messages from query badges (#80078)
* Add interactive mode to the Badge component

* Add expression errors as Alert components instead of badges

* Revert "Add interactive mode to the Badge component"

This reverts commit 9558743fc7.
2024-01-10 10:17:57 +01:00

615 lines
19 KiB
TypeScript

import { css, cx } from '@emotion/css';
import { uniqueId } from 'lodash';
import React, { FC, useCallback, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { DataFrame, dateTimeFormat, GrafanaTheme2, isTimeSeriesFrames, LoadingState, PanelData } from '@grafana/data';
import { Alert, AutoSizeInput, Button, clearButtonStyles, IconButton, Stack, useStyles2 } from '@grafana/ui';
import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions';
import { Math } from 'app/features/expressions/components/Math';
import { Reduce } from 'app/features/expressions/components/Reduce';
import { Resample } from 'app/features/expressions/components/Resample';
import { Threshold } from 'app/features/expressions/components/Threshold';
import {
ExpressionQuery,
ExpressionQueryType,
expressionTypes,
getExpressionLabel,
} from 'app/features/expressions/types';
import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { usePagination } from '../../hooks/usePagination';
import { HoverCard } from '../HoverCard';
import { Spacer } from '../Spacer';
import { AlertStateTag } from '../rules/AlertStateTag';
import { ExpressionStatusIndicator } from './ExpressionStatusIndicator';
import { formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries } from './util';
interface ExpressionProps {
isAlertCondition?: boolean;
data?: PanelData;
error?: Error;
warning?: Error;
queries: AlertQuery[];
query: ExpressionQuery;
onSetCondition: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onRemoveExpression: (refId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onChangeQuery: (query: ExpressionQuery) => void;
}
export const Expression: FC<ExpressionProps> = ({
queries = [],
query,
data,
error,
warning,
isAlertCondition,
onSetCondition,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType,
onChangeQuery,
}) => {
const styles = useStyles2(getStyles);
const queryType = query?.type;
const { setError, clearErrors } = useFormContext();
const onQueriesValidationError = useCallback(
(errorMsg: string | undefined) => {
if (errorMsg) {
setError('queries', { type: 'custom', message: errorMsg });
} else {
clearErrors('queries');
}
},
[setError, clearErrors]
);
const isLoading = data && Object.values(data).some((d) => Boolean(d) && d.state === LoadingState.Loading);
const hasResults = Array.isArray(data?.series) && !isLoading;
const series = data?.series ?? [];
const alertCondition = isAlertCondition ?? false;
const { seriesCount, groupedByState } = getGroupedByStateAndSeriesCount(series);
const renderExpressionType = useCallback(
(query: ExpressionQuery) => {
// these are the refs we can choose from that don't include the current one
const availableRefIds = queries
.filter((q) => query.refId !== q.refId)
.map((q) => ({ value: q.refId, label: q.refId }));
switch (query.type) {
case ExpressionQueryType.math:
return <Math onChange={onChangeQuery} query={query} labelWidth={'auto'} onRunQuery={() => {}} />;
case ExpressionQueryType.reduce:
return <Reduce onChange={onChangeQuery} refIds={availableRefIds} labelWidth={'auto'} query={query} />;
case ExpressionQueryType.resample:
return <Resample onChange={onChangeQuery} query={query} labelWidth={'auto'} refIds={availableRefIds} />;
case ExpressionQueryType.classic:
return <ClassicConditions onChange={onChangeQuery} query={query} refIds={availableRefIds} />;
case ExpressionQueryType.threshold:
return (
<Threshold
onChange={onChangeQuery}
query={query}
labelWidth={'auto'}
refIds={availableRefIds}
onError={onQueriesValidationError}
useHysteresis={true}
/>
);
default:
return <>Expression not supported: {query.type}</>;
}
},
[onChangeQuery, queries, onQueriesValidationError]
);
const selectedExpressionType = expressionTypes.find((o) => o.value === queryType);
const selectedExpressionDescription = selectedExpressionType?.description ?? '';
return (
<div
className={cx(
styles.expression.wrapper,
alertCondition && styles.expression.alertCondition,
queryType === ExpressionQueryType.classic && styles.expression.classic,
queryType !== ExpressionQueryType.classic && styles.expression.nonClassic
)}
>
<div className={styles.expression.stack}>
<Header
refId={query.refId}
queryType={queryType}
onRemoveExpression={() => onRemoveExpression(query.refId)}
onUpdateRefId={(newRefId) => onUpdateRefId(query.refId, newRefId)}
onUpdateExpressionType={(type) => onUpdateExpressionType(query.refId, type)}
onSetCondition={onSetCondition}
query={query}
alertCondition={alertCondition}
/>
<div className={styles.expression.body}>
{error && (
<Alert title="Expression failed" severity="error">
{error.message}
</Alert>
)}
{warning && (
<Alert title="Expression warning" severity="warning">
{warning.message}
</Alert>
)}
<div className={styles.expression.description}>{selectedExpressionDescription}</div>
{renderExpressionType(query)}
</div>
{hasResults && (
<>
<ExpressionResult series={series} isAlertCondition={isAlertCondition} />
<div className={styles.footer}>
<Stack direction="row" alignItems="center">
<Spacer />
<PreviewSummary
isCondition={Boolean(isAlertCondition)}
firing={groupedByState[PromAlertingRuleState.Firing].length}
normal={groupedByState[PromAlertingRuleState.Inactive].length}
seriesCount={seriesCount}
/>
</Stack>
</div>
</>
)}
</div>
</div>
);
};
interface ExpressionResultProps {
series: DataFrame[];
isAlertCondition?: boolean;
}
export const PAGE_SIZE = 20;
export const ExpressionResult: FC<ExpressionResultProps> = ({ series, isAlertCondition }) => {
const { pageItems, previousPage, nextPage, numberOfPages, pageStart, pageEnd } = usePagination(series, 1, PAGE_SIZE);
const styles = useStyles2(getStyles);
// sometimes we receive results where every value is just "null" when noData occurs
const emptyResults = isEmptySeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeriesFrames(series);
const shouldShowPagination = numberOfPages > 1;
return (
<div className={styles.expression.results}>
{!emptyResults && isTimeSeriesResults && (
<div>
{pageItems.map((frame, index) => (
<TimeseriesRow
key={uniqueId()}
frame={frame}
index={pageStart + index}
isAlertCondition={isAlertCondition}
/>
))}
</div>
)}
{!emptyResults &&
!isTimeSeriesResults &&
pageItems.map((frame, index) => (
// There's no way to uniquely identify a frame that doesn't cause render bugs :/ (Gilles)
<FrameRow key={uniqueId()} frame={frame} index={pageStart + index} isAlertCondition={isAlertCondition} />
))}
{emptyResults && <div className={cx(styles.expression.noData, styles.mutedText)}>No data</div>}
{shouldShowPagination && (
<div className={styles.pagination.wrapper} data-testid="paginate-expression">
<Stack>
<Button
variant="secondary"
fill="outline"
onClick={previousPage}
icon="angle-left"
size="sm"
aria-label="previous-page"
/>
<Spacer />
<span className={styles.mutedText}>
{pageStart} - {pageEnd} of {series.length}
</span>
<Spacer />
<Button
variant="secondary"
fill="outline"
onClick={nextPage}
icon="angle-right"
size="sm"
aria-label="next-page"
/>
</Stack>
</div>
)}
</div>
);
};
export const PreviewSummary: FC<{ firing: number; normal: number; isCondition: boolean; seriesCount: number }> = ({
firing,
normal,
isCondition,
seriesCount,
}) => {
const { mutedText } = useStyles2(getStyles);
if (seriesCount === 0) {
return <span className={mutedText}>No series</span>;
}
if (isCondition) {
return <span className={mutedText}>{`${seriesCount} series: ${firing} firing, ${normal} normal`}</span>;
}
return <span className={mutedText}>{`${seriesCount} series`}</span>;
};
export function getGroupedByStateAndSeriesCount(series: DataFrame[]) {
const noDataSeries = series.filter((serie) => getSeriesValue(serie) === undefined).length;
const groupedByState = {
// we need to filter out series with no data (undefined) or zero value
[PromAlertingRuleState.Firing]: series.filter(
(serie) => getSeriesValue(serie) !== undefined && getSeriesValue(serie) !== 0
),
[PromAlertingRuleState.Inactive]: series.filter((serie) => getSeriesValue(serie) === 0),
};
const seriesCount = series.length - noDataSeries;
return { groupedByState, seriesCount };
}
interface HeaderProps {
refId: string;
queryType: ExpressionQueryType;
onUpdateRefId: (refId: string) => void;
onRemoveExpression: () => void;
onUpdateExpressionType: (type: ExpressionQueryType) => void;
onSetCondition: (refId: string) => void;
query: ExpressionQuery;
alertCondition: boolean;
}
const Header: FC<HeaderProps> = ({
refId,
queryType,
onUpdateRefId,
onRemoveExpression,
onSetCondition,
alertCondition,
query,
}) => {
const styles = useStyles2(getStyles);
const clearButton = useStyles2(clearButtonStyles);
/**
* There are 3 edit modes:
*
* 1. "refId": Editing the refId (ie. A -> B)
* 2. "expressionType": Editing the type of the expression (ie. Reduce -> Math)
* 3. "false": This means we're not editing either of those
*/
const [editMode, setEditMode] = useState<'refId' | 'expressionType' | false>(false);
const editing = editMode !== false;
const editingRefId = editing && editMode === 'refId';
return (
<header className={styles.header.wrapper}>
<Stack direction="row" gap={0.5} alignItems="center">
<Stack direction="row" gap={1} alignItems="center">
{!editingRefId && (
<button type="button" className={cx(clearButton, styles.editable)} onClick={() => setEditMode('refId')}>
<div className={styles.expression.refId}>{refId}</div>
</button>
)}
{editingRefId && (
<AutoSizeInput
autoFocus
defaultValue={refId}
minWidth={5}
onChange={(event) => {
onUpdateRefId(event.currentTarget.value);
setEditMode(false);
}}
onFocus={(event) => event.target.select()}
onBlur={(event) => {
onUpdateRefId(event.currentTarget.value);
setEditMode(false);
}}
/>
)}
<div>{getExpressionLabel(queryType)}</div>
</Stack>
<Spacer />
<ExpressionStatusIndicator onSetCondition={() => onSetCondition(query.refId)} isCondition={alertCondition} />
<IconButton
name="trash-alt"
variant="secondary"
className={styles.mutedIcon}
onClick={onRemoveExpression}
tooltip="Remove expression"
/>
</Stack>
</header>
);
};
interface FrameProps extends Pick<ExpressionProps, 'isAlertCondition'> {
frame: DataFrame;
index: number;
}
const FrameRow: FC<FrameProps> = ({ frame, index, isAlertCondition }) => {
const styles = useStyles2(getStyles);
const name = getSeriesName(frame) || 'Series ' + index;
const value = getSeriesValue(frame);
const labelsRecord = getSeriesLabels(frame);
const labels = Object.entries(labelsRecord);
const hasLabels = labels.length > 0;
const showFiring = isAlertCondition && value !== 0;
const showNormal = isAlertCondition && value === 0;
const title = `${hasLabels ? '' : name}${hasLabels ? `{${formatLabels(labelsRecord)}}` : ''}`;
return (
<div className={styles.expression.resultsRow}>
<Stack direction="row" gap={1} alignItems="center">
<div className={styles.expression.resultLabel} title={title}>
<span>{hasLabels ? '' : name}</span>
{hasLabels && (
<>
<span>{'{'}</span>
{labels.map(([key, value], index) => (
<span key={uniqueId()}>
<span className={styles.expression.labelKey}>{key}</span>
<span>=</span>
<span>&quot;</span>
<span className={styles.expression.labelValue}>{value}</span>
<span>&quot;</span>
{index < labels.length - 1 && <span>, </span>}
</span>
))}
<span>{'}'}</span>
</>
)}
</div>
<div className={styles.expression.resultValue}>{value}</div>
{showFiring && <AlertStateTag state={PromAlertingRuleState.Firing} size="sm" />}
{showNormal && <AlertStateTag state={PromAlertingRuleState.Inactive} size="sm" />}
</Stack>
</div>
);
};
const TimeseriesRow: FC<FrameProps & { index: number }> = ({ frame, index }) => {
const styles = useStyles2(getStyles);
const valueField = frame.fields[1]; // field 0 is "time", field 1 is "value"
const hasLabels = valueField.labels;
const displayNameFromDS = valueField.config?.displayNameFromDS;
const name = displayNameFromDS ?? (hasLabels ? formatLabels(valueField.labels ?? {}) : 'Series ' + index);
const timestamps = frame.fields[0].values;
const getTimestampFromIndex = (index: number) => frame.fields[0].values[index];
const getValueFromIndex = (index: number) => frame.fields[1].values[index];
return (
<div className={styles.expression.resultsRow}>
<Stack direction="row" alignItems="center">
<span className={cx(styles.mutedText, styles.expression.resultLabel)} title={name}>
{name}
</span>
<div className={styles.expression.resultValue}>
<HoverCard
placement="right"
wrapperClassName={styles.timeseriesTableWrapper}
content={
<table className={styles.timeseriesTable}>
<thead>
<tr>
<th>Timestamp</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{timestamps.map((_, index) => (
<tr key={index}>
<td className={styles.mutedText}>{dateTimeFormat(getTimestampFromIndex(index))}</td>
<td className={styles.expression.resultValue}>{getValueFromIndex(index)}</td>
</tr>
))}
</tbody>
</table>
}
>
<span>Time series data</span>
</HoverCard>
</div>
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
expression: {
wrapper: css`
display: flex;
border: solid 1px ${theme.colors.border.medium};
flex: 1;
flex-basis: 400px;
border-radius: ${theme.shape.radius.default};
`,
stack: css`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0;
width: 100%;
min-width: 0; // this one is important to prevent text overflow
`,
classic: css`
max-width: 100%;
`,
nonClassic: css`
max-width: 640px;
`,
alertCondition: css``,
body: css`
padding: ${theme.spacing(1)};
flex: 1;
`,
description: css`
margin-bottom: ${theme.spacing(1)};
font-size: ${theme.typography.size.xs};
color: ${theme.colors.text.secondary};
`,
refId: css`
font-weight: ${theme.typography.fontWeightBold};
color: ${theme.colors.primary.text};
`,
results: css`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
border-top: solid 1px ${theme.colors.border.medium};
`,
noResults: css`
display: flex;
align-items: center;
justify-content: center;
`,
resultsRow: css`
padding: ${theme.spacing(0.75)} ${theme.spacing(1)};
&:nth-child(odd) {
background-color: ${theme.colors.background.secondary};
}
&:hover {
background-color: ${theme.colors.background.canvas};
}
`,
labelKey: css`
color: ${theme.isDark ? '#73bf69' : '#56a64b'};
`,
labelValue: css`
color: ${theme.isDark ? '#ce9178' : '#a31515'};
`,
resultValue: css`
text-align: right;
`,
resultLabel: css`
flex: 1;
overflow-x: auto;
display: inline-block;
white-space: nowrap;
`,
noData: css`
display: flex;
align-items: center;
justify-content: center;
padding: ${theme.spacing()};
`,
},
mutedText: css`
color: ${theme.colors.text.secondary};
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
header: {
wrapper: css`
background: ${theme.colors.background.secondary};
padding: ${theme.spacing(0.5)} ${theme.spacing(1)};
border-bottom: solid 1px ${theme.colors.border.weak};
`,
},
footer: css`
background: ${theme.colors.background.secondary};
padding: ${theme.spacing(1)};
border-top: solid 1px ${theme.colors.border.weak};
`,
draggableIcon: css`
cursor: grab;
`,
mutedIcon: css`
color: ${theme.colors.text.secondary};
`,
editable: css`
padding: ${theme.spacing(0.5)} ${theme.spacing(1)};
border: solid 1px ${theme.colors.border.weak};
border-radius: ${theme.shape.radius.default};
display: flex;
flex-direction: row;
align-items: center;
gap: ${theme.spacing(1)};
cursor: pointer;
`,
timeseriesTableWrapper: css`
max-height: 500px;
overflow-y: scroll;
`,
timeseriesTable: css`
table-layout: auto;
width: 100%;
height: 100%;
td,
th {
padding: ${theme.spacing(1)};
}
td {
background: ${theme.colors.background.primary};
}
th {
background: ${theme.colors.background.secondary};
}
tr {
border-bottom: 1px solid ${theme.colors.border.medium};
&:last-of-type {
border-bottom: none;
}
}
`,
pagination: {
wrapper: css`
border-top: 1px solid ${theme.colors.border.medium};
padding: ${theme.spacing()};
`,
},
});