[Metrics UI] Add Charts to Alert Conditions (#64384)

* [Metrics UI] Add Charts to Alert Conditions

- Reorganize files under public/alerting
- Change Metrics Explorer API to force interval
- Add charts to expression rows
- Allow expression rows to be collapsable
- Adding sum aggregation to Metrics Explorer for parity

* Adding interval information to Metrics Eexplorer API

* Moving data hook into the expression charts component

* Revert "Adding interval information to Metrics Eexplorer API"

This reverts commit f6e2fc11be.

* Reducing the opacity for the threshold areas

* Changing darkMode to use alertsContext.uiSettings
This commit is contained in:
Chris Cowan 2020-04-30 11:52:11 -07:00 committed by GitHub
parent c8b9bdd0ec
commit 0399f70050
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 770 additions and 251 deletions

View file

@ -13,6 +13,7 @@ export const METRIC_EXPLORER_AGGREGATIONS = [
'cardinality',
'rate',
'count',
'sum',
] as const;
type MetricExplorerAggregations = typeof METRIC_EXPLORER_AGGREGATIONS[number];
@ -54,6 +55,7 @@ export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({
afterKey: rt.union([rt.string, rt.null, rt.undefined]),
limit: rt.union([rt.number, rt.null, rt.undefined]),
filterQuery: rt.union([rt.string, rt.null, rt.undefined]),
forceInterval: rt.boolean,
});
export const metricsExplorerRequestBodyRT = rt.intersection([

View file

@ -35,6 +35,9 @@ export const metricsExplorerViewSavedObjectMappings: {
},
options: {
properties: {
forceInterval: {
type: 'boolean',
},
metrics: {
type: 'nested',
properties: {

View file

@ -6,9 +6,6 @@
import React, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiSpacer,
EuiText,
EuiFormRow,
@ -18,40 +15,28 @@ import {
EuiIcon,
EuiFieldSearch,
} from '@elastic/eui';
import { IFieldType } from 'src/plugins/data/public';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import {
MetricExpressionParams,
Comparator,
Aggregators,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../server/lib/alerting/metric_threshold/types';
import { euiStyled } from '../../../../../observability/public';
import {
WhenExpression,
OfExpression,
ThresholdExpression,
ForLastExpression,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../triggers_actions_ui/public/common';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { builtInComparators } from '../../../../../triggers_actions_ui/public/common/constants';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { IErrorObject } from '../../../../../triggers_actions_ui/public/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer';
import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar';
import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by';
import { useSourceViaHttp } from '../../../containers/source/use_source_via_http';
interface AlertContextMeta {
currentOptions?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
}
import { ExpressionRow } from './expression_row';
import { AlertContextMeta, TimeUnit, MetricExpression } from '../types';
import { ExpressionChart } from './expression_chart';
interface Props {
errors: IErrorObject[];
@ -67,11 +52,6 @@ interface Props {
setAlertProperty(key: string, value: any): void;
}
type TimeUnit = 's' | 'm' | 'h' | 'd';
type MetricExpression = Omit<MetricExpressionParams, 'metric'> & {
metric?: string;
};
const defaultExpression = {
aggType: Aggregators.AVERAGE,
comparator: Comparator.GT,
@ -80,17 +60,6 @@ const defaultExpression = {
timeUnit: 'm',
} as MetricExpression;
const customComparators = {
...builtInComparators,
[Comparator.OUTSIDE_RANGE]: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.outsideRangeLabel', {
defaultMessage: 'Is not between',
}),
value: Comparator.OUTSIDE_RANGE,
requiredValues: 2,
},
};
export const Expressions: React.FC<Props> = props => {
const { setAlertParams, alertParams, errors, alertsContext } = props;
const { source, createDerivedIndexPattern } = useSourceViaHttp({
@ -101,7 +70,6 @@ export const Expressions: React.FC<Props> = props => {
});
const [timeSize, setTimeSize] = useState<number | undefined>(1);
const [timeUnit, setTimeUnit] = useState<TimeUnit>('m');
const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [
createDerivedIndexPattern,
]);
@ -127,14 +95,14 @@ export const Expressions: React.FC<Props> = props => {
);
const addExpression = useCallback(() => {
const exp = alertParams.criteria.slice();
const exp = alertParams.criteria?.slice() || [];
exp.push(defaultExpression);
setAlertParams('criteria', exp);
}, [setAlertParams, alertParams.criteria]);
const removeExpression = useCallback(
(id: number) => {
const exp = alertParams.criteria.slice();
const exp = alertParams.criteria?.slice() || [];
if (exp.length > 1) {
exp.splice(id, 1);
setAlertParams('criteria', exp);
@ -167,10 +135,11 @@ export const Expressions: React.FC<Props> = props => {
const updateTimeSize = useCallback(
(ts: number | undefined) => {
const criteria = alertParams.criteria.map(c => ({
...c,
timeSize: ts,
}));
const criteria =
alertParams.criteria?.map(c => ({
...c,
timeSize: ts,
})) || [];
setTimeSize(ts || undefined);
setAlertParams('criteria', criteria);
},
@ -179,10 +148,11 @@ export const Expressions: React.FC<Props> = props => {
const updateTimeUnit = useCallback(
(tu: string) => {
const criteria = alertParams.criteria.map(c => ({
...c,
timeUnit: tu,
}));
const criteria =
alertParams.criteria?.map(c => ({
...c,
timeUnit: tu,
})) || [];
setTimeUnit(tu as TimeUnit);
setAlertParams('criteria', criteria);
},
@ -250,7 +220,7 @@ export const Expressions: React.FC<Props> = props => {
alertParams.criteria.map((e, idx) => {
return (
<ExpressionRow
canDelete={alertParams.criteria.length > 1}
canDelete={(alertParams.criteria && alertParams.criteria.length > 1) || false}
fields={derivedIndexPattern.fields}
remove={removeExpression}
addExpression={addExpression}
@ -259,17 +229,28 @@ export const Expressions: React.FC<Props> = props => {
setAlertParams={updateParams}
errors={errors[idx] || emptyError}
expression={e || {}}
/>
>
<ExpressionChart
expression={e}
context={alertsContext}
derivedIndexPattern={derivedIndexPattern}
source={source}
filterQuery={alertParams.filterQuery}
groupBy={alertParams.groupBy}
/>
</ExpressionRow>
);
})}
<ForLastExpression
timeWindowSize={timeSize}
timeWindowUnit={timeUnit}
errors={emptyError}
onChangeWindowSize={updateTimeSize}
onChangeWindowUnit={updateTimeUnit}
/>
<div style={{ marginLeft: 28 }}>
<ForLastExpression
timeWindowSize={timeSize}
timeWindowUnit={timeUnit}
errors={emptyError}
onChangeWindowSize={updateTimeSize}
onChangeWindowUnit={updateTimeUnit}
/>
</div>
<div>
<EuiButtonEmpty
@ -359,175 +340,3 @@ export const Expressions: React.FC<Props> = props => {
</>
);
};
interface ExpressionRowProps {
fields: IFieldType[];
expressionId: number;
expression: MetricExpression;
errors: IErrorObject;
canDelete: boolean;
addExpression(): void;
remove(id: number): void;
setAlertParams(id: number, params: MetricExpression): void;
}
const StyledExpressionRow = euiStyled(EuiFlexGroup)`
display: flex;
flex-wrap: wrap;
margin: 0 -4px;
`;
const StyledExpression = euiStyled.div`
padding: 0 4px;
`;
export const ExpressionRow: React.FC<ExpressionRowProps> = props => {
const { setAlertParams, expression, errors, expressionId, remove, fields, canDelete } = props;
const {
aggType = Aggregators.MAX,
metric,
comparator = Comparator.GT,
threshold = [],
} = expression;
const updateAggType = useCallback(
(at: string) => {
setAlertParams(expressionId, {
...expression,
aggType: at as MetricExpression['aggType'],
metric: at === 'count' ? undefined : expression.metric,
});
},
[expressionId, expression, setAlertParams]
);
const updateMetric = useCallback(
(m?: MetricExpression['metric']) => {
setAlertParams(expressionId, { ...expression, metric: m });
},
[expressionId, expression, setAlertParams]
);
const updateComparator = useCallback(
(c?: string) => {
setAlertParams(expressionId, { ...expression, comparator: c as Comparator });
},
[expressionId, expression, setAlertParams]
);
const updateThreshold = useCallback(
t => {
if (t.join() !== expression.threshold.join()) {
setAlertParams(expressionId, { ...expression, threshold: t });
}
},
[expressionId, expression, setAlertParams]
);
return (
<>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow>
<StyledExpressionRow>
<StyledExpression>
<WhenExpression
customAggTypesOptions={aggregationType}
aggType={aggType}
onChangeSelectedAggType={updateAggType}
/>
</StyledExpression>
{aggType !== 'count' && (
<StyledExpression>
<OfExpression
customAggTypesOptions={aggregationType}
aggField={metric}
fields={fields.map(f => ({
normalizedType: f.type,
name: f.name,
}))}
aggType={aggType}
errors={errors}
onChangeSelectedAggField={updateMetric}
/>
</StyledExpression>
)}
<StyledExpression>
<ThresholdExpression
thresholdComparator={comparator || Comparator.GT}
threshold={threshold}
customComparators={customComparators}
onChangeSelectedThresholdComparator={updateComparator}
onChangeSelectedThreshold={updateThreshold}
errors={errors}
/>
</StyledExpression>
</StyledExpressionRow>
</EuiFlexItem>
{canDelete && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', {
defaultMessage: 'Remove condition',
})}
color={'danger'}
iconType={'trash'}
onClick={() => remove(expressionId)}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size={'s'} />
</>
);
};
export const aggregationType: { [key: string]: any } = {
avg: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', {
defaultMessage: 'Average',
}),
fieldRequired: true,
validNormalizedTypes: ['number'],
value: Aggregators.AVERAGE,
},
max: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.max', {
defaultMessage: 'Max',
}),
fieldRequired: true,
validNormalizedTypes: ['number', 'date'],
value: Aggregators.MAX,
},
min: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.min', {
defaultMessage: 'Min',
}),
fieldRequired: true,
validNormalizedTypes: ['number', 'date'],
value: Aggregators.MIN,
},
cardinality: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.cardinality', {
defaultMessage: 'Cardinality',
}),
fieldRequired: false,
value: Aggregators.CARDINALITY,
validNormalizedTypes: ['number'],
},
rate: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.rate', {
defaultMessage: 'Rate',
}),
fieldRequired: false,
value: Aggregators.RATE,
validNormalizedTypes: ['number'],
},
count: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.count', {
defaultMessage: 'Document count',
}),
fieldRequired: false,
value: Aggregators.COUNT,
validNormalizedTypes: ['number'],
},
};

