import { css, cx } from '@emotion/css'; import { uniqueId } from 'lodash'; import { FC, useCallback, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { CoreApp, DataFrame, GrafanaTheme2, LoadingState, PanelData, dateTimeFormat, isTimeSeriesFrames, } from '@grafana/data'; import { Alert, AutoSizeInput, Button, IconButton, Stack, Text, clearButtonStyles, 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 { SqlExpr } from 'app/features/expressions/components/SqlExpr'; 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 { RuleFormValues } from '../../types/rule-form'; import { isGrafanaRecordingRuleByType } from '../../utils/rules'; import { PopupCard } 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 = ({ queries = [], query, data, error, warning, isAlertCondition, onSetCondition, onUpdateRefId, onRemoveExpression, onUpdateExpressionType, // this method is not used? maybe we should remove it onChangeQuery, }) => { const styles = useStyles2(getStyles); const queryType = query?.type; const { setError, clearErrors, watch } = useFormContext(); const type = watch('type'); const isGrafanaRecordingRule = type ? isGrafanaRecordingRuleByType(type) : false; 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 {}} />; case ExpressionQueryType.reduce: return ( ); case ExpressionQueryType.resample: return ; case ExpressionQueryType.classic: return ; case ExpressionQueryType.threshold: return ( ); case ExpressionQueryType.sql: return ; default: return <>Expression not supported: {query.type}; } }, [onChangeQuery, queries, onQueriesValidationError] ); const selectedExpressionType = expressionTypes.find((o) => o.value === queryType); const selectedExpressionDescription = selectedExpressionType?.description ?? ''; return (
onRemoveExpression(query.refId)} onUpdateRefId={(newRefId) => onUpdateRefId(query.refId, newRefId)} onSetCondition={onSetCondition} query={query} alertCondition={alertCondition} />
{error && ( {error.message} )} {warning && ( {warning.message} )}
{selectedExpressionDescription}
{renderExpressionType(query)}
{hasResults && ( <> {!isGrafanaRecordingRule && (
)} )}
); }; interface ExpressionResultProps { series: DataFrame[]; isAlertCondition?: boolean; isRecordingRule?: boolean; } export const PAGE_SIZE = 20; export const ExpressionResult: FC = ({ series, isAlertCondition, isRecordingRule = false }) => { 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 (
{!emptyResults && isTimeSeriesResults && (
{pageItems.map((frame, index) => ( ))}
)} {!emptyResults && !isTimeSeriesResults && pageItems.map((frame, index) => ( // There's no way to uniquely identify a frame that doesn't cause render bugs :/ (Gilles) ))} {emptyResults &&
No data
} {shouldShowPagination && (
)}
); }; export const PreviewSummary: FC<{ firing: number; normal: number; isCondition: boolean; seriesCount: number }> = ({ firing, normal, isCondition, seriesCount, }) => { const { mutedText } = useStyles2(getStyles); if (seriesCount === 0) { return No series; } if (isCondition) { return {`${seriesCount} series: ${firing} firing, ${normal} normal`}; } return {`${seriesCount} series`}; }; 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; onSetCondition: (refId: string) => void; query: ExpressionQuery; alertCondition: boolean; } const Header: FC = ({ 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 (
{!editingRefId && ( )} {editingRefId && ( event.target.select()} onBlur={(event) => { onUpdateRefId(event.currentTarget.value); setEditMode(false); }} /> )}
{getExpressionLabel(queryType)}
onSetCondition(query.refId)} isCondition={alertCondition} />
); }; interface FrameProps extends Pick { frame: DataFrame; index: number; isRecordingRule?: boolean; } const OpeningBracket = () => {'{'}; const ClosingBracket = () => {'}'}; // eslint-disable-next-line @grafana/no-untranslated-strings const Quote = () => "; const Equals = () => {'='}; function FrameRow({ frame, index, isAlertCondition, isRecordingRule }: FrameProps) { 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)}}` : ''}`; const shouldRenderSumary = !isRecordingRule; return (
{hasLabels ? ( <> {labels.map(([key, value], index) => ( {key} {value} {index < labels.length - 1 && , } ))} ) : ( {title} )}
{value}
{shouldRenderSumary && ( <> {showFiring && } {showNormal && } )}
); } interface TimeseriesRowProps extends Omit {} const TimeseriesRow: FC = ({ 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 (
{name}
Timestamp Value {timestamps.map((_, index) => ( {dateTimeFormat(getTimestampFromIndex(index))} {getValueFromIndex(index)} ))} } > Time series data
); }; const getStyles = (theme: GrafanaTheme2) => ({ expression: { wrapper: css({ display: 'flex', border: `solid 1px ${theme.colors.border.medium}`, flex: 1, flexBasis: '400px', borderRadius: theme.shape.radius.default, }), stack: css({ display: 'flex', flexDirection: 'column', flexWrap: 'nowrap', gap: 0, width: '100%', minWidth: '0', // this one is important to prevent text overflow }), classic: css({ maxWidth: '100%', }), nonClassic: css({ maxWidth: '640px', }), alertCondition: css({}), body: css({ padding: theme.spacing(1), flex: 1, }), description: css({ marginBottom: theme.spacing(1), fontSize: theme.typography.size.xs, color: theme.colors.text.secondary, }), refId: css({ fontWeight: theme.typography.fontWeightBold, color: theme.colors.primary.text, }), results: css({ display: 'flex', flexDirection: 'column', flexWrap: 'nowrap', borderTop: `solid 1px ${theme.colors.border.medium}`, }), noResults: css({ display: 'flex', alignItems: 'center', justifyContent: 'center', }), resultsRow: css({ padding: `${theme.spacing(0.75)} ${theme.spacing(1)}`, '&:nth-child(odd)': { backgroundColor: theme.colors.background.secondary, }, '&:hover': { backgroundColor: theme.colors.background.canvas, }, }), labelKey: css({ color: theme.isDark ? '#73bf69' : '#56a64b', }), labelValue: css({ color: theme.isDark ? '#ce9178' : '#a31515', }), resultValue: css({ textAlign: 'right', }), resultLabel: css({ flex: 1, overflowX: 'auto', display: 'inline-block', whiteSpace: 'nowrap', }), noData: css({ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: theme.spacing(), }), }, mutedText: css({ color: theme.colors.text.secondary, fontSize: '0.9em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }), header: { wrapper: css({ background: theme.colors.background.secondary, padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`, borderBottom: `solid 1px ${theme.colors.border.weak}`, }), }, footer: css({ background: theme.colors.background.secondary, padding: theme.spacing(1), borderTop: `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}`, borderRadius: theme.shape.radius.default, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: theme.spacing(1), cursor: 'pointer', }), timeseriesTableWrapper: css({ maxHeight: '500px', overflowY: 'scroll', }), timeseriesTable: css({ tableLayout: 'auto', width: '100%', height: '100%', 'td, th': { padding: theme.spacing(1), }, td: { background: theme.colors.background.primary, }, th: { background: theme.colors.background.secondary, }, tr: { borderBottom: `1px solid ${theme.colors.border.medium}`, '&:last-of-type': { borderBottom: 'none', }, }, }), pagination: { wrapper: css({ borderTop: `1px solid ${theme.colors.border.medium}`, padding: theme.spacing(), }), }, });