mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Expressions pipeline redesign (#54601)
This commit is contained in:
parent
222c33c307
commit
87cba8836f
@ -4,6 +4,7 @@ const ds1 = {
|
||||
type: 'prometheus',
|
||||
name: 'gdev-prometheus',
|
||||
meta: {
|
||||
alerting: true,
|
||||
info: {
|
||||
logos: {
|
||||
small: 'http://example.com/logo.png',
|
||||
|
@ -6,7 +6,6 @@ import { byTestId } from 'testing-library-selector';
|
||||
|
||||
import { DataSourceApi } from '@grafana/data';
|
||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { toggleOption } from 'app/features/variables/pickers/OptionsPicker/reducer';
|
||||
@ -302,58 +301,7 @@ describe('PanelAlertTabContent', () => {
|
||||
expect(match).toHaveLength(2);
|
||||
|
||||
const defaults = JSON.parse(decodeURIComponent(match![1]));
|
||||
expect(defaults).toEqual({
|
||||
type: 'grafana',
|
||||
folder: { id: 1, title: 'super folder' },
|
||||
queries: [
|
||||
{
|
||||
refId: 'A',
|
||||
queryType: '',
|
||||
relativeTimeRange: { from: 21600, to: 0 },
|
||||
datasourceUid: 'mock-ds-2',
|
||||
model: {
|
||||
expr: 'sum(some_metric [15s])) by (app)',
|
||||
refId: 'A',
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'mock-ds-2',
|
||||
},
|
||||
interval: '',
|
||||
intervalMs: 15000,
|
||||
},
|
||||
},
|
||||
{
|
||||
refId: 'B',
|
||||
datasourceUid: '-100',
|
||||
queryType: '',
|
||||
model: {
|
||||
refId: 'B',
|
||||
hide: false,
|
||||
expression: 'A',
|
||||
type: 'classic_conditions',
|
||||
datasource: {
|
||||
type: ExpressionDatasourceRef.type,
|
||||
uid: '-100',
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
type: 'query',
|
||||
evaluator: { params: [3], type: 'gt' },
|
||||
operator: { type: 'and' },
|
||||
query: { params: ['A'] },
|
||||
reducer: { params: [], type: 'last' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
name: 'mypanel',
|
||||
condition: 'B',
|
||||
annotations: [
|
||||
{ key: '__dashboardUid__', value: '12' },
|
||||
{ key: '__panelId__', value: '34' },
|
||||
],
|
||||
});
|
||||
expect(defaults).toMatchSnapshot();
|
||||
|
||||
expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith(
|
||||
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
|
||||
|
@ -296,7 +296,7 @@ describe('RuleEditor', () => {
|
||||
labels: { severity: 'warn', team: 'the a-team' },
|
||||
for: '5m',
|
||||
grafana_alert: {
|
||||
condition: 'B',
|
||||
condition: 'C',
|
||||
data: getDefaultQueries(),
|
||||
exec_err_state: GrafanaAlertStateDecision.Error,
|
||||
no_data_state: 'NoData',
|
||||
|
@ -0,0 +1,119 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PanelAlertTabContent Will render alerts belonging to panel and a button to create alert from panel queries 1`] = `
|
||||
Object {
|
||||
"annotations": Array [
|
||||
Object {
|
||||
"key": "__dashboardUid__",
|
||||
"value": "12",
|
||||
},
|
||||
Object {
|
||||
"key": "__panelId__",
|
||||
"value": "34",
|
||||
},
|
||||
],
|
||||
"condition": "C",
|
||||
"folder": Object {
|
||||
"id": 1,
|
||||
"title": "super folder",
|
||||
},
|
||||
"name": "mypanel",
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "mock-ds-2",
|
||||
"model": Object {
|
||||
"datasource": Object {
|
||||
"type": "prometheus",
|
||||
"uid": "mock-ds-2",
|
||||
},
|
||||
"expr": "sum(some_metric [15s])) by (app)",
|
||||
"interval": "",
|
||||
"intervalMs": 15000,
|
||||
"refId": "A",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "A",
|
||||
"relativeTimeRange": Object {
|
||||
"from": 21600,
|
||||
"to": 0,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [
|
||||
"B",
|
||||
],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "last",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"type": "__expr__",
|
||||
"uid": "-100",
|
||||
},
|
||||
"expression": "A",
|
||||
"hide": false,
|
||||
"reducer": "last",
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [
|
||||
0,
|
||||
],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "last",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"type": "__expr__",
|
||||
"uid": "-100",
|
||||
},
|
||||
"expression": "B",
|
||||
"hide": false,
|
||||
"refId": "C",
|
||||
"type": "threshold",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "C",
|
||||
},
|
||||
],
|
||||
"type": "grafana",
|
||||
}
|
||||
`;
|
20
public/app/features/alerting/unified/components/Spacer.tsx
Normal file
20
public/app/features/alerting/unified/components/Spacer.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* A simple "flex: 1;" component you can use in combination with the Stack component(s), like so
|
||||
*
|
||||
* <Stack direction="row">
|
||||
* <span>hello</span>
|
||||
* <Spacer />
|
||||
* <span>world</span>
|
||||
* </Stack>
|
||||
*/
|
||||
|
||||
export const Spacer = () => (
|
||||
<span
|
||||
className={css`
|
||||
flex: 1;
|
||||
`}
|
||||
/>
|
||||
);
|
@ -8,12 +8,13 @@ export type State = 'good' | 'bad' | 'warning' | 'neutral' | 'info';
|
||||
|
||||
type Props = {
|
||||
state: State;
|
||||
size?: 'md' | 'sm';
|
||||
};
|
||||
|
||||
export const StateTag: FC<Props> = ({ children, state }) => {
|
||||
export const StateTag: FC<Props> = ({ children, state, size = 'md' }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return <span className={cx(styles.common, styles[state])}>{children || state}</span>;
|
||||
return <span className={cx(styles.common, styles[state], styles[size])}>{children || state}</span>;
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
@ -22,10 +23,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
color: white;
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
text-transform: capitalize;
|
||||
line-height: 1.2;
|
||||
min-width: ${theme.spacing(8)};
|
||||
text-align: center;
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
`,
|
||||
@ -54,4 +53,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
border: solid 1px ${theme.colors.primary.main};
|
||||
color: ${theme.colors.primary.contrastText};
|
||||
`,
|
||||
md: css`
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
min-width: ${theme.spacing(8)};
|
||||
`,
|
||||
sm: css`
|
||||
padding: ${theme.spacing(0.3, 0.5)};
|
||||
min-width: 52px;
|
||||
`,
|
||||
});
|
||||
|
@ -0,0 +1,54 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Badge, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface AlertConditionProps {
|
||||
enabled?: boolean;
|
||||
error?: Error;
|
||||
warning?: Error;
|
||||
onSetCondition: () => void;
|
||||
}
|
||||
|
||||
export const AlertConditionIndicator: FC<AlertConditionProps> = ({
|
||||
enabled = false,
|
||||
error,
|
||||
warning,
|
||||
onSetCondition,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (enabled && error) {
|
||||
return <Badge color="red" icon="exclamation-circle" text="Alert condition" tooltip={error.message} />;
|
||||
}
|
||||
|
||||
if (enabled && warning) {
|
||||
return <Badge color="orange" icon="exclamation-triangle" text="Alert condition" tooltip={warning.message} />;
|
||||
}
|
||||
|
||||
if (enabled && !error && !warning) {
|
||||
return <Badge color="green" icon="check" text="Alert condition" />;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className={styles.actionLink} onClick={() => onSetCondition()}>
|
||||
Make this the alert condition
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
actionLink: css`
|
||||
color: ${theme.colors.text.link};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
});
|
@ -0,0 +1,449 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { capitalize, uniqueId } from 'lodash';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
|
||||
import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData } from '@grafana/data';
|
||||
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
|
||||
import { AutoSizeInput, Icon, IconButton, Select, 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, gelTypes } from 'app/features/expressions/types';
|
||||
import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { HoverCard } from '../HoverCard';
|
||||
import { Spacer } from '../Spacer';
|
||||
import { AlertStateTag } from '../rules/AlertStateTag';
|
||||
|
||||
import { AlertConditionIndicator } from './AlertConditionIndicator';
|
||||
import { formatLabels, 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 isLoading = data && Object.values(data).some((d) => Boolean(d) && d.state === LoadingState.Loading);
|
||||
const hasResults = Array.isArray(data?.series) && !isLoading;
|
||||
const series = data?.series ?? [];
|
||||
|
||||
// sometime we receive results where every value is just "null" when noData occurs
|
||||
const emptyResults = hasResults && isEmptySeries(series);
|
||||
const isTimeSeriesResults = !emptyResults && isTimeSeries(series);
|
||||
|
||||
const alertCondition = isAlertCondition ?? false;
|
||||
const showSummary = isAlertCondition && hasResults;
|
||||
|
||||
const groupedByState = {
|
||||
[PromAlertingRuleState.Firing]: series.filter((serie) => getSeriesValue(serie) >= 1),
|
||||
[PromAlertingRuleState.Inactive]: series.filter((serie) => getSeriesValue(serie) < 1),
|
||||
};
|
||||
|
||||
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} />;
|
||||
|
||||
default:
|
||||
return <>Expression not supported: {query.type}</>;
|
||||
}
|
||||
},
|
||||
[onChangeQuery, queries]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.expression.wrapper, alertCondition && styles.expression.alertCondition)}>
|
||||
<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)}
|
||||
/>
|
||||
<div className={styles.expression.body}>{renderExpressionType(query)}</div>
|
||||
{hasResults && (
|
||||
<div className={styles.expression.results}>
|
||||
{!emptyResults && isTimeSeriesResults && (
|
||||
<div>
|
||||
{series.map((frame, index) => (
|
||||
<TimeseriesRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!emptyResults &&
|
||||
!isTimeSeriesResults &&
|
||||
series.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={index} isAlertCondition={alertCondition} />
|
||||
))}
|
||||
{emptyResults && <div className={cx(styles.expression.noData, styles.mutedText)}>No data</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<AlertConditionIndicator
|
||||
onSetCondition={() => onSetCondition(query.refId)}
|
||||
enabled={alertCondition}
|
||||
error={error}
|
||||
warning={warning}
|
||||
/>
|
||||
<Spacer />
|
||||
{showSummary && (
|
||||
<PreviewSummary
|
||||
firing={groupedByState[PromAlertingRuleState.Firing].length}
|
||||
normal={groupedByState[PromAlertingRuleState.Inactive].length}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PreviewSummary: FC<{ firing: number; normal: number }> = ({ firing, normal }) => {
|
||||
const { mutedText } = useStyles2(getStyles);
|
||||
return <span className={mutedText}>{`${firing} firing, ${normal} normal`}</span>;
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
refId: string;
|
||||
queryType: ExpressionQueryType;
|
||||
onUpdateRefId: (refId: string) => void;
|
||||
onRemoveExpression: () => void;
|
||||
onUpdateExpressionType: (type: ExpressionQueryType) => void;
|
||||
}
|
||||
|
||||
const Header: FC<HeaderProps> = ({ refId, queryType, onUpdateRefId, onUpdateExpressionType, onRemoveExpression }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
/**
|
||||
* There are 3 edit modes:
|
||||
*
|
||||
* 1. "refId": Editing the refId (ie. A -> B)
|
||||
* 2. "epressionType": 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';
|
||||
const editingType = editing && editMode === 'expressionType';
|
||||
|
||||
const selectedExpressionType = gelTypes.find((o) => o.value === queryType);
|
||||
|
||||
return (
|
||||
<header className={styles.header.wrapper}>
|
||||
<Stack direction="row" gap={0.5} alignItems="center">
|
||||
<Stack direction="row" gap={1} alignItems="center" wrap={false}>
|
||||
{!editingRefId && (
|
||||
<div className={styles.editable} onClick={() => setEditMode('refId')}>
|
||||
<div className={styles.expression.refId}>{refId}</div>
|
||||
</div>
|
||||
)}
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!editingType && (
|
||||
<div className={styles.editable} onClick={() => setEditMode('expressionType')}>
|
||||
<div className={styles.mutedText}>{capitalize(queryType)}</div>
|
||||
<Icon size="xs" name="pen" className={styles.mutedIcon} onClick={() => setEditMode('expressionType')} />
|
||||
</div>
|
||||
)}
|
||||
{editingType && (
|
||||
<Select
|
||||
isOpen
|
||||
autoFocus
|
||||
onChange={(selection) => {
|
||||
onUpdateExpressionType(selection.value ?? ExpressionQueryType.classic);
|
||||
setEditMode(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
options={gelTypes}
|
||||
value={selectedExpressionType}
|
||||
width={25}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
type="button"
|
||||
name="trash-alt"
|
||||
variant="secondary"
|
||||
className={styles.mutedIcon}
|
||||
onClick={onRemoveExpression}
|
||||
/>
|
||||
</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 showFiring = isAlertCondition && value !== 0;
|
||||
const showNormal = isAlertCondition && value === 0;
|
||||
|
||||
return (
|
||||
<div className={styles.expression.resultsRow}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<span className={cx(styles.mutedText, styles.expression.resultLabel)} title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<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 hasLabels = frame.fields[1].labels;
|
||||
const name = hasLabels ? formatLabels(frame.fields[1].labels ?? {}) : 'Series ' + index;
|
||||
|
||||
const timestamps = frame.fields[0].values.toArray();
|
||||
|
||||
const getTimestampFromIndex = (index: number) => frame.fields[0].values.get(index);
|
||||
const getValueFromIndex = (index: number) => frame.fields[1].values.get(index);
|
||||
|
||||
return (
|
||||
<div className={styles.expression.resultsRow}>
|
||||
<Stack direction="row" gap={1} 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};
|
||||
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
max-width: 640px;
|
||||
`,
|
||||
stack: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
min-width: 0; // this one is important to prevent text overflow
|
||||
`,
|
||||
alertCondition: css``,
|
||||
body: css`
|
||||
padding: ${theme.spacing(1)};
|
||||
flex: 1;
|
||||
`,
|
||||
refId: css`
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
color: ${theme.colors.primary.text};
|
||||
`,
|
||||
results: css`
|
||||
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};
|
||||
}
|
||||
`,
|
||||
resultValue: css`
|
||||
color: ${theme.colors.text.maxContrast};
|
||||
text-align: right;
|
||||
`,
|
||||
resultLabel: css`
|
||||
flex: 1;
|
||||
`,
|
||||
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.medium};
|
||||
`,
|
||||
},
|
||||
footer: css`
|
||||
background: ${theme.colors.background.secondary};
|
||||
padding: ${theme.spacing(1)};
|
||||
border-top: solid 1px ${theme.colors.border.medium};
|
||||
`,
|
||||
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.borderRadius()};
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${theme.spacing(1)};
|
||||
|
||||
cursor: pointer;
|
||||
`,
|
||||
timeseriesTableWrapper: css`
|
||||
max-height: 500px;
|
||||
max-width: 300px;
|
||||
|
||||
overflow-y: scroll;
|
||||
|
||||
padding: 0 !important; // not sure why but style override doesn't work otherwise :( (Gilles)
|
||||
`,
|
||||
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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
@ -0,0 +1,31 @@
|
||||
import { DataFrame, Labels, roundDecimals } from '@grafana/data';
|
||||
|
||||
const getSeriesName = (frame: DataFrame): string => {
|
||||
return frame.name ?? formatLabels(frame.fields[0].labels ?? {});
|
||||
};
|
||||
|
||||
const getSeriesValue = (frame: DataFrame) => {
|
||||
const value = frame.fields[0].values.get(0);
|
||||
|
||||
if (Number.isFinite(value)) {
|
||||
return roundDecimals(value, 5);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const formatLabels = (labels: Labels): string => {
|
||||
return Object.entries(labels)
|
||||
.map(([key, value]) => key + '=' + value)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const isEmptySeries = (series: DataFrame[]): boolean => {
|
||||
const isEmpty = series.every((serie) =>
|
||||
serie.fields.every((field) => field.values.toArray().every((value) => value == null))
|
||||
);
|
||||
|
||||
return isEmpty;
|
||||
};
|
||||
|
||||
export { getSeriesName, getSeriesValue, formatLabels, isEmptySeries };
|
@ -25,7 +25,7 @@ import { DetailsStep } from './DetailsStep';
|
||||
import { GrafanaEvaluationBehavior } from './GrafanaEvaluationBehavior';
|
||||
import { NotificationsStep } from './NotificationsStep';
|
||||
import { RuleInspector } from './RuleInspector';
|
||||
import { QueryAndAlertConditionStep } from './query-and-alert-condition/QueryAndAlertConditionStep';
|
||||
import { QueryAndExpressionsStep } from './query-and-alert-condition/QueryAndExpressionsStep';
|
||||
|
||||
type Props = {
|
||||
existing?: RuleWithLocation;
|
||||
@ -48,6 +48,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||
return {
|
||||
...getDefaultFormValues(),
|
||||
queries: getDefaultQueries(),
|
||||
condition: 'C',
|
||||
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
|
||||
type: RuleFormType.grafana,
|
||||
};
|
||||
@ -159,7 +160,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||
<div className={styles.contentOuter}>
|
||||
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
|
||||
<div className={styles.contentInner}>
|
||||
<QueryAndAlertConditionStep editingExistingRule={!!existing} />
|
||||
<QueryAndExpressionsStep editingExistingRule={!!existing} />
|
||||
{showStep2 && (
|
||||
<>
|
||||
{type === RuleFormType.grafana ? <GrafanaEvaluationBehavior /> : <CloudEvaluationBehavior />}
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React, { FC } from 'react';
|
||||
import { FormProvider, useForm, UseFormProps } from 'react-hook-form';
|
||||
|
||||
import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
|
||||
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
|
||||
import { ConditionField } from './ConditionField';
|
||||
|
||||
const FormProviderWrapper: FC<UseFormProps> = ({ children, ...props }) => {
|
||||
const methods = useForm({ ...props });
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
};
|
||||
|
||||
describe('ConditionField', () => {
|
||||
it('should render the correct condition when editing existing rule', () => {
|
||||
const existingRule = {
|
||||
name: 'ConditionsTest',
|
||||
condition: 'B',
|
||||
queries: [
|
||||
{ refId: 'A' },
|
||||
{ refId: 'B', datasourceUid: ExpressionDatasourceUID },
|
||||
{ refId: 'C', datasourceUid: ExpressionDatasourceUID },
|
||||
],
|
||||
} as RuleFormValues;
|
||||
|
||||
const form = (
|
||||
<FormProviderWrapper defaultValues={existingRule}>
|
||||
<ConditionField existing={true} />
|
||||
</FormProviderWrapper>
|
||||
);
|
||||
|
||||
render(form);
|
||||
expect(screen.getByLabelText(/^A/)).not.toBeChecked();
|
||||
expect(screen.getByLabelText(/^B/)).toBeChecked();
|
||||
expect(screen.getByLabelText(/^C/)).not.toBeChecked();
|
||||
});
|
||||
});
|
@ -1,95 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { last } from 'lodash';
|
||||
import React, { FC, useEffect, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Alert, Card, Field, InputControl, RadioButtonList, useStyles2 } from '@grafana/ui';
|
||||
import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
|
||||
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
|
||||
interface Props {
|
||||
existing?: boolean;
|
||||
}
|
||||
|
||||
export const ConditionField: FC<Props> = ({ existing = false }) => {
|
||||
const {
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const queries = watch('queries');
|
||||
const condition = watch('condition');
|
||||
|
||||
const expressions = useMemo(() => {
|
||||
return queries.filter((query) => query.datasourceUid === ExpressionDatasourceUID);
|
||||
}, [queries]);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
queries
|
||||
.filter((q) => !!q.refId)
|
||||
.map<SelectableValue<string>>((q) => ({
|
||||
value: q.refId,
|
||||
label: `${q.refId} - ${expressions.includes(q) ? 'expression' : 'query'}`,
|
||||
})),
|
||||
[queries, expressions]
|
||||
);
|
||||
|
||||
// automatically use the last expression when new expressions have been added
|
||||
useEffect(() => {
|
||||
const lastExpression = last(expressions);
|
||||
if (lastExpression && !existing) {
|
||||
setValue('condition', lastExpression.refId, { shouldValidate: true });
|
||||
}
|
||||
}, [expressions, setValue, existing]);
|
||||
|
||||
// reset condition if option no longer exists or if it is unset, but there are options available
|
||||
useEffect(() => {
|
||||
const lastExpression = last(expressions);
|
||||
const conditionExists = options.find(({ value }) => value === condition);
|
||||
|
||||
if (condition && !conditionExists) {
|
||||
setValue('condition', lastExpression?.refId ?? null);
|
||||
} else if (!condition && lastExpression) {
|
||||
setValue('condition', lastExpression.refId, { shouldValidate: true });
|
||||
}
|
||||
}, [condition, expressions, options, setValue]);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return options.length ? (
|
||||
<Card className={styles.container}>
|
||||
<Card.Heading>Set alert condition</Card.Heading>
|
||||
<Card.Meta>Select one of your queries or expressions set above that contains your alert condition.</Card.Meta>
|
||||
<Card.Actions>
|
||||
<Field error={errors.condition?.message} invalid={!!errors.condition?.message}>
|
||||
<InputControl
|
||||
name="condition"
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<RadioButtonList options={options} onChange={onChange} {...field} />
|
||||
)}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Please select the condition to alert on',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
) : (
|
||||
<Alert title="No queries or expressions have been configured" severity="warning" className={styles.container}>
|
||||
Create at least one query or expression to be alerted on
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
max-width: ${theme.breakpoints.values.sm}px;
|
||||
`,
|
||||
});
|
@ -0,0 +1,68 @@
|
||||
import React, { FC, useMemo } from 'react';
|
||||
|
||||
import { PanelData } from '@grafana/data';
|
||||
import { Stack } from '@grafana/ui';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { Expression } from '../expressions/Expression';
|
||||
|
||||
import { errorFromSeries, warningFromSeries } from './util';
|
||||
|
||||
interface Props {
|
||||
condition: string | null;
|
||||
onSetCondition: (refId: string) => void;
|
||||
panelData: Record<string, PanelData | undefined>;
|
||||
queries: AlertQuery[];
|
||||
onRemoveExpression: (refId: string) => void;
|
||||
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
|
||||
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
|
||||
onUpdateQueryExpression: (query: ExpressionQuery) => void;
|
||||
}
|
||||
|
||||
export const ExpressionsEditor: FC<Props> = ({
|
||||
condition,
|
||||
onSetCondition,
|
||||
queries,
|
||||
panelData,
|
||||
onUpdateRefId,
|
||||
onRemoveExpression,
|
||||
onUpdateExpressionType,
|
||||
onUpdateQueryExpression,
|
||||
}) => {
|
||||
const expressionQueries = useMemo(() => {
|
||||
return queries.reduce((acc: ExpressionQuery[], query) => {
|
||||
return isExpressionQuery(query.model) ? acc.concat(query.model) : acc;
|
||||
}, []);
|
||||
}, [queries]);
|
||||
|
||||
return (
|
||||
<Stack direction="row" alignItems="stretch">
|
||||
{expressionQueries.map((query) => {
|
||||
const data = panelData[query.refId];
|
||||
|
||||
const isAlertCondition = condition === query.refId;
|
||||
const error = isAlertCondition && data ? errorFromSeries(data.series) : undefined;
|
||||
const warning = isAlertCondition && data ? warningFromSeries(data.series) : undefined;
|
||||
|
||||
return (
|
||||
<Expression
|
||||
key={query.refId}
|
||||
isAlertCondition={isAlertCondition}
|
||||
data={data}
|
||||
error={error}
|
||||
warning={warning}
|
||||
queries={queries}
|
||||
query={query}
|
||||
onSetCondition={onSetCondition}
|
||||
onRemoveExpression={onRemoveExpression}
|
||||
onUpdateRefId={onUpdateRefId}
|
||||
onUpdateExpressionType={onUpdateExpressionType}
|
||||
onChangeQuery={onUpdateQueryExpression}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
@ -16,7 +16,6 @@ import { CollapseToggle } from '../CollapseToggle';
|
||||
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
|
||||
|
||||
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
||||
import { PreviewRule } from './PreviewRule';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
|
||||
const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
|
||||
@ -161,7 +160,6 @@ export const GrafanaEvaluationBehavior: FC = () => {
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<PreviewRule />
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
||||
|
@ -1,111 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { byLabelText, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { getDefaultRelativeTimeRange } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { setDataSourceSrv } from '@grafana/runtime';
|
||||
|
||||
import { MockDataSourceApi } from '../../../../../../test/mocks/datasource_srv';
|
||||
import { ExpressionDatasourceUID, instanceSettings } from '../../../../expressions/ExpressionDatasource';
|
||||
import { mockDataSource, MockDataSourceSrv } from '../../mocks';
|
||||
import { getDefaultQueries } from '../../utils/rule-form';
|
||||
|
||||
import { QueryEditor } from './QueryEditor';
|
||||
|
||||
const ui = {
|
||||
queryNames: byTestId<HTMLButtonElement>('query-name-div'),
|
||||
dataSourcePicker: byLabelText<HTMLDivElement>(selectors.components.DataSourcePicker.container),
|
||||
noDataSourcesWarning: byText('You appear to have no compatible data sources'),
|
||||
};
|
||||
|
||||
const onChangeMock = jest.fn();
|
||||
describe('Query Editor', () => {
|
||||
it('should maintain the original query time range when duplicating it', () => {
|
||||
const query = {
|
||||
refId: 'A',
|
||||
queryType: '',
|
||||
datasourceUid: '',
|
||||
model: { refId: 'A', hide: false },
|
||||
relativeTimeRange: { from: 100, to: 0 },
|
||||
};
|
||||
const queryEditor = new QueryEditor({
|
||||
onChange: onChangeMock,
|
||||
value: [query],
|
||||
});
|
||||
|
||||
queryEditor.onDuplicateQuery(query);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith([
|
||||
query,
|
||||
{ ...query, ...{ refId: 'B', model: { refId: 'B', hide: false } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use the default query time range if none is set when duplicating a query', () => {
|
||||
const query = {
|
||||
refId: 'A',
|
||||
queryType: '',
|
||||
datasourceUid: '',
|
||||
model: { refId: 'A', hide: false },
|
||||
};
|
||||
const queryEditor = new QueryEditor({
|
||||
onChange: onChangeMock,
|
||||
value: [query],
|
||||
});
|
||||
|
||||
queryEditor.onDuplicateQuery(query);
|
||||
|
||||
const defaultRange = getDefaultRelativeTimeRange();
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith([
|
||||
query,
|
||||
{ ...query, ...{ refId: 'B', relativeTimeRange: defaultRange, model: { refId: 'B', hide: false } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should select first data source supporting alerting when there is no default data source', async () => {
|
||||
const dsServer = new MockDataSourceSrv({
|
||||
influx: mockDataSource({ name: 'influx' }, { alerting: true }),
|
||||
postgres: mockDataSource({ name: 'postgres' }, { alerting: true }),
|
||||
[ExpressionDatasourceUID]: instanceSettings,
|
||||
});
|
||||
dsServer.get = () => Promise.resolve(new MockDataSourceApi());
|
||||
|
||||
setDataSourceSrv(dsServer);
|
||||
|
||||
const defaultQueries = getDefaultQueries();
|
||||
|
||||
render(<QueryEditor onChange={() => null} value={defaultQueries} />);
|
||||
|
||||
const queryRef = await ui.queryNames.findAll();
|
||||
const select = await ui.dataSourcePicker.find();
|
||||
|
||||
expect(queryRef).toHaveLength(2);
|
||||
expect(queryRef[0]).toHaveTextContent('A');
|
||||
expect(queryRef[1]).toHaveTextContent('B');
|
||||
expect(select).toHaveTextContent('influx'); // Alphabetical order
|
||||
expect(ui.noDataSourcesWarning.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select the default data source when specified', async () => {
|
||||
const dsServer = new MockDataSourceSrv({
|
||||
influx: mockDataSource({ name: 'influx' }, { alerting: true }),
|
||||
postgres: mockDataSource({ name: 'postgres', isDefault: true }, { alerting: true }),
|
||||
[ExpressionDatasourceUID]: instanceSettings,
|
||||
});
|
||||
dsServer.get = () => Promise.resolve(new MockDataSourceApi());
|
||||
|
||||
setDataSourceSrv(dsServer);
|
||||
|
||||
const defaultQueries = getDefaultQueries();
|
||||
|
||||
render(<QueryEditor onChange={() => null} value={defaultQueries} />);
|
||||
|
||||
const queryRef = await ui.queryNames.findAll();
|
||||
const select = await ui.dataSourcePicker.find();
|
||||
|
||||
expect(queryRef).toHaveLength(2);
|
||||
expect(select).toHaveTextContent('postgres'); // Default data source
|
||||
});
|
||||
});
|
@ -1,227 +1,52 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { PureComponent } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import {
|
||||
DataQuery,
|
||||
getDefaultRelativeTimeRange,
|
||||
GrafanaTheme2,
|
||||
LoadingState,
|
||||
PanelData,
|
||||
RelativeTimeRange,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, HorizontalGroup, stylesFactory, Tooltip } from '@grafana/ui';
|
||||
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||
import {
|
||||
dataSource as expressionDatasource,
|
||||
ExpressionDatasourceUID,
|
||||
} from 'app/features/expressions/ExpressionDatasource';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
|
||||
import { GrafanaTheme2, PanelData } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { AlertingQueryRunner } from '../../state/AlertingQueryRunner';
|
||||
import { getDefaultOrFirstCompatibleDataSource } from '../../utils/datasource';
|
||||
|
||||
import { QueryRows } from './QueryRows';
|
||||
|
||||
interface Props {
|
||||
value?: AlertQuery[];
|
||||
onChange: (queries: AlertQuery[]) => void;
|
||||
panelData: Record<string, PanelData>;
|
||||
queries: AlertQuery[];
|
||||
onRunQueries: () => void;
|
||||
onChangeQueries: (queries: AlertQuery[]) => void;
|
||||
onDuplicateQuery: (query: AlertQuery) => void;
|
||||
condition: string | null;
|
||||
onSetCondition: (refId: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
panelDataByRefId: Record<string, PanelData>;
|
||||
}
|
||||
export const QueryEditor: FC<Props> = ({
|
||||
queries,
|
||||
panelData,
|
||||
onRunQueries,
|
||||
onChangeQueries,
|
||||
onDuplicateQuery,
|
||||
condition,
|
||||
onSetCondition,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
export class QueryEditor extends PureComponent<Props, State> {
|
||||
private runner: AlertingQueryRunner;
|
||||
private queries: AlertQuery[];
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { panelDataByRefId: {} };
|
||||
this.runner = new AlertingQueryRunner();
|
||||
this.queries = props.value ?? [];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.runner.get().subscribe((data) => {
|
||||
this.setState({ panelDataByRefId: data });
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.runner.destroy();
|
||||
}
|
||||
|
||||
onRunQueries = () => {
|
||||
const { queries } = this;
|
||||
this.runner.run(queries);
|
||||
};
|
||||
|
||||
onCancelQueries = () => {
|
||||
this.runner.cancel();
|
||||
};
|
||||
|
||||
onChangeQueries = (queries: AlertQuery[]) => {
|
||||
this.queries = queries;
|
||||
this.props.onChange(queries);
|
||||
};
|
||||
|
||||
onDuplicateQuery = (query: AlertQuery) => {
|
||||
const { queries } = this;
|
||||
this.onChangeQueries(addQuery(queries, query));
|
||||
};
|
||||
|
||||
onNewAlertingQuery = () => {
|
||||
const { queries } = this;
|
||||
const datasource = getDefaultOrFirstCompatibleDataSource();
|
||||
|
||||
if (!datasource) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onChangeQueries(
|
||||
addQuery(queries, {
|
||||
datasourceUid: datasource.uid,
|
||||
model: {
|
||||
refId: '',
|
||||
datasource: {
|
||||
type: datasource.type,
|
||||
uid: datasource.uid,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
onNewExpressionQuery = () => {
|
||||
const { queries } = this;
|
||||
|
||||
const lastQuery = queries.at(-1);
|
||||
const defaultParams = lastQuery ? [lastQuery.refId] : [];
|
||||
|
||||
this.onChangeQueries(
|
||||
addQuery(queries, {
|
||||
datasourceUid: ExpressionDatasourceUID,
|
||||
model: expressionDatasource.newQuery({
|
||||
type: ExpressionQueryType.classic,
|
||||
conditions: [{ ...defaultCondition, query: { params: defaultParams } }],
|
||||
expression: lastQuery?.refId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
isRunning() {
|
||||
const data = Object.values(this.state.panelDataByRefId).find((d) => Boolean(d));
|
||||
return data?.state === LoadingState.Loading;
|
||||
}
|
||||
|
||||
renderRunQueryButton() {
|
||||
const isRunning = this.isRunning();
|
||||
|
||||
if (isRunning) {
|
||||
return (
|
||||
<Button icon="fa fa-spinner" type="button" variant="destructive" onClick={this.onCancelQueries}>
|
||||
Cancel
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button icon="sync" type="button" onClick={this.onRunQueries}>
|
||||
Run queries
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value = [] } = this.props;
|
||||
const { panelDataByRefId } = this.state;
|
||||
const styles = getStyles(config.theme2);
|
||||
|
||||
const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<QueryRows
|
||||
data={panelDataByRefId}
|
||||
queries={value}
|
||||
onQueriesChange={this.onChangeQueries}
|
||||
onDuplicateQuery={this.onDuplicateQuery}
|
||||
onRunQueries={this.onRunQueries}
|
||||
/>
|
||||
<HorizontalGroup spacing="sm" align="flex-start">
|
||||
<Tooltip content={'You appear to have no compatible data sources'} show={noCompatibleDataSources}>
|
||||
<Button
|
||||
type="button"
|
||||
icon="plus"
|
||||
onClick={this.onNewAlertingQuery}
|
||||
variant="secondary"
|
||||
aria-label={selectors.components.QueryTab.addQuery}
|
||||
disabled={noCompatibleDataSources}
|
||||
>
|
||||
Add query
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{config.expressionsEnabled && (
|
||||
<Button type="button" icon="plus" onClick={this.onNewExpressionQuery} variant="secondary">
|
||||
Add expression
|
||||
</Button>
|
||||
)}
|
||||
{this.renderRunQueryButton()}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const addQuery = (
|
||||
queries: AlertQuery[],
|
||||
queryToAdd: Pick<AlertQuery, 'model' | 'datasourceUid' | 'relativeTimeRange'>
|
||||
): AlertQuery[] => {
|
||||
const refId = getNextRefIdChar(queries);
|
||||
|
||||
const query: AlertQuery = {
|
||||
...queryToAdd,
|
||||
refId,
|
||||
queryType: '',
|
||||
model: {
|
||||
...queryToAdd.model,
|
||||
hide: false,
|
||||
refId,
|
||||
},
|
||||
relativeTimeRange: queryToAdd.relativeTimeRange || defaultTimeRange(queryToAdd.model),
|
||||
};
|
||||
|
||||
return [...queries, query];
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<QueryRows
|
||||
data={panelData}
|
||||
queries={queries}
|
||||
onRunQueries={onRunQueries}
|
||||
onQueriesChange={onChangeQueries}
|
||||
onDuplicateQuery={onDuplicateQuery}
|
||||
condition={condition}
|
||||
onSetCondition={onSetCondition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultTimeRange = (model: DataQuery): RelativeTimeRange | undefined => {
|
||||
if (isExpressionQuery(model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getDefaultRelativeTimeRange();
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css`
|
||||
background-color: ${theme.colors.background.primary};
|
||||
height: 100%;
|
||||
max-width: ${theme.breakpoints.values.xxl}px;
|
||||
`,
|
||||
runWrapper: css`
|
||||
margin-top: ${theme.spacing(1)};
|
||||
`,
|
||||
editorWrapper: css`
|
||||
border: 1px solid ${theme.colors.border.medium};
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
`,
|
||||
};
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
background-color: ${theme.colors.background.primary};
|
||||
height: 100%;
|
||||
max-width: ${theme.breakpoints.values.xxl}px;
|
||||
`,
|
||||
});
|
||||
|
@ -19,36 +19,29 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { EmptyQueryWrapper, QueryWrapper } from './QueryWrapper';
|
||||
import { queriesWithUpdatedReferences } from './util';
|
||||
import { errorFromSeries } from './util';
|
||||
|
||||
interface Props {
|
||||
// The query configuration
|
||||
queries: AlertQuery[];
|
||||
data: Record<string, PanelData>;
|
||||
onRunQueries: () => void;
|
||||
|
||||
// Query editing
|
||||
onQueriesChange: (queries: AlertQuery[]) => void;
|
||||
onDuplicateQuery: (query: AlertQuery) => void;
|
||||
onRunQueries: () => void;
|
||||
condition: string | null;
|
||||
onSetCondition: (refId: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
dataPerQuery: Record<string, PanelData>;
|
||||
}
|
||||
|
||||
export class QueryRows extends PureComponent<Props, State> {
|
||||
export class QueryRows extends PureComponent<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = { dataPerQuery: {} };
|
||||
}
|
||||
|
||||
onRemoveQuery = (query: DataQuery) => {
|
||||
this.props.onQueriesChange(
|
||||
this.props.queries.filter((item) => {
|
||||
return item.model.refId !== query.refId;
|
||||
})
|
||||
);
|
||||
const { queries, onQueriesChange } = this.props;
|
||||
onQueriesChange(queries.filter((q) => q.refId !== query.refId));
|
||||
};
|
||||
|
||||
onChangeTimeRange = (timeRange: RelativeTimeRange, index: number) => {
|
||||
@ -119,12 +112,8 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
onChangeQuery = (query: DataQuery, index: number) => {
|
||||
const { queries, onQueriesChange } = this.props;
|
||||
|
||||
// find what queries still have a reference to the old name
|
||||
const previousRefId = queries[index].refId;
|
||||
const newRefId = query.refId;
|
||||
|
||||
onQueriesChange(
|
||||
queriesWithUpdatedReferences(queries, previousRefId, newRefId).map((item, itemIndex) => {
|
||||
queries.map((item, itemIndex) => {
|
||||
if (itemIndex !== index) {
|
||||
return item;
|
||||
}
|
||||
@ -162,13 +151,6 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
onQueriesChange(update);
|
||||
};
|
||||
|
||||
onDuplicateQuery = (query: DataQuery, source: AlertQuery): void => {
|
||||
this.props.onDuplicateQuery({
|
||||
...source,
|
||||
model: query,
|
||||
});
|
||||
};
|
||||
|
||||
getDataSourceSettings = (query: AlertQuery): DataSourceInstanceSettings | undefined => {
|
||||
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
|
||||
};
|
||||
@ -218,7 +200,7 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onDuplicateQuery, onRunQueries, queries } = this.props;
|
||||
const { queries } = this.props;
|
||||
const thresholdByRefId = this.getThresholdsForQueries(queries);
|
||||
|
||||
return (
|
||||
@ -234,6 +216,9 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
};
|
||||
const dsSettings = this.getDataSourceSettings(query);
|
||||
|
||||
const isAlertCondition = this.props.condition === query.refId;
|
||||
const error = isAlertCondition ? errorFromSeries(data.series) : undefined;
|
||||
|
||||
if (!dsSettings) {
|
||||
return (
|
||||
<DatasourceNotFound
|
||||
@ -259,16 +244,19 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
key={query.refId}
|
||||
dsSettings={dsSettings}
|
||||
data={data}
|
||||
error={error}
|
||||
query={query}
|
||||
onChangeQuery={this.onChangeQuery}
|
||||
onRemoveQuery={this.onRemoveQuery}
|
||||
queries={queries}
|
||||
onChangeDataSource={this.onChangeDataSource}
|
||||
onDuplicateQuery={onDuplicateQuery}
|
||||
onRunQueries={onRunQueries}
|
||||
onDuplicateQuery={this.props.onDuplicateQuery}
|
||||
onChangeTimeRange={this.onChangeTimeRange}
|
||||
thresholds={thresholdByRefId[query.refId]}
|
||||
onChangeThreshold={this.onChangeThreshold}
|
||||
onRunQueries={this.props.onRunQueries}
|
||||
condition={this.props.condition}
|
||||
onSetCondition={this.props.onSetCondition}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -13,18 +13,20 @@ import {
|
||||
RelativeTimeRange,
|
||||
ThresholdsConfig,
|
||||
} from '@grafana/data';
|
||||
import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon } from '@grafana/ui';
|
||||
import { RelativeTimeRangePicker, useStyles2, Tooltip, Icon, Stack } from '@grafana/ui';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { TABLE, TIMESERIES } from '../../utils/constants';
|
||||
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
|
||||
import { AlertConditionIndicator } from '../expressions/AlertConditionIndicator';
|
||||
|
||||
import { VizWrapper } from './VizWrapper';
|
||||
|
||||
interface Props {
|
||||
data: PanelData;
|
||||
error?: Error;
|
||||
query: AlertQuery;
|
||||
queries: AlertQuery[];
|
||||
dsSettings: DataSourceInstanceSettings;
|
||||
@ -37,10 +39,13 @@ interface Props {
|
||||
index: number;
|
||||
thresholds: ThresholdsConfig;
|
||||
onChangeThreshold: (thresholds: ThresholdsConfig, index: number) => void;
|
||||
condition: string | null;
|
||||
onSetCondition: (refId: string) => void;
|
||||
}
|
||||
|
||||
export const QueryWrapper: FC<Props> = ({
|
||||
data,
|
||||
error,
|
||||
dsSettings,
|
||||
index,
|
||||
onChangeDataSource,
|
||||
@ -53,6 +58,8 @@ export const QueryWrapper: FC<Props> = ({
|
||||
queries,
|
||||
thresholds,
|
||||
onChangeThreshold,
|
||||
condition,
|
||||
onSetCondition,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const isExpression = isExpressionQuery(query.model);
|
||||
@ -84,12 +91,13 @@ export const QueryWrapper: FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderExtras({ query, index }: { query: AlertQuery; index: number }) {
|
||||
// TODO add a warning label here too when the data looks like time series data and is used as an alert condition
|
||||
function HeaderExtras({ query, error, index }: { query: AlertQuery; error?: Error; index: number }) {
|
||||
if (isExpressionQuery(query.model)) {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<SelectingDataSourceTooltip />
|
||||
{onChangeTimeRange && (
|
||||
<RelativeTimeRangePicker
|
||||
@ -97,7 +105,12 @@ export const QueryWrapper: FC<Props> = ({
|
||||
onChange={(range) => onChangeTimeRange(range, index)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<AlertConditionIndicator
|
||||
onSetCondition={() => onSetCondition(query.refId)}
|
||||
enabled={condition === query.refId}
|
||||
error={error}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -118,7 +131,7 @@ export const QueryWrapper: FC<Props> = ({
|
||||
onAddQuery={() => onDuplicateQuery(cloneDeep(query))}
|
||||
onRunQuery={onRunQueries}
|
||||
queries={queries}
|
||||
renderHeaderExtras={() => <HeaderExtras query={query} index={index} />}
|
||||
renderHeaderExtras={() => <HeaderExtras query={query} index={index} error={error} />}
|
||||
app={CoreApp.UnifiedAlerting}
|
||||
visualization={
|
||||
data.state !== LoadingState.NotStarted ? (
|
||||
@ -152,7 +165,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
dsTooltip: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: ${theme.spacing(2)};
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
cursor: pointer;
|
||||
|
@ -1,61 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Field, InputControl } from '@grafana/ui';
|
||||
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { ExpressionEditor } from '../ExpressionEditor';
|
||||
import { QueryEditor } from '../QueryEditor';
|
||||
|
||||
export const Query: FC = () => {
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const [type, dataSourceName] = watch(['type', 'dataSourceName']);
|
||||
|
||||
const isGrafanaManagedType = type === RuleFormType.grafana;
|
||||
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
|
||||
const isRecordingRuleType = type === RuleFormType.cloudRecording;
|
||||
|
||||
const showCloudExpressionEditor = (isRecordingRuleType || isCloudAlertRuleType) && dataSourceName;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* This is the PromQL Editor for Cloud rules and recording rules */}
|
||||
{showCloudExpressionEditor && (
|
||||
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
|
||||
<InputControl
|
||||
name="expression"
|
||||
render={({ field: { ref, ...field } }) => {
|
||||
return <ExpressionEditor {...field} dataSourceName={dataSourceName} />;
|
||||
}}
|
||||
control={control}
|
||||
rules={{
|
||||
required: { value: true, message: 'A valid expression is required' },
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* This is the editor for Grafana managed rules */}
|
||||
{isGrafanaManagedType && (
|
||||
<Field
|
||||
invalid={!!errors.queries}
|
||||
error={(!!errors.queries && 'Must provide at least one valid query.') || undefined}
|
||||
>
|
||||
<InputControl
|
||||
name="queries"
|
||||
render={({ field: { ref, ...field } }) => <QueryEditor {...field} />}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (queries) => Array.isArray(queries) && !!queries.length,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,28 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { ConditionField } from '../ConditionField';
|
||||
import { RuleEditorSection } from '../RuleEditorSection';
|
||||
|
||||
import { AlertType } from './AlertType';
|
||||
import { Query } from './Query';
|
||||
|
||||
interface Props {
|
||||
editingExistingRule: boolean;
|
||||
}
|
||||
|
||||
export const QueryAndAlertConditionStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
const { watch } = useFormContext<RuleFormValues>();
|
||||
|
||||
const type = watch('type');
|
||||
const isGrafanaManagedType = type === RuleFormType.grafana;
|
||||
|
||||
return (
|
||||
<RuleEditorSection stepNo={1} title="Set a query and alert condition">
|
||||
<AlertType editingExistingRule={editingExistingRule} />
|
||||
{type && <Query />}
|
||||
{isGrafanaManagedType && <ConditionField existing={editingExistingRule} />}
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
@ -0,0 +1,253 @@
|
||||
import React, { FC, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { LoadingState, PanelData } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, Button, Field, InputControl, Stack, Tooltip } from '@grafana/ui';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { AlertingQueryRunner } from '../../../state/AlertingQueryRunner';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
|
||||
import { ExpressionEditor } from '../ExpressionEditor';
|
||||
import { ExpressionsEditor } from '../ExpressionsEditor';
|
||||
import { QueryEditor } from '../QueryEditor';
|
||||
import { RuleEditorSection } from '../RuleEditorSection';
|
||||
import { refIdExists } from '../util';
|
||||
|
||||
import { AlertType } from './AlertType';
|
||||
import {
|
||||
duplicateQuery,
|
||||
addNewDataQuery,
|
||||
addNewExpression,
|
||||
queriesAndExpressionsReducer,
|
||||
removeExpression,
|
||||
rewireExpressions,
|
||||
setDataQueries,
|
||||
updateExpression,
|
||||
updateExpressionRefId,
|
||||
updateExpressionType,
|
||||
} from './reducer';
|
||||
|
||||
interface Props {
|
||||
editingExistingRule: boolean;
|
||||
}
|
||||
|
||||
export const QueryAndExpressionsStep: FC<Props> = ({ editingExistingRule }) => {
|
||||
const runner = useRef(new AlertingQueryRunner());
|
||||
const {
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
formState: { errors },
|
||||
control,
|
||||
} = useFormContext<RuleFormValues>();
|
||||
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
|
||||
|
||||
const initialState = {
|
||||
queries: getValues('queries'),
|
||||
panelData: {},
|
||||
};
|
||||
const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
|
||||
|
||||
const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
|
||||
|
||||
const isGrafanaManagedType = type === RuleFormType.grafana;
|
||||
const isCloudAlertRuleType = type === RuleFormType.cloudAlerting;
|
||||
const isRecordingRuleType = type === RuleFormType.cloudRecording;
|
||||
|
||||
const showCloudExpressionEditor = (isRecordingRuleType || isCloudAlertRuleType) && dataSourceName;
|
||||
|
||||
const cancelQueries = useCallback(() => {
|
||||
runner.current.cancel();
|
||||
}, []);
|
||||
|
||||
const runQueries = useCallback(() => {
|
||||
runner.current.run(queries);
|
||||
}, [queries]);
|
||||
|
||||
// whenever we update the queries we have to update the form too
|
||||
useEffect(() => {
|
||||
setValue('queries', queries, { shouldValidate: false });
|
||||
}, [queries, runQueries, setValue]);
|
||||
|
||||
// set up the AlertQueryRunner
|
||||
useEffect(() => {
|
||||
const currentRunner = runner.current;
|
||||
|
||||
runner.current.get().subscribe((data) => {
|
||||
setPanelData(data);
|
||||
});
|
||||
|
||||
return () => currentRunner.destroy();
|
||||
}, []);
|
||||
|
||||
const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined;
|
||||
|
||||
const isDataLoading = useMemo(() => {
|
||||
return Object.values(panelData).some((d) => d.state === LoadingState.Loading);
|
||||
}, [panelData]);
|
||||
|
||||
// data queries only
|
||||
const dataQueries = useMemo(() => {
|
||||
return queries.filter((query) => !isExpressionQuery(query.model));
|
||||
}, [queries]);
|
||||
|
||||
const emptyQueries = queries.length === 0;
|
||||
|
||||
const onUpdateRefId = useCallback(
|
||||
(oldRefId: string, newRefId: string) => {
|
||||
const newRefIdExists = refIdExists(queries, newRefId);
|
||||
// TODO we should set an error and explain what went wrong instead of just refusing to update
|
||||
if (newRefIdExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(updateExpressionRefId({ oldRefId, newRefId }));
|
||||
|
||||
// update condition too if refId was updated
|
||||
if (condition === oldRefId) {
|
||||
setValue('condition', newRefId);
|
||||
}
|
||||
},
|
||||
[condition, queries, setValue]
|
||||
);
|
||||
|
||||
const onChangeQueries = useCallback(
|
||||
(updatedQueries: AlertQuery[]) => {
|
||||
dispatch(setDataQueries(updatedQueries));
|
||||
|
||||
// check if we need to rewire expressions
|
||||
updatedQueries.forEach((query, index) => {
|
||||
const oldRefId = queries[index].refId;
|
||||
const newRefId = query.refId;
|
||||
|
||||
if (oldRefId !== newRefId) {
|
||||
dispatch(rewireExpressions({ oldRefId, newRefId }));
|
||||
}
|
||||
});
|
||||
},
|
||||
[queries]
|
||||
);
|
||||
|
||||
const onDuplicateQuery = useCallback((query: AlertQuery) => {
|
||||
dispatch(duplicateQuery(query));
|
||||
}, []);
|
||||
|
||||
// update the condition if it's been removed
|
||||
useEffect(() => {
|
||||
if (!refIdExists(queries, condition)) {
|
||||
const lastRefId = queries.at(-1)?.refId ?? null;
|
||||
setValue('condition', lastRefId);
|
||||
}
|
||||
}, [condition, queries, setValue]);
|
||||
|
||||
return (
|
||||
<RuleEditorSection stepNo={1} title="Set a query and alert condition">
|
||||
<AlertType editingExistingRule={editingExistingRule} />
|
||||
|
||||
{/* This is the PromQL Editor for Cloud rules and recording rules */}
|
||||
{showCloudExpressionEditor && (
|
||||
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
|
||||
<InputControl
|
||||
name="expression"
|
||||
render={({ field: { ref, ...field } }) => {
|
||||
return <ExpressionEditor {...field} dataSourceName={dataSourceName} />;
|
||||
}}
|
||||
control={control}
|
||||
rules={{
|
||||
required: { value: true, message: 'A valid expression is required' },
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* This is the editor for Grafana managed rules */}
|
||||
{isGrafanaManagedType && (
|
||||
<Stack direction="column">
|
||||
{/* Data Queries */}
|
||||
<QueryEditor
|
||||
queries={dataQueries}
|
||||
onRunQueries={runQueries}
|
||||
onChangeQueries={onChangeQueries}
|
||||
onDuplicateQuery={onDuplicateQuery}
|
||||
panelData={panelData}
|
||||
condition={condition}
|
||||
onSetCondition={(refId) => {
|
||||
setValue('condition', refId);
|
||||
}}
|
||||
/>
|
||||
{/* Expression Queries */}
|
||||
<ExpressionsEditor
|
||||
queries={queries}
|
||||
panelData={panelData}
|
||||
condition={condition}
|
||||
onSetCondition={(refId) => {
|
||||
setValue('condition', refId);
|
||||
}}
|
||||
onRemoveExpression={(refId) => {
|
||||
dispatch(removeExpression(refId));
|
||||
}}
|
||||
onUpdateRefId={onUpdateRefId}
|
||||
onUpdateExpressionType={(refId, type) => {
|
||||
dispatch(updateExpressionType({ refId, type }));
|
||||
}}
|
||||
onUpdateQueryExpression={(model) => {
|
||||
dispatch(updateExpression(model));
|
||||
}}
|
||||
/>
|
||||
{/* action buttons */}
|
||||
<Stack direction="row">
|
||||
<Tooltip content={'You appear to have no compatible data sources'} show={noCompatibleDataSources}>
|
||||
<Button
|
||||
type="button"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dispatch(addNewDataQuery());
|
||||
}}
|
||||
variant="secondary"
|
||||
aria-label={selectors.components.QueryTab.addQuery}
|
||||
disabled={noCompatibleDataSources}
|
||||
>
|
||||
Add query
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{config.expressionsEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dispatch(addNewExpression());
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
Add expression
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isDataLoading && (
|
||||
<Button icon="fa fa-spinner" type="button" variant="destructive" onClick={cancelQueries}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{!isDataLoading && (
|
||||
<Button icon="sync" type="button" onClick={() => runQueries()} disabled={emptyQueries}>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* No Queries */}
|
||||
{emptyQueries && (
|
||||
<Alert title="No queries or expressions have been configured" severity="warning">
|
||||
Create at least one query or expression to be alerted on
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
@ -0,0 +1,382 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Query and expressions reducer should add a new expression 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"refId": "A",
|
||||
},
|
||||
"queryType": "query",
|
||||
"refId": "A",
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "avg",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"name": "Expression",
|
||||
"type": "__expr__",
|
||||
"uid": "__expr__",
|
||||
},
|
||||
"expression": "",
|
||||
"hide": false,
|
||||
"refId": "B",
|
||||
"type": "math",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
"relativeTimeRange": undefined,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should add query 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"refId": "A",
|
||||
},
|
||||
"queryType": "query",
|
||||
"refId": "A",
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
|
||||
"model": Object {
|
||||
"datasource": Object {
|
||||
"type": "prometheus",
|
||||
"uid": "c8eceabb-0275-4108-8f03-8f74faf4bf6d",
|
||||
},
|
||||
"hide": false,
|
||||
"refId": "B",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
"relativeTimeRange": Object {
|
||||
"from": 600,
|
||||
"to": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should duplicate query 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"refId": "A",
|
||||
},
|
||||
"queryType": "query",
|
||||
"refId": "A",
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"hide": false,
|
||||
"refId": "B",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
"relativeTimeRange": Object {
|
||||
"from": 600,
|
||||
"to": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should remove an expression or alert query 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"refId": "A",
|
||||
},
|
||||
"queryType": "query",
|
||||
"refId": "A",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should rewire expressions 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"refId": "A",
|
||||
},
|
||||
"queryType": "query",
|
||||
"refId": "A",
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "avg",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"name": "Expression",
|
||||
"type": "__expr__",
|
||||
"uid": "__expr__",
|
||||
},
|
||||
"expression": "",
|
||||
"refId": "B",
|
||||
"type": "classic_conditions",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should set data queries 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [
|
||||
"A",
|
||||
],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "avg",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"name": "Expression",
|
||||
"type": "__expr__",
|
||||
"uid": "__expr__",
|
||||
},
|
||||
"expression": "",
|
||||
"refId": "B",
|
||||
"type": "classic_conditions",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should update an expression 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [
|
||||
"A",
|
||||
],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "avg",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"name": "Expression",
|
||||
"type": "__expr__",
|
||||
"uid": "__expr__",
|
||||
},
|
||||
"expression": "",
|
||||
"refId": "B",
|
||||
"type": "math",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should update an expression refId and rewire expressions 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"refId": "C",
|
||||
},
|
||||
"queryType": "query",
|
||||
"refId": "C",
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [
|
||||
"C",
|
||||
],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "avg",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"name": "Expression",
|
||||
"type": "__expr__",
|
||||
"uid": "__expr__",
|
||||
},
|
||||
"expression": "",
|
||||
"refId": "B",
|
||||
"type": "classic_conditions",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Query and expressions reducer should update expression type 1`] = `
|
||||
Object {
|
||||
"queries": Array [
|
||||
Object {
|
||||
"datasourceUid": "abc123",
|
||||
"model": Object {
|
||||
"refId": "A",
|
||||
},
|
||||
"queryType": "query",
|
||||
"refId": "A",
|
||||
},
|
||||
Object {
|
||||
"datasourceUid": "-100",
|
||||
"model": Object {
|
||||
"conditions": Array [
|
||||
Object {
|
||||
"evaluator": Object {
|
||||
"params": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"type": "gt",
|
||||
},
|
||||
"operator": Object {
|
||||
"type": "and",
|
||||
},
|
||||
"query": Object {
|
||||
"params": Array [],
|
||||
},
|
||||
"reducer": Object {
|
||||
"params": Array [],
|
||||
"type": "avg",
|
||||
},
|
||||
"type": "query",
|
||||
},
|
||||
],
|
||||
"datasource": Object {
|
||||
"name": "Expression",
|
||||
"type": "__expr__",
|
||||
"uid": "__expr__",
|
||||
},
|
||||
"expression": "",
|
||||
"refId": "B",
|
||||
"type": "reduce",
|
||||
},
|
||||
"queryType": "",
|
||||
"refId": "B",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
@ -0,0 +1,213 @@
|
||||
import { getDefaultRelativeTimeRange } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime/src/services/__mocks__/dataSourceSrv';
|
||||
import {
|
||||
dataSource as expressionDatasource,
|
||||
ExpressionDatasourceUID,
|
||||
} from 'app/features/expressions/ExpressionDatasource';
|
||||
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import {
|
||||
addNewDataQuery,
|
||||
addNewExpression,
|
||||
duplicateQuery,
|
||||
queriesAndExpressionsReducer,
|
||||
QueriesAndExpressionsState,
|
||||
removeExpression,
|
||||
rewireExpressions,
|
||||
setDataQueries,
|
||||
updateExpression,
|
||||
updateExpressionRefId,
|
||||
updateExpressionType,
|
||||
} from './reducer';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getDataSourceSrv: getDataSourceSrv,
|
||||
}));
|
||||
|
||||
const alertQuery: AlertQuery = {
|
||||
refId: 'A',
|
||||
queryType: 'query',
|
||||
datasourceUid: 'abc123',
|
||||
model: {
|
||||
refId: 'A',
|
||||
},
|
||||
};
|
||||
|
||||
const expressionQuery: AlertQuery = {
|
||||
datasourceUid: ExpressionDatasourceUID,
|
||||
model: expressionDatasource.newQuery({
|
||||
type: ExpressionQueryType.classic,
|
||||
conditions: [{ ...defaultCondition, query: { params: ['A'] } }],
|
||||
expression: '',
|
||||
refId: 'B',
|
||||
}),
|
||||
refId: 'B',
|
||||
queryType: '',
|
||||
};
|
||||
|
||||
describe('Query and expressions reducer', () => {
|
||||
it('should return initial state', () => {
|
||||
expect(queriesAndExpressionsReducer(undefined, { type: undefined })).toEqual({
|
||||
queries: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should duplicate query', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(initialState, duplicateQuery(alertQuery));
|
||||
const newQuery = newState.queries.at(-1);
|
||||
expect(newState).toMatchSnapshot();
|
||||
expect(newQuery).toHaveProperty('relativeTimeRange', getDefaultRelativeTimeRange());
|
||||
});
|
||||
|
||||
it('should duplicate query and copy time range', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery],
|
||||
};
|
||||
|
||||
const customTimeRange = {
|
||||
from: -200,
|
||||
to: 800,
|
||||
};
|
||||
|
||||
const query: AlertQuery = {
|
||||
...initialState.queries[0],
|
||||
relativeTimeRange: customTimeRange,
|
||||
};
|
||||
|
||||
const previousState: QueriesAndExpressionsState = {
|
||||
queries: [query],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(previousState, duplicateQuery(query));
|
||||
const newQuery = newState.queries.at(-1);
|
||||
expect(newQuery).toHaveProperty('relativeTimeRange', customTimeRange);
|
||||
});
|
||||
|
||||
it('should add query', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(initialState, addNewDataQuery());
|
||||
expect(newState.queries).toHaveLength(2);
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should set data queries', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery, expressionQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(initialState, setDataQueries([]));
|
||||
expect(newState.queries).toHaveLength(1);
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should add a new expression', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(initialState, addNewExpression());
|
||||
expect(newState.queries).toHaveLength(2);
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should remove an expression or alert query', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery, expressionQuery],
|
||||
};
|
||||
|
||||
let stateWithoutB = queriesAndExpressionsReducer(initialState, removeExpression('B'));
|
||||
expect(stateWithoutB.queries).toHaveLength(1);
|
||||
expect(stateWithoutB).toMatchSnapshot();
|
||||
|
||||
let stateWithoutAOrB = queriesAndExpressionsReducer(stateWithoutB, removeExpression('A'));
|
||||
expect(stateWithoutAOrB.queries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should update an expression', () => {
|
||||
const newExpression: ExpressionQuery = {
|
||||
...expressionQuery.model,
|
||||
type: ExpressionQueryType.math,
|
||||
};
|
||||
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [expressionQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(initialState, updateExpression(newExpression));
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should update an expression refId and rewire expressions', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery, expressionQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(
|
||||
initialState,
|
||||
updateExpressionRefId({
|
||||
oldRefId: 'A',
|
||||
newRefId: 'C',
|
||||
})
|
||||
);
|
||||
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should not update an expression when the refId exists', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery, expressionQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(
|
||||
initialState,
|
||||
updateExpressionRefId({
|
||||
oldRefId: 'A',
|
||||
newRefId: 'B',
|
||||
})
|
||||
);
|
||||
|
||||
expect(newState).toEqual(initialState);
|
||||
});
|
||||
|
||||
it('should rewire expressions', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery, expressionQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(
|
||||
initialState,
|
||||
rewireExpressions({
|
||||
oldRefId: 'A',
|
||||
newRefId: 'C',
|
||||
})
|
||||
);
|
||||
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should update expression type', () => {
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [alertQuery, expressionQuery],
|
||||
};
|
||||
|
||||
const newState = queriesAndExpressionsReducer(
|
||||
initialState,
|
||||
updateExpressionType({
|
||||
refId: 'B',
|
||||
type: ExpressionQueryType.reduce,
|
||||
})
|
||||
);
|
||||
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
});
|
@ -0,0 +1,163 @@
|
||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
||||
|
||||
import { DataQuery, RelativeTimeRange, getDefaultRelativeTimeRange } from '@grafana/data';
|
||||
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||
import {
|
||||
dataSource as expressionDatasource,
|
||||
ExpressionDatasourceUID,
|
||||
} from 'app/features/expressions/ExpressionDatasource';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
|
||||
import { queriesWithUpdatedReferences, refIdExists } from '../util';
|
||||
|
||||
export interface QueriesAndExpressionsState {
|
||||
queries: AlertQuery[];
|
||||
}
|
||||
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [],
|
||||
};
|
||||
|
||||
export const duplicateQuery = createAction<AlertQuery>('duplicateQuery');
|
||||
export const addNewDataQuery = createAction('addNewDataQuery');
|
||||
export const setDataQueries = createAction<AlertQuery[]>('setDataQueries');
|
||||
|
||||
export const addNewExpression = createAction('addNewExpression');
|
||||
export const removeExpression = createAction<string>('removeExpression');
|
||||
export const updateExpression = createAction<ExpressionQuery>('updateExpression');
|
||||
export const updateExpressionRefId = createAction<{ oldRefId: string; newRefId: string }>('updateExpressionRefId');
|
||||
export const rewireExpressions = createAction<{ oldRefId: string; newRefId: string }>('rewireExpressions');
|
||||
export const updateExpressionType = createAction<{ refId: string; type: ExpressionQueryType }>('updateExpressionType');
|
||||
|
||||
export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => {
|
||||
// data queries actions
|
||||
builder
|
||||
.addCase(duplicateQuery, (state, { payload }) => {
|
||||
state.queries = addQuery(state.queries, payload);
|
||||
})
|
||||
.addCase(addNewDataQuery, (state) => {
|
||||
const datasource = getDefaultOrFirstCompatibleDataSource();
|
||||
if (!datasource) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.queries = addQuery(state.queries, {
|
||||
datasourceUid: datasource.uid,
|
||||
model: {
|
||||
refId: '',
|
||||
datasource: {
|
||||
type: datasource.type,
|
||||
uid: datasource.uid,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
.addCase(setDataQueries, (state, { payload }) => {
|
||||
const expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
|
||||
state.queries = [...payload, ...expressionQueries];
|
||||
});
|
||||
|
||||
// expressions actions
|
||||
builder
|
||||
.addCase(addNewExpression, (state) => {
|
||||
state.queries = addQuery(state.queries, {
|
||||
datasourceUid: ExpressionDatasourceUID,
|
||||
model: expressionDatasource.newQuery({
|
||||
type: ExpressionQueryType.math,
|
||||
conditions: [{ ...defaultCondition, query: { params: [] } }],
|
||||
expression: '',
|
||||
}),
|
||||
});
|
||||
})
|
||||
.addCase(removeExpression, (state, { payload }) => {
|
||||
state.queries = state.queries.filter((query) => query.refId !== payload);
|
||||
})
|
||||
.addCase(updateExpression, (state, { payload }) => {
|
||||
state.queries = state.queries.map((query) => {
|
||||
return query.refId === payload.refId
|
||||
? {
|
||||
...query,
|
||||
model: payload,
|
||||
}
|
||||
: query;
|
||||
});
|
||||
})
|
||||
.addCase(updateExpressionRefId, (state, { payload }) => {
|
||||
const { newRefId, oldRefId } = payload;
|
||||
|
||||
// if the new refId already exists we just refuse to update the state
|
||||
const newRefIdExists = refIdExists(state.queries, newRefId);
|
||||
if (newRefIdExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedQueries = queriesWithUpdatedReferences(state.queries, oldRefId, newRefId);
|
||||
state.queries = updatedQueries.map((query) => {
|
||||
if (query.refId === oldRefId) {
|
||||
return {
|
||||
...query,
|
||||
refId: newRefId,
|
||||
model: {
|
||||
...query.model,
|
||||
refId: newRefId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return query;
|
||||
});
|
||||
})
|
||||
.addCase(rewireExpressions, (state, { payload }) => {
|
||||
state.queries = queriesWithUpdatedReferences(state.queries, payload.oldRefId, payload.newRefId);
|
||||
})
|
||||
.addCase(updateExpressionType, (state, action) => {
|
||||
state.queries = state.queries.map((query) => {
|
||||
return query.refId === action.payload.refId
|
||||
? {
|
||||
...query,
|
||||
model: {
|
||||
...expressionDatasource.newQuery({
|
||||
type: action.payload.type,
|
||||
conditions: [{ ...defaultCondition, query: { params: [] } }],
|
||||
expression: '',
|
||||
}),
|
||||
refId: action.payload.refId,
|
||||
},
|
||||
}
|
||||
: query;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const addQuery = (
|
||||
queries: AlertQuery[],
|
||||
queryToAdd: Pick<AlertQuery, 'model' | 'datasourceUid' | 'relativeTimeRange'>
|
||||
): AlertQuery[] => {
|
||||
const refId = getNextRefIdChar(queries);
|
||||
|
||||
const query: AlertQuery = {
|
||||
...queryToAdd,
|
||||
refId,
|
||||
queryType: '',
|
||||
model: {
|
||||
...queryToAdd.model,
|
||||
hide: false,
|
||||
refId,
|
||||
},
|
||||
relativeTimeRange: queryToAdd.relativeTimeRange ?? defaultTimeRange(queryToAdd.model),
|
||||
};
|
||||
|
||||
return [...queries, query];
|
||||
};
|
||||
|
||||
const defaultTimeRange = (model: DataQuery): RelativeTimeRange | undefined => {
|
||||
if (isExpressionQuery(model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getDefaultRelativeTimeRange();
|
||||
};
|
@ -1,5 +1,7 @@
|
||||
import { ValidateResult } from 'react-hook-form';
|
||||
|
||||
import { DataFrame } from '@grafana/data';
|
||||
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@ -67,6 +69,10 @@ export function updateMathExpressionRefs(expression: string, previousRefId: stri
|
||||
return expression.replace(oldExpression, newExpression);
|
||||
}
|
||||
|
||||
export function refIdExists(queries: AlertQuery[], refId: string | null): boolean {
|
||||
return queries.find((query) => query.refId === refId) !== undefined;
|
||||
}
|
||||
|
||||
// some gateways (like Istio) will decode "/" and "\" characters – this will cause 404 errors for any API call
|
||||
// that includes these values in the URL (ie. /my/path%2fto/resource -> /my/path/to/resource)
|
||||
//
|
||||
@ -79,3 +85,25 @@ export function checkForPathSeparator(value: string): ValidateResult {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function errorFromSeries(series: DataFrame[]): Error | undefined {
|
||||
if (series.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTimeSeriesResults = isTimeSeries(series);
|
||||
|
||||
let error;
|
||||
if (isTimeSeriesResults) {
|
||||
error = new Error('You cannot use time series data as an alert condition, consider adding a reduce expression.');
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
export function warningFromSeries(series: DataFrame[]): Error | undefined {
|
||||
const notices = series[0]?.meta?.notices ?? [];
|
||||
const warning = notices.find((notice) => notice.severity === 'warning')?.text;
|
||||
|
||||
return warning ? new Error(warning) : undefined;
|
||||
}
|
||||
|
@ -7,8 +7,11 @@ import { alertStateToReadable, alertStateToState } from '../../utils/rules';
|
||||
import { StateTag } from '../StateTag';
|
||||
interface Props {
|
||||
state: PromAlertingRuleState | GrafanaAlertState | GrafanaAlertStateWithReason | AlertState;
|
||||
size?: 'md' | 'sm';
|
||||
}
|
||||
|
||||
export const AlertStateTag: FC<Props> = ({ state }) => (
|
||||
<StateTag state={alertStateToState(state)}>{alertStateToReadable(state)}</StateTag>
|
||||
export const AlertStateTag: FC<Props> = ({ state, size = 'md' }) => (
|
||||
<StateTag state={alertStateToState(state)} size={size}>
|
||||
{alertStateToReadable(state)}
|
||||
</StateTag>
|
||||
);
|
||||
|
@ -198,7 +198,7 @@ export const getDefaultQueries = (): AlertQuery[] => {
|
||||
const dataSource = getDefaultOrFirstCompatibleDataSource();
|
||||
|
||||
if (!dataSource) {
|
||||
return [getDefaultExpression('A')];
|
||||
return [...getDefaultExpressions('A', 'B')];
|
||||
}
|
||||
const relativeTimeRange = getDefaultRelativeTimeRange();
|
||||
|
||||
@ -213,15 +213,18 @@ export const getDefaultQueries = (): AlertQuery[] => {
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
getDefaultExpression('B'),
|
||||
...getDefaultExpressions('B', 'C'),
|
||||
];
|
||||
};
|
||||
|
||||
const getDefaultExpression = (refId: string): AlertQuery => {
|
||||
const model: ExpressionQuery = {
|
||||
refId,
|
||||
const getDefaultExpressions = (...refIds: [string, string]): AlertQuery[] => {
|
||||
const refOne = refIds[0];
|
||||
const refTwo = refIds[1];
|
||||
|
||||
const reduceExpression: ExpressionQuery = {
|
||||
refId: refIds[0],
|
||||
hide: false,
|
||||
type: ExpressionQueryType.classic,
|
||||
type: ExpressionQueryType.reduce,
|
||||
datasource: {
|
||||
uid: ExpressionDatasourceUID,
|
||||
type: ExpressionDatasourceRef.type,
|
||||
@ -230,14 +233,14 @@ const getDefaultExpression = (refId: string): AlertQuery => {
|
||||
{
|
||||
type: 'query',
|
||||
evaluator: {
|
||||
params: [3],
|
||||
params: [],
|
||||
type: EvalFunction.IsAbove,
|
||||
},
|
||||
operator: {
|
||||
type: 'and',
|
||||
},
|
||||
query: {
|
||||
params: ['A'],
|
||||
params: [refOne],
|
||||
},
|
||||
reducer: {
|
||||
params: [],
|
||||
@ -245,15 +248,54 @@ const getDefaultExpression = (refId: string): AlertQuery => {
|
||||
},
|
||||
},
|
||||
],
|
||||
reducer: 'last',
|
||||
expression: 'A',
|
||||
};
|
||||
|
||||
return {
|
||||
refId,
|
||||
datasourceUid: ExpressionDatasourceUID,
|
||||
queryType: '',
|
||||
model,
|
||||
const thresholdExpression: ExpressionQuery = {
|
||||
refId: refTwo,
|
||||
hide: false,
|
||||
type: ExpressionQueryType.threshold,
|
||||
datasource: {
|
||||
uid: ExpressionDatasourceUID,
|
||||
type: ExpressionDatasourceRef.type,
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
type: 'query',
|
||||
evaluator: {
|
||||
params: [0],
|
||||
type: EvalFunction.IsAbove,
|
||||
},
|
||||
operator: {
|
||||
type: 'and',
|
||||
},
|
||||
query: {
|
||||
params: [refTwo],
|
||||
},
|
||||
reducer: {
|
||||
params: [],
|
||||
type: 'last',
|
||||
},
|
||||
},
|
||||
],
|
||||
expression: refOne,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
refId: refOne,
|
||||
datasourceUid: ExpressionDatasourceUID,
|
||||
queryType: '',
|
||||
model: reduceExpression,
|
||||
},
|
||||
{
|
||||
refId: refTwo,
|
||||
datasourceUid: ExpressionDatasourceUID,
|
||||
queryType: '',
|
||||
model: thresholdExpression,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const dataQueriesToGrafanaQueries = async (
|
||||
@ -338,7 +380,14 @@ export const panelToRuleFormValues = async (
|
||||
}
|
||||
|
||||
if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
|
||||
queries.push(getDefaultExpression(getNextRefIdChar(queries.map((query) => query.model))));
|
||||
const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefIdChar(queries), '-');
|
||||
queries.push(reduceExpression);
|
||||
|
||||
const [_reduceExpression, thresholdExpression] = getDefaultExpressions(
|
||||
reduceExpression.refId,
|
||||
getNextRefIdChar(queries)
|
||||
);
|
||||
queries.push(thresholdExpression);
|
||||
}
|
||||
|
||||
const { folderId, folderTitle } = dashboard.meta;
|
||||
|
@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
|
||||
import React, { FC, FormEvent } from 'react';
|
||||
|
||||
import { GrafanaTheme, SelectableValue } from '@grafana/data';
|
||||
import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, useStyles } from '@grafana/ui';
|
||||
import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, Stack, useStyles } from '@grafana/ui';
|
||||
|
||||
import alertDef, { EvalFunction } from '../../alerting/state/alertDef';
|
||||
import { ClassicCondition, ReducerType } from '../types';
|
||||
@ -69,65 +69,70 @@ export const Condition: FC<Props> = ({ condition, index, onChange, onRemoveCondi
|
||||
condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange;
|
||||
|
||||
return (
|
||||
<InlineFieldRow>
|
||||
{index === 0 ? (
|
||||
<div className={cx(styles.button, buttonWidth)}>WHEN</div>
|
||||
) : (
|
||||
<ButtonSelect
|
||||
className={cx(styles.buttonSelectText, buttonWidth)}
|
||||
options={evalOperators}
|
||||
onChange={onEvalOperatorChange}
|
||||
value={evalOperators.find((ea) => ea.value === condition.operator!.type)}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
options={reducerFunctions}
|
||||
onChange={onReducerFunctionChange}
|
||||
width={20}
|
||||
value={reducerFunctions.find((rf) => rf.value === condition.reducer.type)}
|
||||
/>
|
||||
<div className={styles.button}>OF</div>
|
||||
<Select
|
||||
onChange={onRefIdChange}
|
||||
options={refIds}
|
||||
width={15}
|
||||
value={refIds.find((r) => r.value === condition.query.params[0])}
|
||||
/>
|
||||
<ButtonSelect
|
||||
className={styles.buttonSelectText}
|
||||
options={evalFunctions}
|
||||
onChange={onEvalFunctionChange}
|
||||
value={evalFunctions.find((ef) => ef.value === condition.evaluator.type)}
|
||||
/>
|
||||
{isRange ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 0)}
|
||||
value={condition.evaluator.params[0]}
|
||||
<Stack direction="row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<InlineFieldRow>
|
||||
{index === 0 ? (
|
||||
<div className={cx(styles.button, buttonWidth)}>WHEN</div>
|
||||
) : (
|
||||
<ButtonSelect
|
||||
className={cx(styles.buttonSelectText, buttonWidth)}
|
||||
options={evalOperators}
|
||||
onChange={onEvalOperatorChange}
|
||||
value={evalOperators.find((ea) => ea.value === condition.operator!.type)}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
options={reducerFunctions}
|
||||
onChange={onReducerFunctionChange}
|
||||
width={20}
|
||||
value={reducerFunctions.find((rf) => rf.value === condition.reducer.type)}
|
||||
/>
|
||||
<div className={styles.button}>TO</div>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 1)}
|
||||
value={condition.evaluator.params[1]}
|
||||
<div className={styles.button}>OF</div>
|
||||
<Select
|
||||
onChange={onRefIdChange}
|
||||
options={refIds}
|
||||
width={'auto'}
|
||||
value={refIds.find((r) => r.value === condition.query.params[0])}
|
||||
/>
|
||||
</>
|
||||
) : condition.evaluator.type !== EvalFunction.HasNoValue ? (
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 0)}
|
||||
value={condition.evaluator.params[0]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<ButtonSelect
|
||||
className={styles.buttonSelectText}
|
||||
options={evalFunctions}
|
||||
onChange={onEvalFunctionChange}
|
||||
value={evalFunctions.find((ef) => ef.value === condition.evaluator.type)}
|
||||
/>
|
||||
{isRange ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 0)}
|
||||
value={condition.evaluator.params[0]}
|
||||
/>
|
||||
<div className={styles.button}>TO</div>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 1)}
|
||||
value={condition.evaluator.params[1]}
|
||||
/>
|
||||
</>
|
||||
) : condition.evaluator.type !== EvalFunction.HasNoValue ? (
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onChange={(event) => onEvaluateValueChange(event, 0)}
|
||||
value={condition.evaluator.params[0]}
|
||||
/>
|
||||
) : null}
|
||||
</InlineFieldRow>
|
||||
</div>
|
||||
<Button variant="secondary" type="button" onClick={() => onRemoveCondition(index)}>
|
||||
<Icon name="trash-alt" />
|
||||
</Button>
|
||||
</InlineFieldRow>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { ChangeEvent, FC } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Icon, InlineField, Stack, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { Icon, InlineField, InlineLabel, Stack, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { HoverCard } from 'app/features/alerting/unified/components/HoverCard';
|
||||
|
||||
import { ExpressionQuery } from '../types';
|
||||
|
||||
interface Props {
|
||||
labelWidth: number;
|
||||
labelWidth: number | 'auto';
|
||||
query: ExpressionQuery;
|
||||
onChange: (query: ExpressionQuery) => void;
|
||||
onRunQuery: () => void;
|
||||
@ -19,13 +19,11 @@ const mathPlaceholder =
|
||||
'The sum of two scalar values: $A + $B > 10';
|
||||
|
||||
export const Math: FC<Props> = ({ labelWidth, onChange, query, onRunQuery }) => {
|
||||
const [showHelp, toggleShowHelp] = useToggle(false);
|
||||
|
||||
const onExpressionChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange({ ...query, expression: event.target.value });
|
||||
};
|
||||
|
||||
const styles = useStyles2((theme) => getStyles(theme, showHelp));
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const executeQuery = () => {
|
||||
if (query.expression) {
|
||||
@ -36,93 +34,97 @@ export const Math: FC<Props> = ({ labelWidth, onChange, query, onRunQuery }) =>
|
||||
return (
|
||||
<Stack direction="row">
|
||||
<InlineField
|
||||
label="Expression"
|
||||
label={
|
||||
<InlineLabel width="auto">
|
||||
<HoverCard
|
||||
content={
|
||||
<div className={styles.documentationContainer}>
|
||||
<header className={styles.documentationHeader}>
|
||||
<Icon name="book-open" /> Math operator
|
||||
</header>
|
||||
<div>
|
||||
Run math operations on one or more queries. You reference the query by {'${refId}'} ie. $A, $B, $C
|
||||
etc.
|
||||
<br />
|
||||
Example: <code>$A + $B</code>
|
||||
</div>
|
||||
<header className={styles.documentationHeader}>Available Math functions</header>
|
||||
<div className={styles.documentationFunctions}>
|
||||
<DocumentedFunction
|
||||
name="abs"
|
||||
description="returns the absolute value of its argument which can be a number or a series"
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_inf"
|
||||
description="returns 1 for Inf values (negative or positive) and 0 for other values. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_nan"
|
||||
description="returns 1 for NaN values and 0 for other values. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_null"
|
||||
description="returns 1 for null values and 0 for other values. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_number"
|
||||
description="returns 1 for all real number values and 0 for non-number. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="log"
|
||||
description="returns the natural logarithm of its argument, which can be a number or a series"
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="inf, infn, nan, and null"
|
||||
description="The inf for infinity positive, infn for infinity negative, nan, and null functions all return a single scalar value that matches its name."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="round"
|
||||
description="returns a rounded integer value. It's able to operate on series or escalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="ceil"
|
||||
description="rounds the number up to the nearest integer value. It's able to operate on series or escalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="floor"
|
||||
description="rounds the number down to the nearest integer value. It's able to operate on series or escalar values."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
See our additional documentation on{' '}
|
||||
<a
|
||||
className={styles.documentationLink}
|
||||
target="_blank"
|
||||
href="https://grafana.com/docs/grafana/latest/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions/#math"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon size="xs" name="external-link-alt" /> Math expressions
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
Expression <Icon name="info-circle" />
|
||||
</span>
|
||||
</HoverCard>
|
||||
</InlineLabel>
|
||||
}
|
||||
labelWidth={labelWidth}
|
||||
grow={true}
|
||||
shrink={true}
|
||||
className={css`
|
||||
align-items: flex-start;
|
||||
flex: 0.7;
|
||||
`}
|
||||
>
|
||||
<>
|
||||
<TextArea
|
||||
value={query.expression}
|
||||
onChange={onExpressionChange}
|
||||
rows={5}
|
||||
placeholder={mathPlaceholder}
|
||||
onBlur={executeQuery}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={toggleShowHelp}>
|
||||
{showHelp === false ? 'Show' : 'Hide'} help
|
||||
</Button>
|
||||
</>
|
||||
<TextArea
|
||||
value={query.expression}
|
||||
onChange={onExpressionChange}
|
||||
rows={1}
|
||||
placeholder={mathPlaceholder}
|
||||
onBlur={executeQuery}
|
||||
style={{ minWidth: 250, lineHeight: '26px', minHeight: 32 }}
|
||||
/>
|
||||
</InlineField>
|
||||
<div className={styles.documentationContainer}>
|
||||
<header className={styles.documentationHeader}>
|
||||
<Icon name="book-open" /> Math operator
|
||||
</header>
|
||||
<div>
|
||||
Run math operations on one or more queries. You reference the query by {'${refId}'} ie. $A, $B, $C etc.
|
||||
<br />
|
||||
Example: <code>$A + $B</code>
|
||||
</div>
|
||||
<header className={styles.documentationHeader}>Available Math functions</header>
|
||||
<div className={styles.documentationFunctions}>
|
||||
<DocumentedFunction
|
||||
name="abs"
|
||||
description="returns the absolute value of its argument which can be a number or a series"
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_inf"
|
||||
description="returns 1 for Inf values (negative or positive) and 0 for other values. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_nan"
|
||||
description="returns 1 for NaN values and 0 for other values. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_null"
|
||||
description="returns 1 for null values and 0 for other values. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="is_number"
|
||||
description="returns 1 for all real number values and 0 for non-number. It's able to operate on series or scalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="log"
|
||||
description="returns the natural logarithm of its argument, which can be a number or a series"
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="inf, infn, nan, and null"
|
||||
description="The inf for infinity positive, infn for infinity negative, nan, and null functions all return a single scalar value that matches its name."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="round"
|
||||
description="returns a rounded integer value. It's able to operate on series or escalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="ceil"
|
||||
description="rounds the number up to the nearest integer value. It's able to operate on series or escalar values."
|
||||
/>
|
||||
<DocumentedFunction
|
||||
name="floor"
|
||||
description="rounds the number down to the nearest integer value. It's able to operate on series or escalar values."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
See our additional documentation on{' '}
|
||||
<a
|
||||
className={styles.documentationLink}
|
||||
target="_blank"
|
||||
href="https://grafana.com/docs/grafana/latest/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions/#math"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon size="xs" name="external-link-alt" /> Math expressions
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@ -142,7 +144,7 @@ const DocumentedFunction = ({ name, description }: DocumentedFunctionProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, showHelp?: boolean) => ({
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
documentationHeader: css`
|
||||
font-size: ${theme.typography.h5.fontSize};
|
||||
font-weight: ${theme.typography.h5.fontWeight};
|
||||
@ -151,10 +153,12 @@ const getStyles = (theme: GrafanaTheme2, showHelp?: boolean) => ({
|
||||
color: ${theme.colors.text.link};
|
||||
`,
|
||||
documentationContainer: css`
|
||||
display: ${showHelp ? 'flex' : 'none'};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(2)};
|
||||
|
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
||||
`,
|
||||
documentationFunctions: css`
|
||||
display: grid;
|
||||
|
@ -6,13 +6,13 @@ import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
|
||||
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerMode, reducerTypes } from '../types';
|
||||
|
||||
interface Props {
|
||||
labelWidth: number;
|
||||
labelWidth?: number | 'auto';
|
||||
refIds: Array<SelectableValue<string>>;
|
||||
query: ExpressionQuery;
|
||||
onChange: (query: ExpressionQuery) => void;
|
||||
}
|
||||
|
||||
export const Reduce: FC<Props> = ({ labelWidth, onChange, refIds, query }) => {
|
||||
export const Reduce: FC<Props> = ({ labelWidth = 'auto', onChange, refIds, query }) => {
|
||||
const reducer = reducerTypes.find((o) => o.value === query.reducer);
|
||||
|
||||
const onRefIdChange = (value: SelectableValue<string>) => {
|
||||
|
@ -8,11 +8,11 @@ import { downsamplingTypes, ExpressionQuery, upsamplingTypes } from '../types';
|
||||
interface Props {
|
||||
refIds: Array<SelectableValue<string>>;
|
||||
query: ExpressionQuery;
|
||||
labelWidth: number;
|
||||
labelWidth?: number | 'auto';
|
||||
onChange: (query: ExpressionQuery) => void;
|
||||
}
|
||||
|
||||
export const Resample: FC<Props> = ({ labelWidth, onChange, refIds, query }) => {
|
||||
export const Resample: FC<Props> = ({ labelWidth = 'auto', onChange, refIds, query }) => {
|
||||
const downsampler = downsamplingTypes.find((o) => o.value === query.downsampler);
|
||||
const upsampler = upsamplingTypes.find((o) => o.value === query.upsampler);
|
||||
|
||||
|
@ -8,7 +8,7 @@ import { EvalFunction } from 'app/features/alerting/state/alertDef';
|
||||
import { ClassicCondition, ExpressionQuery, thresholdFunctions } from '../types';
|
||||
|
||||
interface Props {
|
||||
labelWidth: number;
|
||||
labelWidth: number | 'auto';
|
||||
refIds: Array<SelectableValue<string>>;
|
||||
query: ExpressionQuery;
|
||||
onChange: (query: ExpressionQuery) => void;
|
||||
|
@ -11,11 +11,34 @@ export enum ExpressionQueryType {
|
||||
}
|
||||
|
||||
export const gelTypes: Array<SelectableValue<ExpressionQueryType>> = [
|
||||
{ value: ExpressionQueryType.math, label: 'Math' },
|
||||
{ value: ExpressionQueryType.reduce, label: 'Reduce' },
|
||||
{ value: ExpressionQueryType.resample, label: 'Resample' },
|
||||
{ value: ExpressionQueryType.classic, label: 'Classic condition' },
|
||||
{ value: ExpressionQueryType.threshold, label: 'Threshold' },
|
||||
{
|
||||
value: ExpressionQueryType.math,
|
||||
label: 'Math',
|
||||
description: 'Free-form math formulas on time series or number data.',
|
||||
},
|
||||
{
|
||||
value: ExpressionQueryType.reduce,
|
||||
label: 'Reduce',
|
||||
description:
|
||||
'Takes one or more time series returned from a query or an expression and turns each series into a single number.',
|
||||
},
|
||||
{
|
||||
value: ExpressionQueryType.resample,
|
||||
label: 'Resample',
|
||||
description: 'Changes the time stamps in each time series to have a consistent time interval.',
|
||||
},
|
||||
{
|
||||
value: ExpressionQueryType.classic,
|
||||
label: 'Classic condition',
|
||||
description:
|
||||
'Takes one or more time series returned from a query or an expression and checks if any of the series match the condition.',
|
||||
},
|
||||
{
|
||||
value: ExpressionQueryType.threshold,
|
||||
label: 'Threshold',
|
||||
description:
|
||||
'Takes one or more time series returned from a query or an expression and checks if any of the series match the threshold condition.',
|
||||
},
|
||||
];
|
||||
|
||||
export const reducerTypes: Array<SelectableValue<string>> = [
|
||||
|
Loading…
Reference in New Issue
Block a user