View file

@ -0,0 +1,302 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useCallback } from 'react';
import {
Axis,
Chart,
niceTimeFormatter,
Position,
Settings,
TooltipValue,
RectAnnotation,
} from '@elastic/charts';
import { first, last } from 'lodash';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { IIndexPattern } from 'src/plugins/data/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context';
import { InfraSource } from '../../../../common/http_api/source_api';
import {
Comparator,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../server/lib/alerting/metric_threshold/types';
import { MetricsExplorerColor, colorTransformer } from '../../../../common/color_palette';
import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api';
import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart';
import { MetricExpression, AlertContextMeta } from '../types';
import { MetricsExplorerChartType } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
import { getChartTheme } from '../../../pages/metrics/metrics_explorer/components/helpers/get_chart_theme';
import { createFormatterForMetric } from '../../../pages/metrics/metrics_explorer/components/helpers/create_formatter_for_metric';
import { calculateDomain } from '../../../pages/metrics/metrics_explorer/components/helpers/calculate_domain';
import { useMetricsExplorerChartData } from '../hooks/use_metrics_explorer_chart_data';
interface Props {
context: AlertsContextValue<AlertContextMeta>;
expression: MetricExpression;
derivedIndexPattern: IIndexPattern;
source: InfraSource | null;
filterQuery?: string;
groupBy?: string;
}
const tooltipProps = {
headerFormatter: (tooltipValue: TooltipValue) =>
moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'),
};
const TIME_LABELS = {
s: i18n.translate('xpack.infra.metrics.alerts.timeLabels.seconds', { defaultMessage: 'seconds' }),
m: i18n.translate('xpack.infra.metrics.alerts.timeLabels.minutes', { defaultMessage: 'minutes' }),
h: i18n.translate('xpack.infra.metrics.alerts.timeLabels.hours', { defaultMessage: 'hours' }),
d: i18n.translate('xpack.infra.metrics.alerts.timeLabels.days', { defaultMessage: 'days' }),
};
export const ExpressionChart: React.FC<Props> = ({
expression,
context,
derivedIndexPattern,
source,
filterQuery,
groupBy,
}) => {
const { loading, data } = useMetricsExplorerChartData(
expression,
context,
derivedIndexPattern,
source,
filterQuery,
groupBy
);
const metric = {
field: expression.metric,
aggregation: expression.aggType as MetricsExplorerAggregation,
color: MetricsExplorerColor.color0,
};
const isDarkMode = context.uiSettings?.get('theme:darkMode') || false;
const dateFormatter = useMemo(() => {
const firstSeries = data ? first(data.series) : null;
return firstSeries && firstSeries.rows.length > 0
? niceTimeFormatter([first(firstSeries.rows).timestamp, last(firstSeries.rows).timestamp])
: (value: number) => `${value}`;
}, [data]);
const yAxisFormater = useCallback(createFormatterForMetric(metric), [expression]);
if (loading || !data) {
return (
<EmptyContainer>
<EuiText color="subdued">
<FormattedMessage
id="xpack.infra.metrics.alerts.loadingMessage"
defaultMessage="Loading"
/>
</EuiText>
</EmptyContainer>
);
}
const thresholds = expression.threshold.slice().sort();
// Creating a custom series where the ID is changed to 0
// so that we can get a proper domian
const firstSeries = first(data.series);
if (!firstSeries) {
return (
<EmptyContainer>
<EuiText color="subdued">Oops, no chart data available</EuiText>
</EmptyContainer>
);
}
const series = {
...firstSeries,
rows: firstSeries.rows.map(row => {
const newRow: MetricsExplorerRow = {
timestamp: row.timestamp,
metric_0: row.metric_0 || null,
};
thresholds.forEach((thresholdValue, index) => {
newRow[`metric_threshold_${index}`] = thresholdValue;
});
return newRow;
}),
};
const firstTimestamp = first(firstSeries.rows).timestamp;
const lastTimestamp = last(firstSeries.rows).timestamp;
const dataDomain = calculateDomain(series, [metric], false);
const domain = {
max: Math.max(dataDomain.max, last(thresholds) || dataDomain.max) * 1.1, // add 10% headroom.
min: Math.min(dataDomain.min, first(thresholds) || dataDomain.min),
};
if (domain.min === first(expression.threshold)) {
domain.min = domain.min * 0.9;
}
const isAbove = [Comparator.GT, Comparator.GT_OR_EQ].includes(expression.comparator);
const opacity = 0.3;
const timeLabel = TIME_LABELS[expression.timeUnit];
return (
<>
<ChartContainer>
<Chart>
<MetricExplorerSeriesChart
type={MetricsExplorerChartType.area}
metric={metric}
id="0"
series={series}
stack={false}
/>
{thresholds.length ? (
<MetricExplorerSeriesChart
type={isAbove ? MetricsExplorerChartType.line : MetricsExplorerChartType.area}
metric={{
...metric,
color: MetricsExplorerColor.color1,
label: i18n.translate('xpack.infra.metrics.alerts.thresholdLabel', {
defaultMessage: 'Threshold',
}),
}}
id={thresholds.map((t, i) => `threshold_${i}`)}
series={series}
stack={false}
opacity={opacity}
/>
) : null}
{thresholds.length && expression.comparator === Comparator.OUTSIDE_RANGE ? (
<>
<MetricExplorerSeriesChart
type={MetricsExplorerChartType.line}
metric={{
...metric,
color: MetricsExplorerColor.color1,
label: i18n.translate('xpack.infra.metrics.alerts.thresholdLabel', {
defaultMessage: 'Threshold',
}),
}}
id={thresholds.map((t, i) => `threshold_${i}`)}
series={series}
stack={false}
opacity={opacity}
/>
<RectAnnotation
id="lower-threshold"
style={{
fill: colorTransformer(MetricsExplorerColor.color1),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: domain.min,
y1: first(expression.threshold),
},
},
]}
/>
<RectAnnotation
id="upper-threshold"
style={{
fill: colorTransformer(MetricsExplorerColor.color1),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: last(expression.threshold),
y1: domain.max,
},
},
]}
/>
</>
) : null}
{isAbove ? (
<RectAnnotation
id="upper-threshold"
style={{
fill: colorTransformer(MetricsExplorerColor.color1),
opacity,
}}
dataValues={[
{
coordinates: {
x0: firstTimestamp,
x1: lastTimestamp,
y0: first(expression.threshold),
y1: domain.max,
},
},
]}
/>
) : null}
<Axis
id={'timestamp'}
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={dateFormatter}
/>
<Axis id={'values'} position={Position.Left} tickFormat={yAxisFormater} domain={domain} />
<Settings tooltip={tooltipProps} theme={getChartTheme(isDarkMode)} />
</Chart>
</ChartContainer>
<div style={{ textAlign: 'center' }}>
{series.id !== 'ALL' ? (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.infra.metrics.alerts.dataTimeRangeLabelWithGrouping"
defaultMessage="Last 20 {timeLabel} of data for {id}"
values={{ id: series.id, timeLabel }}
/>
</EuiText>
) : (
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.infra.metrics.alerts.dataTimeRangeLabel"
defaultMessage="Last 20 {timeLabel}"
values={{ timeLabel }}
/>
</EuiText>
)}
</div>
</>
);
};
const EmptyContainer: React.FC = ({ children }) => (
<div
style={{
width: '100%',
height: 150,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{children}
</div>
);
const ChartContainer: React.FC = ({ children }) => (
<div
style={{
width: '100%',
height: 150,
}}
>
{children}
</div>
);

View file

@ -0,0 +1,237 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiSpacer } from '@elastic/eui';
import { IFieldType } from 'src/plugins/data/public';
import {
WhenExpression,
OfExpression,
ThresholdExpression,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../triggers_actions_ui/public/common';
import { euiStyled } from '../../../../../observability/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { IErrorObject } from '../../../../../triggers_actions_ui/public/types';
import { MetricExpression, AGGREGATION_TYPES } from '../types';
import {
Comparator,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../server/lib/alerting/metric_threshold/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { builtInComparators } from '../../../../../triggers_actions_ui/public/common/constants';
const customComparators = {
...builtInComparators,
[Comparator.OUTSIDE_RANGE]: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.outsideRangeLabel', {
defaultMessage: 'Is not between',
}),
value: Comparator.OUTSIDE_RANGE,
requiredValues: 2,
},
};
interface ExpressionRowProps {
fields: IFieldType[];
expressionId: number;
expression: MetricExpression;
errors: IErrorObject;
canDelete: boolean;
addExpression(): void;
remove(id: number): void;
setAlertParams(id: number, params: MetricExpression): void;
}
const StyledExpressionRow = euiStyled(EuiFlexGroup)`
display: flex;
flex-wrap: wrap;
margin: 0 -4px;
`;
const StyledExpression = euiStyled.div`
padding: 0 4px;
`;
export const ExpressionRow: React.FC<ExpressionRowProps> = props => {
const [isExpanded, setRowState] = useState(true);
const toggleRowState = useCallback(() => setRowState(!isExpanded), [isExpanded]);
const {
children,
setAlertParams,
expression,
errors,
expressionId,
remove,
fields,
canDelete,
} = props;
const {
aggType = AGGREGATION_TYPES.MAX,
metric,
comparator = Comparator.GT,
threshold = [],
} = expression;
const updateAggType = useCallback(
(at: string) => {
setAlertParams(expressionId, {
...expression,
aggType: at as MetricExpression['aggType'],
metric: at === 'count' ? undefined : expression.metric,
});
},
[expressionId, expression, setAlertParams]
);
const updateMetric = useCallback(
(m?: MetricExpression['metric']) => {
setAlertParams(expressionId, { ...expression, metric: m });
},
[expressionId, expression, setAlertParams]
);
const updateComparator = useCallback(
(c?: string) => {
setAlertParams(expressionId, { ...expression, comparator: c as Comparator });
},
[expressionId, expression, setAlertParams]
);
const updateThreshold = useCallback(
t => {
if (t.join() !== expression.threshold.join()) {
setAlertParams(expressionId, { ...expression, threshold: t });
}
},
[expressionId, expression, setAlertParams]
);
return (
<>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={isExpanded ? 'arrowDown' : 'arrowRight'}
onClick={toggleRowState}
aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.expandRowLabel', {
defaultMessage: 'Expand row.',
})}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<StyledExpressionRow>
<StyledExpression>
<WhenExpression
customAggTypesOptions={aggregationType}
aggType={aggType}
onChangeSelectedAggType={updateAggType}
/>
</StyledExpression>
{aggType !== 'count' && (
<StyledExpression>
<OfExpression
customAggTypesOptions={aggregationType}
aggField={metric}
fields={fields.map(f => ({
normalizedType: f.type,
name: f.name,
}))}
aggType={aggType}
errors={errors}
onChangeSelectedAggField={updateMetric}
/>
</StyledExpression>
)}
<StyledExpression>
<ThresholdExpression
thresholdComparator={comparator || Comparator.GT}
threshold={threshold}
customComparators={customComparators}
onChangeSelectedThresholdComparator={updateComparator}
onChangeSelectedThreshold={updateThreshold}
errors={errors}
/>
</StyledExpression>
</StyledExpressionRow>
</EuiFlexItem>
{canDelete && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', {
defaultMessage: 'Remove condition',
})}
color={'danger'}
iconType={'trash'}
onClick={() => remove(expressionId)}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
{isExpanded ? <div style={{ padding: '0 0 0 28px' }}>{children}</div> : null}
<EuiSpacer size={'s'} />
</>
);
};
export const aggregationType: { [key: string]: any } = {
avg: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', {
defaultMessage: 'Average',
}),
fieldRequired: true,
validNormalizedTypes: ['number'],
value: AGGREGATION_TYPES.AVERAGE,
},
max: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.max', {
defaultMessage: 'Max',
}),
fieldRequired: true,
validNormalizedTypes: ['number', 'date'],
value: AGGREGATION_TYPES.MAX,
},
min: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.min', {
defaultMessage: 'Min',
}),
fieldRequired: true,
validNormalizedTypes: ['number', 'date'],
value: AGGREGATION_TYPES.MIN,
},
cardinality: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.cardinality', {
defaultMessage: 'Cardinality',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.CARDINALITY,
validNormalizedTypes: ['number'],
},
rate: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.rate', {
defaultMessage: 'Rate',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.RATE,
validNormalizedTypes: ['number'],
},
count: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.count', {
defaultMessage: 'Document count',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.COUNT,
validNormalizedTypes: ['number'],
},
sum: {
text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.sum', {
defaultMessage: 'Sum',
}),
fieldRequired: false,
value: AGGREGATION_TYPES.SUM,
validNormalizedTypes: ['number'],
},
};

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IIndexPattern } from 'src/plugins/data/public';
import { useMemo } from 'react';
import { InfraSource } from '../../../../common/http_api/source_api';
import { AlertContextMeta, MetricExpression } from '../types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context';
import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data';
export const useMetricsExplorerChartData = (
expression: MetricExpression,
context: AlertsContextValue<AlertContextMeta>,
derivedIndexPattern: IIndexPattern,
source: InfraSource | null,
filterQuery?: string,
groupBy?: string
) => {
const { timeSize, timeUnit } = expression || { timeSize: 1, timeUnit: 'm' };
const options: MetricsExplorerOptions = useMemo(
() => ({
limit: 1,
forceInterval: true,
groupBy,
filterQuery,
metrics: [
{
field: expression.metric,
aggregation: expression.aggType,
},
],
aggregation: expression.aggType || 'avg',
}),
[expression.aggType, expression.metric, filterQuery, groupBy]
);
const timerange = useMemo(
() => ({
interval: `>=${timeSize || 1}${timeUnit}`,
from: `now-${(timeSize || 1) * 20}${timeUnit}`,
to: 'now',
}),
[timeSize, timeUnit]
);
return useMetricsExplorerData(
options,
source?.configuration,
derivedIndexPattern,
timerange,
null,
null,
context.http.fetch
);
};

