The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx

543 lines
17 KiB

import { css, cx } from '@emotion/css';
import { keyBy, startCase } from 'lodash';
import React from 'react';
import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2, PanelData, urlUtil } from '@grafana/data';
import { secondsToHms } from '@grafana/data/src/datetime/rangeutil';
import { config } from '@grafana/runtime';
import { Preview } from '@grafana/sql/src/components/visual-query-builder/Preview';
import { Badge, ErrorBoundaryAlert, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
import { CombinedRule } from 'app/types/unified-alerting';
import { AlertDataQuery, AlertQuery } from '../../../types/unified-alerting-dto';
import { isExpressionQuery } from '../../expressions/guards';
import {
downsamplingTypes,
ExpressionQuery,
ExpressionQueryType,
ReducerMode,
reducerModes,
reducerTypes,
thresholdFunctions,
upsamplingTypes,
} from '../../expressions/types';
import alertDef, { EvalFunction } from '../state/alertDef';
import { Spacer } from './components/Spacer';
import { WithReturnButton } from './components/WithReturnButton';
import { ExpressionResult } from './components/expressions/Expression';
import { getThresholdsForQueries, ThresholdDefinition } from './components/rule-editor/util';
import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization';
import { DatasourceModelPreview } from './components/rule-viewer/tabs/Query/DataSourceModelPreview';
import { AlertRuleAction, useAlertRuleAbility } from './hooks/useAbilities';
interface GrafanaRuleViewerProps {
rule: CombinedRule;
queries: AlertQuery[];
condition: string;
evalDataByQuery?: Record<string, PanelData>;
}
export function GrafanaRuleQueryViewer({ rule, queries, condition, evalDataByQuery = {} }: GrafanaRuleViewerProps) {
const dsByUid = keyBy(Object.values(config.datasources), (ds) => ds.uid);
const dataQueries = queries.filter((q) => !isExpressionQuery(q.model));
const expressions = queries.filter((q) => isExpressionQuery(q.model));
const styles = useStyles2(getExpressionViewerStyles);
const thresholds = getThresholdsForQueries(queries, condition);
return (
<Stack gap={1} direction="column" flex={'1 1 320px'}>
<div className={styles.maxWidthContainer}>
<Stack gap={1} wrap="wrap" data-testid="queries-container">
{dataQueries.map(({ model, relativeTimeRange, refId, datasourceUid }, index) => {
const dataSource = dsByUid[datasourceUid];
return (
<QueryPreview
rule={rule}
key={index}
refId={refId}
isAlertCondition={condition === refId}
model={model}
relativeTimeRange={relativeTimeRange}
dataSource={dataSource}
thresholds={thresholds[refId]}
queryData={evalDataByQuery[refId]}
/>
);
})}
</Stack>
</div>
<div className={styles.maxWidthContainer}>
<Stack gap={1} wrap="wrap" data-testid="expressions-container">
{expressions.map(({ model, refId, datasourceUid }, index) => {
const dataSource = dsByUid[datasourceUid];
return (
isExpressionQuery(model) && (
<ExpressionPreview
key={index}
refId={refId}
isAlertCondition={condition === refId}
model={model}
dataSource={dataSource}
evalData={evalDataByQuery[refId]}
/>
)
);
})}
</Stack>
</div>
</Stack>
);
}
interface QueryPreviewProps extends Pick<AlertQuery, 'refId' | 'relativeTimeRange' | 'model'> {
rule: CombinedRule;
isAlertCondition: boolean;
dataSource?: DataSourceInstanceSettings;
queryData?: PanelData;
thresholds?: ThresholdDefinition;
}
export function QueryPreview({
refId,
rule,
thresholds,
model,
dataSource,
queryData,
relativeTimeRange,
}: QueryPreviewProps) {
const styles = useStyles2(getQueryPreviewStyles);
const isExpression = isExpressionQuery(model);
const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore);
const canExplore = exploreSupported && exploreAllowed;
const headerItems: React.ReactNode[] = [];
if (dataSource) {
const dataSourceName = dataSource.name ?? '[[Data source not found]]';
const dataSourceImgUrl = dataSource.meta.info.logos.small;
headerItems.push(<DataSourceBadge name={dataSourceName} imgUrl={dataSourceImgUrl} key="datasource" />);
}
if (relativeTimeRange) {
headerItems.push(
<Text color="secondary" key="timerange">
{secondsToHms(relativeTimeRange.from)} to now
</Text>
);
}
let exploreLink: string | undefined = undefined;
if (!isExpression && canExplore) {
exploreLink = dataSource && createExploreLink(dataSource, model);
}
return (
<>
<QueryBox refId={refId} headerItems={headerItems} exploreLink={exploreLink}>
<div className={styles.queryPreviewWrapper}>
<ErrorBoundaryAlert>
{model && dataSource && <DatasourceModelPreview model={model} dataSource={dataSource} />}
</ErrorBoundaryAlert>
</div>
</QueryBox>
{dataSource && <RuleViewerVisualization data={queryData} thresholds={thresholds} />}
</>
);
}
function createExploreLink(settings: DataSourceRef, model: AlertDataQuery): string {
const { uid, type } = settings;
const { refId, ...rest } = model;
/*
In my testing I've found some alerts that don't have a data source embedded inside the model.
At this moment in time it is unclear to me why some alert definitions not have a data source embedded in the model.
I don't think that should happen here, the fact that the datasource ref is sometimes missing here is a symptom of another cause. (Gilles)
*/
return urlUtil.renderUrl(`${config.appSubUrl}/explore`, {
left: JSON.stringify({
datasource: settings.uid,
queries: [{ refId: 'A', ...rest, datasource: { type, uid } }],
range: { from: 'now-1h', to: 'now' },
}),
});
}
interface DataSourceBadgeProps {
name: string;
imgUrl: string;
}
function DataSourceBadge({ name, imgUrl }: DataSourceBadgeProps) {
const styles = useStyles2(getQueryPreviewStyles);
return (
<div className={styles.dataSource} key="datasource">
<img src={imgUrl} width={16} alt={name} />
{name}
</div>
);
}
const getQueryPreviewStyles = (theme: GrafanaTheme2) => ({
queryPreviewWrapper: css({
margin: theme.spacing(1),
}),
contentBox: css({
flex: '1 0 100%',
}),
visualization: css({
padding: theme.spacing(1),
}),
dataSource: css({
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
padding: theme.spacing(0.5, 1),
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
});
interface ExpressionPreviewProps extends Pick<AlertQuery, 'refId'> {
isAlertCondition: boolean;
model: ExpressionQuery;
dataSource: DataSourceInstanceSettings;
evalData?: PanelData;
}
function ExpressionPreview({ refId, model, evalData, isAlertCondition }: ExpressionPreviewProps) {
function renderPreview() {
switch (model.type) {
case ExpressionQueryType.math:
return <MathExpressionViewer model={model} />;
case ExpressionQueryType.reduce:
return <ReduceConditionViewer model={model} />;
case ExpressionQueryType.resample:
return <ResampleExpressionViewer model={model} />;
case ExpressionQueryType.classic:
return <ClassicConditionViewer model={model} />;
case ExpressionQueryType.threshold:
return <ThresholdExpressionViewer model={model} />;
case ExpressionQueryType.sql:
return <Preview rawSql={model.expression || ''} datasourceType={model.datasource?.type} />;
default:
return <>Expression not supported: {model.type}</>;
}
}
return (
<QueryBox
refId={refId}
headerItems={[
<Text color="secondary" key="expression-type">
{startCase(model.type)}
</Text>,
]}
isAlertCondition={isAlertCondition}
>
{renderPreview()}
<Spacer />
{evalData && <ExpressionResult series={evalData.series} isAlertCondition={isAlertCondition} />}
</QueryBox>
);
}
interface QueryBoxProps extends React.PropsWithChildren<unknown> {
refId: string;
headerItems?: React.ReactNode;
isAlertCondition?: boolean;
exploreLink?: string;
}
function QueryBox({ refId, headerItems = [], children, isAlertCondition, exploreLink }: QueryBoxProps) {
const styles = useStyles2(getQueryBoxStyles);
return (
<div className={cx(styles.container)}>
<header className={styles.header}>
<span className={styles.refId}>{refId}</span>
{headerItems}
<Spacer />
{isAlertCondition && <Badge color="green" icon="check" text="Alert condition" />}
{exploreLink && (
<WithReturnButton
component={
<LinkButton size="md" variant="secondary" icon="compass" href={exploreLink}>
View in Explore
</LinkButton>
}
/>
)}
</header>
{children}
</div>
);
}
const getQueryBoxStyles = (theme: GrafanaTheme2) => ({
container: css({
flex: '1 0 25%',
border: `1px solid ${theme.colors.border.weak}`,
maxWidth: '100%',
borderRadius: theme.shape.radius.default,
display: 'flex',
flexDirection: 'column',
}),
header: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1),
backgroundColor: theme.colors.background.secondary,
}),
textBlock: css({
border: `1px solid ${theme.colors.border.weak}`,
padding: theme.spacing(0.5, 1),
backgroundColor: theme.colors.background.primary,
borderRadius: theme.shape.radius.default,
}),
refId: css({
color: theme.colors.text.link,
padding: theme.spacing(0.5, 1),
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});
function ClassicConditionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getClassicConditionViewerStyles);
const reducerFunctions = keyBy(alertDef.reducerTypes, (rt) => rt.value);
const evalOperators = keyBy(alertDef.evalOperators, (eo) => eo.value);
const evalFunctions = keyBy(alertDef.evalFunctions, (ef) => ef.value);
return (
<div className={styles.container}>
{model.conditions?.map(({ query, operator, reducer, evaluator }, index) => {
const isRange = isRangeEvaluator(evaluator);
return (
<React.Fragment key={index}>
<div className={styles.blue}>
{index === 0 ? 'WHEN' : !!operator?.type && evalOperators[operator?.type]?.text}
</div>
<div className={styles.bold}>{reducer?.type && reducerFunctions[reducer.type]?.text}</div>
<div className={styles.blue}>OF</div>
<div className={styles.bold}>{query.params[0]}</div>
<div className={styles.blue}>{evalFunctions[evaluator.type].text}</div>
<div className={styles.bold}>
{isRange ? `(${evaluator.params[0]}; ${evaluator.params[1]})` : evaluator.params[0]}
</div>
</React.Fragment>
);
})}
</div>
);
}
const getClassicConditionViewerStyles = (theme: GrafanaTheme2) => ({
container: css({
padding: theme.spacing(1),
display: 'grid',
gridTemplateColumns: 'repeat(6, max-content)',
gap: theme.spacing(0, 1),
}),
...getCommonQueryStyles(theme),
});
function ReduceConditionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getReduceConditionViewerStyles);
const { reducer, expression, settings } = model;
const reducerType = reducerTypes.find((rt) => rt.value === reducer);
const reducerMode = settings?.mode ?? ReducerMode.Strict;
const modeName = reducerModes.find((rm) => rm.value === reducerMode);
return (
<div className={styles.container}>
<div className={styles.label}>Function</div>
<div className={styles.value}>{reducerType?.label}</div>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
<div className={styles.label}>Mode</div>
<div className={styles.value}>{modeName?.label}</div>
</div>
);
}
const getReduceConditionViewerStyles = (theme: GrafanaTheme2) => ({
container: css({
padding: theme.spacing(1),
display: 'grid',
gap: theme.spacing(0.5),
gridTemplateRows: '1fr 1fr',
gridTemplateColumns: 'repeat(4, 1fr)',
'> :nth-child(6)': {
gridColumn: 'span 3',
},
}),
...getCommonQueryStyles(theme),
});
function ResampleExpressionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getResampleExpressionViewerStyles);
const { expression, window, downsampler, upsampler } = model;
const downsamplerType = downsamplingTypes.find((dt) => dt.value === downsampler);
const upsamplerType = upsamplingTypes.find((ut) => ut.value === upsampler);
return (
<div className={styles.container}>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
<div className={styles.label}>Resample to</div>
<div className={styles.value}>{window}</div>
<div className={styles.label}>Downsample</div>
<div className={styles.value}>{downsamplerType?.label}</div>
<div className={styles.label}>Upsample</div>
<div className={styles.value}>{upsamplerType?.label}</div>
</div>
);
}
const getResampleExpressionViewerStyles = (theme: GrafanaTheme2) => ({
container: css({
padding: theme.spacing(1),
display: 'grid',
gap: theme.spacing(0.5),
gridTemplateColumns: 'repeat(4, 1fr)',
gridTemplateRows: '1fr 1fr',
}),
...getCommonQueryStyles(theme),
});
function ThresholdExpressionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getExpressionViewerStyles);
const { expression, conditions } = model;
const evaluator = conditions && conditions[0]?.evaluator;
const thresholdFunction = thresholdFunctions.find((tf) => tf.value === evaluator?.type);
const isRange = evaluator ? isRangeEvaluator(evaluator) : false;
const unloadEvaluator = conditions && conditions[0]?.unloadEvaluator;
const unloadThresholdFunction = thresholdFunctions.find((tf) => tf.value === unloadEvaluator?.type);
const unloadIsRange = unloadEvaluator ? isRangeEvaluator(unloadEvaluator) : false;
return (
<>
<div className={styles.container}>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
{evaluator && (
<>
<div className={styles.blue}>{thresholdFunction?.label}</div>
<div className={styles.bold}>
{isRange ? `(${evaluator.params[0]}; ${evaluator.params[1]})` : evaluator.params[0]}
</div>
</>
)}
</div>
<div className={styles.container}>
{unloadEvaluator && (
<>
<div className={styles.label}>Stop alerting when </div>
<div className={styles.value}>{expression}</div>
<>
<div className={styles.blue}>{unloadThresholdFunction?.label}</div>
<div className={styles.bold}>
{unloadIsRange
? `(${unloadEvaluator.params[0]}; ${unloadEvaluator.params[1]})`
: unloadEvaluator.params[0]}
</div>
</>
</>
)}
</div>
</>
);
}
const getExpressionViewerStyles = (theme: GrafanaTheme2) => {
const { blue, bold, ...common } = getCommonQueryStyles(theme);
return {
...common,
maxWidthContainer: css({
maxWidth: '100%',
}),
container: css({
padding: theme.spacing(1),
display: 'flex',
gap: theme.spacing(0.5),
}),
blue: css(blue, { margin: 'auto 0' }),
bold: css(bold, { margin: 'auto 0' }),
};
};
function MathExpressionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getExpressionViewerStyles);
const { expression } = model;
return (
<div className={styles.container}>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
</div>
);
}
const getCommonQueryStyles = (theme: GrafanaTheme2) => ({
blue: css({
color: theme.colors.text.link,
}),
bold: css({
fontWeight: theme.typography.fontWeightBold,
}),
label: css({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0.5, 1),
backgroundColor: theme.colors.background.secondary,
fontSize: theme.typography.bodySmall.fontSize,
lineHeight: theme.typography.bodySmall.lineHeight,
fontWeight: theme.typography.fontWeightBold,
borderRadius: theme.shape.radius.default,
}),
value: css({
padding: theme.spacing(0.5, 1),
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});
function isRangeEvaluator(evaluator: { params: number[]; type: EvalFunction }) {
return evaluator.type === EvalFunction.IsWithinRange || evaluator.type === EvalFunction.IsOutsideRange;
}