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 = ({ 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 {}} />; case ExpressionQueryType.reduce: return ; case ExpressionQueryType.resample: return ; case ExpressionQueryType.classic: return ; case ExpressionQueryType.threshold: 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)} onUpdateExpressionType={(type) => onUpdateExpressionType(query.refId, type)} onSetCondition={onSetCondition} query={query} alertCondition={alertCondition} />
{error && ( {error.message} )} {warning && ( {warning.message} )}
{selectedExpressionDescription}
{renderExpressionType(query)}
{hasResults && ( <>
)}
); }; interface ExpressionResultProps { series: DataFrame[]; isAlertCondition?: boolean; } export const PAGE_SIZE = 20; export const ExpressionResult: FC = ({ 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 (
{!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; onUpdateExpressionType: (type: ExpressionQueryType) => 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 && ( { onUpdateRefId(event.currentTarget.value); setEditMode(false); }} onFocus={(event) => 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; } const FrameRow: FC = ({ 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 (
{hasLabels ? '' : name} {hasLabels && ( <> {'{'} {labels.map(([key, value], index) => ( {key} = " {value} " {index < labels.length - 1 && , } ))} {'}'} )}
{value}
{showFiring && } {showNormal && }
); }; 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; 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()}; `, }, });