View file

@ -5,13 +5,13 @@
*/
import { i18n } from '@kbn/i18n';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types';
import { Expressions } from './expression';
import { validateMetricThreshold } from './validation';
import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types';
import { Expressions } from './components/expression';
import { validateMetricThreshold } from './components/validation';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types';
import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../server/lib/alerting/metric_threshold/types';
export function getAlertType(): AlertTypeModel {
export function createMetricThresholdAlertType(): AlertTypeModel {
return {
id: METRIC_THRESHOLD_ALERT_TYPE_ID,
name: i18n.translate('xpack.infra.metrics.alertFlyout.alertName', {

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { first } from 'lodash';
import { MetricsExplorerResponse } from '../../../../common/http_api/metrics_explorer';
import { MetricThresholdAlertParams, ExpressionChartSeries } from '../types';
export const transformMetricsExplorerData = (
params: MetricThresholdAlertParams,
data: MetricsExplorerResponse | null
) => {
const { criteria } = params;
if (criteria && data) {
const firstSeries = first(data.series);
const series = firstSeries.rows.reduce((acc, row) => {
const { timestamp } = row;
criteria.forEach((item, index) => {
if (!acc[index]) {
acc[index] = [];
}
const value = (row[`metric_${index}`] as number) || 0;
acc[index].push({ timestamp, value });
});
return acc;
}, [] as ExpressionChartSeries);
return { id: firstSeries.id, series };
}
};

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
MetricExpressionParams,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../server/lib/alerting/metric_threshold/types';
import { MetricsExplorerOptions } from '../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
import { MetricsExplorerSeries } from '../../../common/http_api/metrics_explorer';
export interface AlertContextMeta {
currentOptions?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
}
export type TimeUnit = 's' | 'm' | 'h' | 'd';
export type MetricExpression = Omit<MetricExpressionParams, 'metric'> & {
metric?: string;
};
export enum AGGREGATION_TYPES {
COUNT = 'count',
AVERAGE = 'avg',
SUM = 'sum',
MIN = 'min',
MAX = 'max',
RATE = 'rate',
CARDINALITY = 'cardinality',
}
export interface MetricThresholdAlertParams {
criteria?: MetricExpression[];
groupBy?: string;
filterQuery?: string;
sourceId?: string;
}
export interface ExpressionChartRow {
timestamp: number;
value: number;
}
export type ExpressionChartSeries = ExpressionChartRow[][];
export interface ExpressionChartData {
id: string;
series: ExpressionChartSeries;
}

View file

@ -28,7 +28,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { WaffleOptionsProvider } from './inventory_view/hooks/use_waffle_options';
import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time';
import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters';
import { AlertDropdown } from '../../components/alerting/metrics/alert_dropdown';
import { AlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown';
export const InfrastructurePage = ({ match }: RouteComponentProps) => {
const uiCapabilities = useKibana().services.application?.capabilities;

View file

@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo, useState } from 'react';
import { AlertFlyout } from '../../../../../components/alerting/metrics/alert_flyout';
import { AlertFlyout } from '../../../../../alerting/metric_threshold/components/alert_flyout';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib';
import { getNodeDetailUrl, getNodeLogsUrl } from '../../../../link_to';
import { createUptimeLink } from '../../lib/create_uptime_link';

View file

@ -26,6 +26,9 @@ export const MetricsExplorerAggregationPicker = ({ options, onChange }: Props) =
['avg']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.avg', {
defaultMessage: 'Average',
}),
['sum']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.sum', {
defaultMessage: 'Sum',
}),
['max']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.max', {
defaultMessage: 'Max',
}),

View file

@ -14,7 +14,7 @@ import {
} from '@elastic/eui';
import DateMath from '@elastic/datemath';
import { Capabilities } from 'src/core/public';
import { AlertFlyout } from '../../../../components/alerting/metrics/alert_flyout';
import { AlertFlyout } from '../../../../alerting/metric_threshold/components/alert_flyout';
import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer';
import {
MetricsExplorerOptions,

View file

@ -4,8 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { MetricsExplorerMetric } from '../../../../../../common/http_api/metrics_explorer';
import { MetricsExplorerOptionsMetric } from '../../hooks/use_metrics_explorer_options';
export const createMetricLabel = (metric: MetricsExplorerMetric) => {
export const createMetricLabel = (metric: MetricsExplorerOptionsMetric) => {
if (metric.label) {
return metric.label;
}
return `${metric.aggregation}(${metric.field || ''})`;
};

View file

@ -21,12 +21,15 @@ import {
MetricsExplorerChartType,
} from '../hooks/use_metrics_explorer_options';
type NumberOrString = string | number;
interface Props {
metric: MetricsExplorerOptionsMetric;
id: string | number;
id: NumberOrString | NumberOrString[];
series: MetricsExplorerSeries;
type: MetricsExplorerChartType;
stack: boolean;
opacity?: number;
}
export const MetricExplorerSeriesChart = (props: Props) => {
@ -36,13 +39,17 @@ export const MetricExplorerSeriesChart = (props: Props) => {
return <MetricsExplorerAreaChart {...props} />;
};
export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack }: Props) => {
export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack, opacity }: Props) => {
const color =
(metric.color && colorTransformer(metric.color)) ||
colorTransformer(MetricsExplorerColor.color0);
const yAccessor = `metric_${id}`;
const chartId = `series-${series.id}-${yAccessor}`;
const yAccessors = Array.isArray(id)
? id.map(i => `metric_${i}`).slice(id.length - 1, id.length)
: [`metric_${id}`];
const y0Accessors =
Array.isArray(id) && id.length > 1 ? id.map(i => `metric_${i}`).slice(0, 1) : undefined;
const chartId = `series-${series.id}-${yAccessors.join('-')}`;
const seriesAreaStyle: RecursivePartial<AreaSeriesStyle> = {
line: {
@ -50,19 +57,21 @@ export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack }: Pr
visible: true,
},
area: {
opacity: 0.5,
opacity: opacity || 0.5,
visible: type === MetricsExplorerChartType.area,
},
};
return (
<AreaSeries
id={yAccessor}
id={chartId}
key={chartId}
name={createMetricLabel(metric)}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="timestamp"
yAccessors={[yAccessor]}
yAccessors={yAccessors}
y0Accessors={y0Accessors}
data={series.rows}
stackAccessors={stack ? ['timestamp'] : void 0}
areaSeriesStyle={seriesAreaStyle}

View file

@ -7,6 +7,7 @@
import DateMath from '@elastic/datemath';
import { isEqual } from 'lodash';
import { useEffect, useState } from 'react';
import { HttpHandler } from 'target/types/core/public/http';
import { IIndexPattern } from 'src/plugins/data/public';
import { SourceQuery } from '../../../../../common/graphql/types';
import {
@ -24,13 +25,15 @@ function isSameOptions(current: MetricsExplorerOptions, next: MetricsExplorerOpt
export function useMetricsExplorerData(
options: MetricsExplorerOptions,
source: SourceQuery.Query['source']['configuration'],
source: SourceQuery.Query['source']['configuration'] | undefined,
derivedIndexPattern: IIndexPattern,
timerange: MetricsExplorerTimeOptions,
afterKey: string | null,
signal: any
signal: any,
fetch?: HttpHandler
) {
const fetch = useKibana().services.http?.fetch;
const kibana = useKibana();
const fetchFn = fetch ? fetch : kibana.services.http?.fetch;
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<MetricsExplorerResponse | null>(null);
@ -46,13 +49,17 @@ export function useMetricsExplorerData(
if (!from || !to) {
throw new Error('Unalble to parse timerange');
}
if (!fetch) {
if (!fetchFn) {
throw new Error('HTTP service is unavailable');
}
if (!source) {
throw new Error('Source is unavailable');
}
const response = decodeOrThrow(metricsExplorerResponseRT)(
await fetch('/api/infra/metrics_explorer', {
await fetchFn('/api/infra/metrics_explorer', {
method: 'POST',
body: JSON.stringify({
forceInterval: options.forceInterval,
metrics:
options.aggregation === 'count'
? [{ aggregation: 'count' }]

View file

@ -40,6 +40,7 @@ export interface MetricsExplorerOptions {
groupBy?: string;
filterQuery?: string;
aggregation: MetricsExplorerAggregation;
forceInterval?: boolean;
}
export interface MetricsExplorerTimeOptions {

View file

@ -21,8 +21,8 @@ import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/p
import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public';
import { getAlertType as getMetricsAlertType } from './components/alerting/metrics/metric_threshold_alert_type';
import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type';
import { createMetricThresholdAlertType } from './alerting/metric_threshold';
export type ClientSetup = void;
export type ClientStart = void;
@ -53,8 +53,8 @@ export class Plugin
setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) {
registerFeatures(pluginsSetup.home);
pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getMetricsAlertType());
pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType());
pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createMetricThresholdAlertType());
core.application.register({
id: 'logs',

View file

@ -72,7 +72,9 @@ export const populateSeriesWithTSVBData = (
);
if (calculatedInterval) {
model.interval = `>=${calculatedInterval}s`;
model.interval = options.forceInterval
? options.timerange.interval
: `>=${calculatedInterval}s`;
}
// Get TSVB results using the model, timerange and filters