[Metrics UI] Alerting for metrics explorer and inventory (#58779)

* Add flyout with expressions

* Integrate frontend with backend

* Extended AlertContextValue with metadata optional property

* Progress

* Pre-fill criteria with current page filters

* Better validation. Naming for clarity

* Fix types for flyout

* Respect the groupby property in metric explorer

* Fix lint errors

* Fix text, add toast notifications

* Fix tests. Make sure update handles predefined expressions

* Dynamically load source from alert flyout

* Remove unused import

* Simplify and add group by functionality

* Remove unecessary useEffect

* disable exhastive deps

* Remove unecessary useEffect

* change language

* Implement design feedback

* Add alert dropdown to the header and snapshot screen

* Remove icon

* Remove unused props. Code cleanup

* Remove unused values

* Fix formatted message id

* Remove create alert option for now.

* Fix type issue

* Add rate, card and count as aggs

* Fix types

Co-authored-by: Yuliia Naumenko <yuliia.naumenko@elastic.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Henry Harding <henry.harding@elastic.co>
This commit is contained in:
Phillip Burch 2020-03-23 10:02:11 -05:00 committed by GitHub
parent 8572e3f18f
commit a790877694
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 887 additions and 96 deletions

View file

@ -11,7 +11,8 @@
"data",
"dataEnhanced",
"metrics",
"alerting"
"alerting",
"triggers_actions_ui"
],
"server": true,
"ui": true,

View file

@ -15,7 +15,8 @@ import { CoreStart, AppMountParameters } from 'kibana/public';
// TODO use theme provided from parentApp when kibana supports it
import { EuiErrorBoundary } from '@elastic/eui';
import { EuiThemeProvider } from '../../../observability/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { EuiThemeProvider } from '../../../observability/public/typings/eui_styled_components';
import { InfraFrontendLibs } from '../lib/lib';
import { createStore } from '../store';
import { ApolloClientContext } from '../utils/apollo_context';
@ -26,6 +27,8 @@ import {
KibanaContextProvider,
} from '../../../../../src/plugins/kibana_react/public';
import { AppRouter } from '../routers';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public';
import { TriggersActionsProvider } from '../utils/triggers_actions_context';
import '../index.scss';
export const CONTAINER_CLASSNAME = 'infra-container-element';
@ -35,7 +38,8 @@ export async function startApp(
core: CoreStart,
plugins: object,
params: AppMountParameters,
Router: AppRouter
Router: AppRouter,
triggersActionsUI: TriggersAndActionsUIPublicPluginSetup
) {
const { element, appBasePath } = params;
const history = createBrowserHistory({ basename: appBasePath });
@ -51,19 +55,21 @@ export async function startApp(
return (
<core.i18n.Context>
<EuiErrorBoundary>
<ReduxStoreProvider store={store}>
<ReduxStateContextProvider>
<ApolloProvider client={libs.apolloClient}>
<ApolloClientContext.Provider value={libs.apolloClient}>
<EuiThemeProvider darkMode={darkMode}>
<HistoryContext.Provider value={history}>
<Router history={history} />
</HistoryContext.Provider>
</EuiThemeProvider>
</ApolloClientContext.Provider>
</ApolloProvider>
</ReduxStateContextProvider>
</ReduxStoreProvider>
<TriggersActionsProvider triggersActionsUI={triggersActionsUI}>
<ReduxStoreProvider store={store}>
<ReduxStateContextProvider>
<ApolloProvider client={libs.apolloClient}>
<ApolloClientContext.Provider value={libs.apolloClient}>
<EuiThemeProvider darkMode={darkMode}>
<HistoryContext.Provider value={history}>
<Router history={history} />
</HistoryContext.Provider>
</EuiThemeProvider>
</ApolloClientContext.Provider>
</ApolloProvider>
</ReduxStateContextProvider>
</ReduxStoreProvider>
</TriggersActionsProvider>
</EuiErrorBoundary>
</core.i18n.Context>
);

View file

@ -0,0 +1,62 @@
/*
* 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, { useState, useCallback, useMemo } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { AlertFlyout } from './alert_flyout';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
export const AlertDropdown = () => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [flyoutVisible, setFlyoutVisible] = useState(false);
const kibana = useKibana();
const closePopover = useCallback(() => {
setPopoverOpen(false);
}, [setPopoverOpen]);
const openPopover = useCallback(() => {
setPopoverOpen(true);
}, [setPopoverOpen]);
const menuItems = useMemo(() => {
return [
<EuiContextMenuItem icon="bell" key="createLink" onClick={() => setFlyoutVisible(true)}>
<FormattedMessage
id="xpack.infra.alerting.createAlertButton"
defaultMessage="Create alert"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem
icon="tableOfContents"
key="manageLink"
href={kibana.services?.application?.getUrlForApp(
'kibana#/management/kibana/triggersActions/alerts'
)}
>
<FormattedMessage id="xpack.infra.alerting.manageAlerts" defaultMessage="Manage Alerts" />
</EuiContextMenuItem>,
];
}, [kibana.services]);
return (
<>
<EuiPopover
button={
<EuiButtonEmpty iconSide={'right'} iconType={'arrowDown'} onClick={openPopover}>
<FormattedMessage id="xpack.infra.alerting.alertsButton" defaultMessage="Alerts" />
</EuiButtonEmpty>
}
isOpen={popoverOpen}
closePopover={closePopover}
>
<EuiContextMenuPanel items={menuItems} />
</EuiPopover>
<AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} />
</>
);
};

View file

@ -0,0 +1,53 @@
/*
* 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, { useContext } from 'react';
import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public';
import { TriggerActionsContext } from '../../../utils/triggers_actions_context';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types';
import { MetricsExplorerOptions } from '../../../containers/metrics_explorer/use_metrics_explorer_options';
import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer';
interface Props {
visible?: boolean;
options?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
setVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AlertFlyout = (props: Props) => {
const { triggersActionsUI } = useContext(TriggerActionsContext);
const { services } = useKibana();
return (
<>
{triggersActionsUI && (
<AlertsContextProvider
value={{
metadata: {
currentOptions: props.options,
series: props.series,
},
toastNotifications: services.notifications?.toasts,
http: services.http,
actionTypeRegistry: triggersActionsUI.actionTypeRegistry,
alertTypeRegistry: triggersActionsUI.alertTypeRegistry,
}}
>
<AlertAdd
addFlyoutVisible={props.visible!}
setAddFlyoutVisibility={props.setVisible}
alertTypeId={METRIC_THRESHOLD_ALERT_TYPE_ID}
canChangeTrigger={false}
consumer={'metrics'}
/>
</AlertsContextProvider>
)}
</>
);
};

View file

@ -0,0 +1,473 @@
/*
* 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, useMemo, useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiSpacer,
EuiText,
EuiFormRow,
EuiButtonEmpty,
} from '@elastic/eui';
import { IFieldType } from 'src/plugins/data/public';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
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 { 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 { MetricsExplorerOptions } from '../../../containers/metrics_explorer/use_metrics_explorer_options';
import { MetricsExplorerKueryBar } from '../../metrics_explorer/kuery_bar';
import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer';
import { useSource } from '../../../containers/source';
import { MetricsExplorerGroupBy } from '../../metrics_explorer/group_by';
export interface MetricExpression {
aggType?: string;
metric?: string;
comparator?: Comparator;
threshold?: number[];
timeSize?: number;
timeUnit?: TimeUnit;
indexPattern?: string;
}
interface AlertContextMeta {
currentOptions?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
}
interface Props {
errors: IErrorObject[];
alertParams: {
criteria: MetricExpression[];
groupBy?: string;
filterQuery?: string;
};
alertsContext: AlertsContextValue<AlertContextMeta>;
setAlertParams(key: string, value: any): void;
setAlertProperty(key: string, value: any): void;
}
type Comparator = '>' | '>=' | 'between' | '<' | '<=';
type TimeUnit = 's' | 'm' | 'h' | 'd';
export const Expressions: React.FC<Props> = props => {
const { setAlertParams, alertParams, errors, alertsContext } = props;
const { source, createDerivedIndexPattern } = useSource({ sourceId: 'default' });
const [timeSize, setTimeSize] = useState<number | undefined>(1);
const [timeUnit, setTimeUnit] = useState<TimeUnit>('s');
const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [
createDerivedIndexPattern,
]);
const options = useMemo<MetricsExplorerOptions>(() => {
if (alertsContext.metadata?.currentOptions?.metrics) {
return alertsContext.metadata.currentOptions as MetricsExplorerOptions;
} else {
return {
metrics: [],
aggregation: 'avg',
};
}
}, [alertsContext.metadata]);
const defaultExpression = useMemo<MetricExpression>(
() => ({
aggType: AGGREGATION_TYPES.MAX,
comparator: '>',
threshold: [],
timeSize: 1,
timeUnit: 's',
indexPattern: source?.configuration.metricAlias,
}),
[source]
);
const updateParams = useCallback(
(id, e: MetricExpression) => {
const exp = alertParams.criteria ? alertParams.criteria.slice() : [];
exp[id] = { ...exp[id], ...e };
setAlertParams('criteria', exp);
},
[setAlertParams, alertParams.criteria]
);
const addExpression = useCallback(() => {
const exp = alertParams.criteria.slice();
exp.push(defaultExpression);
setAlertParams('criteria', exp);
}, [setAlertParams, alertParams.criteria, defaultExpression]);
const removeExpression = useCallback(
(id: number) => {
const exp = alertParams.criteria.slice();
if (exp.length > 1) {
exp.splice(id, 1);
setAlertParams('criteria', exp);
}
},
[setAlertParams, alertParams.criteria]
);
const onFilterQuerySubmit = useCallback(
(filter: any) => {
setAlertParams('filterQuery', filter);
},
[setAlertParams]
);
const onGroupByChange = useCallback(
(group: string | null) => {
setAlertParams('groupBy', group || undefined);
},
[setAlertParams]
);
const emptyError = useMemo(() => {
return {
aggField: [],
timeSizeUnit: [],
timeWindowSize: [],
};
}, []);
const updateTimeSize = useCallback(
(ts: number | undefined) => {
const criteria = alertParams.criteria.map(c => ({
...c,
timeSize: ts,
}));
setTimeSize(ts || undefined);
setAlertParams('criteria', criteria);
},
[alertParams.criteria, setAlertParams]
);
const updateTimeUnit = useCallback(
(tu: string) => {
const criteria = alertParams.criteria.map(c => ({
...c,
timeUnit: tu,
}));
setTimeUnit(tu as TimeUnit);
setAlertParams('criteria', criteria);
},
[alertParams.criteria, setAlertParams]
);
useEffect(() => {
const md = alertsContext.metadata;
if (md) {
if (md.currentOptions?.metrics) {
setAlertParams(
'criteria',
md.currentOptions.metrics.map(metric => ({
metric: metric.field,
comparator: '>',
threshold: [],
timeSize,
timeUnit,
indexPattern: source?.configuration.metricAlias,
aggType: metric.aggregation,
}))
);
} else {
setAlertParams('criteria', [defaultExpression]);
}
if (md.currentOptions) {
if (md.currentOptions.filterQuery) {
setAlertParams('filterQuery', md.currentOptions.filterQuery);
} else if (md.currentOptions.groupBy && md.series) {
const filter = `${md.currentOptions.groupBy}: "${md.series.id}"`;
setAlertParams('filterQuery', filter);
}
setAlertParams('groupBy', md.currentOptions.groupBy);
}
}
}, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<EuiSpacer size={'m'} />
<EuiText size="xs">
<h4>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.conditions"
defaultMessage="Conditions"
/>
</h4>
</EuiText>
<EuiSpacer size={'xs'} />
{alertParams.criteria &&
alertParams.criteria.map((e, idx) => {
return (
<ExpressionRow
canDelete={alertParams.criteria.length > 1}
fields={derivedIndexPattern.fields}
remove={removeExpression}
addExpression={addExpression}
key={idx} // idx's don't usually make good key's but here the index has semantic meaning
expressionId={idx}
setAlertParams={updateParams}
errors={errors[idx] || emptyError}
expression={e || {}}
/>
);
})}
<ForLastExpression
timeWindowSize={timeSize}
timeWindowUnit={timeUnit}
errors={emptyError}
onChangeWindowSize={updateTimeSize}
onChangeWindowUnit={updateTimeUnit}
/>
<div>
<EuiButtonEmpty
color={'primary'}
iconSide={'left'}
flush={'left'}
iconType={'plusInCircleFilled'}
onClick={addExpression}
>
<FormattedMessage
id="xpack.infra.metrics.alertFlyout.addCondition"
defaultMessage="Add condition"
/>
</EuiButtonEmpty>
</div>
<EuiSpacer size={'m'} />
<EuiFormRow
label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', {
defaultMessage: 'Filter',
})}
helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', {
defaultMessage: 'Filter help text',
})}
fullWidth
compressed
>
<MetricsExplorerKueryBar
derivedIndexPattern={derivedIndexPattern}
onSubmit={onFilterQuerySubmit}
value={alertParams.filterQuery}
/>
</EuiFormRow>
<EuiSpacer size={'m'} />
{alertsContext.metadata && (
<EuiFormRow
label={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerText', {
defaultMessage: 'Create alert per',
})}
helpText={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerHelpText', {
defaultMessage: 'Create alert help text',
})}
fullWidth
compressed
>
<MetricsExplorerGroupBy
onChange={onGroupByChange}
fields={derivedIndexPattern.fields}
options={{
...options,
groupBy: alertParams.groupBy || undefined,
}}
/>
</EuiFormRow>
)}
</>
);
};
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 -${props => props.theme.eui.euiSizeXS};
`;
const StyledExpression = euiStyled.div`
padding: 0 ${props => props.theme.eui.euiSizeXS};
`;
export const ExpressionRow: React.FC<ExpressionRowProps> = props => {
const { setAlertParams, expression, errors, expressionId, remove, fields, canDelete } = props;
const { aggType = AGGREGATION_TYPES.MAX, metric, comparator = '>', threshold = [] } = expression;
const updateAggType = useCallback(
(at: string) => {
setAlertParams(expressionId, { ...expression, aggType: at });
},
[expressionId, expression, setAlertParams]
);
const updateMetric = useCallback(
(m?: string) => {
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 => {
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 || '>'}
threshold={threshold}
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'} />
</>
);
};
enum AGGREGATION_TYPES {
COUNT = 'count',
AVERAGE = 'avg',
SUM = 'sum',
MIN = 'min',
MAX = 'max',
RATE = 'rate',
CARDINALITY = 'cardinality',
}
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'],
},
};

View file

@ -0,0 +1,24 @@
/*
* 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 { 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';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types';
export function getAlertType(): AlertTypeModel {
return {
id: METRIC_THRESHOLD_ALERT_TYPE_ID,
name: i18n.translate('xpack.infra.metrics.alertFlyout.alertName', {
defaultMessage: 'Alert Trigger',
}),
iconClass: 'bell',
alertParamsExpression: Expressions,
validate: validateMetricThreshold,
};
}

View file

@ -0,0 +1,80 @@
/*
* 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 { i18n } from '@kbn/i18n';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { MetricExpression } from './expression';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ValidationResult } from '../../../../../triggers_actions_ui/public/types';
export function validateMetricThreshold({
criteria,
}: {
criteria: MetricExpression[];
}): ValidationResult {
const validationResult = { errors: {} };
const errors: {
[id: string]: {
aggField: string[];
timeSizeUnit: string[];
timeWindowSize: string[];
threshold0: string[];
threshold1: string[];
};
} = {};
validationResult.errors = errors;
if (!criteria || !criteria.length) {
return validationResult;
}
criteria.forEach((c, idx) => {
// Create an id for each criteria, so we can map errors to specific criteria.
const id = idx.toString();
errors[id] = errors[id] || {
aggField: [],
timeSizeUnit: [],
timeWindowSize: [],
threshold0: [],
threshold1: [],
};
if (!c.aggType) {
errors[id].aggField.push(
i18n.translate('xpack.infra.metrics.alertFlyout.error.aggregationRequired', {
defaultMessage: 'Aggreation is required.',
})
);
}
if (!c.threshold || !c.threshold.length) {
errors[id].threshold0.push(
i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', {
defaultMessage: 'Threshold is required.',
})
);
}
if (c.comparator === 'between' && (!c.threshold || c.threshold.length < 2)) {
errors[id].threshold1.push(
i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', {
defaultMessage: 'Threshold is required.',
})
);
}
if (!c.timeSize) {
errors[id].timeWindowSize.push(
i18n.translate('xpack.infra.metrics.alertFlyout.error.timeRequred', {
defaultMessage: 'Time size is Required.',
})
);
}
});
return validationResult;
}

View file

@ -143,7 +143,7 @@ describe('MetricsExplorerChartContextMenu', () => {
uiCapabilities: customUICapabilities,
chartOptions,
});
expect(component.find('button').length).toBe(0);
expect(component.find('button').length).toBe(1);
});
});

View file

@ -24,6 +24,7 @@ import { createTSVBLink } from './helpers/create_tsvb_link';
import { getNodeDetailUrl } from '../../pages/link_to/redirect_to_node_detail';
import { SourceConfiguration } from '../../utils/source_configuration';
import { InventoryItemType } from '../../../common/inventory_models/types';
import { AlertFlyout } from '../alerting/metrics/alert_flyout';
import { useLinkProps } from '../../hooks/use_link_props';
export interface Props {
@ -81,6 +82,7 @@ export const MetricsExplorerChartContextMenu: React.FC<Props> = ({
chartOptions,
}: Props) => {
const [isPopoverOpen, setPopoverState] = useState(false);
const [flyoutVisible, setFlyoutVisible] = useState(false);
const supportFiltering = options.groupBy != null && onFilter != null;
const handleFilter = useCallback(() => {
// onFilter needs check for Typescript even though it's
@ -141,7 +143,20 @@ export const MetricsExplorerChartContextMenu: React.FC<Props> = ({
]
: [];
const itemPanels = [...filterByItem, ...openInVisualize, ...viewNodeDetail];
const itemPanels = [
...filterByItem,
...openInVisualize,
...viewNodeDetail,
{
name: i18n.translate('xpack.infra.metricsExplorer.alerts.createAlertButton', {
defaultMessage: 'Create alert',
}),
icon: 'bell',
onClick() {
setFlyoutVisible(true);
},
},
];
// If there are no itemPanels then there is no reason to show the actions button.
if (itemPanels.length === 0) return null;
@ -174,15 +189,24 @@ export const MetricsExplorerChartContextMenu: React.FC<Props> = ({
{actionLabel}
</EuiButtonEmpty>
);
return (
<EuiPopover
closePopover={handleClose}
id={`${series.id}-popover`}
button={button}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
<>
<EuiPopover
closePopover={handleClose}
id={`${series.id}-popover`}
button={button}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
<AlertFlyout
series={series}
options={options}
setVisible={setFlyoutVisible}
visible={flyoutVisible}
/>
</EuiPopover>
</>
);
};

View file

@ -16,6 +16,7 @@ interface Props {
derivedIndexPattern: IIndexPattern;
onSubmit: (query: string) => void;
value?: string | null;
placeholder?: string;
}
function validateQuery(query: string) {
@ -27,7 +28,12 @@ function validateQuery(query: string) {
return true;
}
export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value }: Props) => {
export const MetricsExplorerKueryBar = ({
derivedIndexPattern,
onSubmit,
value,
placeholder,
}: Props) => {
const [draftQuery, setDraftQuery] = useState<string>(value || '');
const [isValid, setValidation] = useState<boolean>(true);
@ -48,9 +54,12 @@ export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value }
fields: derivedIndexPattern.fields.filter(field => isDisplayable(field)),
};
const placeholder = i18n.translate('xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', {
defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)',
});
const defaultPlaceholder = i18n.translate(
'xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder',
{
defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)',
}
);
return (
<WithKueryAutocompletion indexPattern={filteredDerivedIndexPattern}>
@ -62,7 +71,7 @@ export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value }
loadSuggestions={loadSuggestions}
onChange={handleChange}
onSubmit={onSubmit}
placeholder={placeholder}
placeholder={placeholder || defaultPlaceholder}
suggestions={suggestions}
value={draftQuery}
/>

View file

@ -63,6 +63,7 @@ export const MetricsExplorerToolbar = ({
const isDefaultOptions = options.aggregation === 'avg' && options.metrics.length === 0;
const [timepickerQuickRanges] = useKibanaUiSetting('timepicker:quickRanges');
const commonlyUsedRanges = mapKibanaQuickRangesToDatePickerRanges(timepickerQuickRanges);
return (
<Toolbar>
<EuiFlexGroup alignItems="center">

View file

@ -8,7 +8,7 @@ import { EuiPopoverProps, EuiCode } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib';
import { getNodeDetailUrl, getNodeLogsUrl } from '../../pages/link_to';
import { createUptimeLink } from './lib/create_uptime_link';
@ -25,6 +25,7 @@ import {
SectionLink,
} from '../../../../observability/public';
import { useLinkProps } from '../../hooks/use_link_props';
import { AlertFlyout } from '../alerting/metrics/alert_flyout';
interface Props {
options: InfraWaffleMapOptions;
@ -46,6 +47,7 @@ export const NodeContextMenu: React.FC<Props> = ({
nodeType,
popoverPosition,
}) => {
const [flyoutVisible, setFlyoutVisible] = useState(false);
const inventoryModel = findInventoryModel(nodeType);
const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000;
const uiCapabilities = useKibana().services.application?.capabilities;
@ -144,41 +146,48 @@ export const NodeContextMenu: React.FC<Props> = ({
};
return (
<ActionMenu
closePopover={closePopover}
id={`${node.pathId}-popover`}
isOpen={isPopoverOpen}
button={children!}
anchorPosition={popoverPosition}
>
<div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu">
<Section>
<SectionTitle>
<FormattedMessage
id="xpack.infra.nodeContextMenu.title"
defaultMessage="{inventoryName} details"
values={{ inventoryName: inventoryModel.singularDisplayName }}
/>
</SectionTitle>
{inventoryId.label && (
<SectionSubtitle>
<div style={{ wordBreak: 'break-all' }}>
<FormattedMessage
id="xpack.infra.nodeContextMenu.description"
defaultMessage="View details for {label} {value}"
values={{ label: inventoryId.label, value: inventoryId.value }}
/>
</div>
</SectionSubtitle>
)}
<SectionLinks>
<SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} />
<SectionLink {...nodeDetailMenuItem} />
<SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} />
<SectionLink {...uptimeMenuItem} />
</SectionLinks>
</Section>
</div>
</ActionMenu>
<>
<ActionMenu
closePopover={closePopover}
id={`${node.pathId}-popover`}
isOpen={isPopoverOpen}
button={children!}
anchorPosition={popoverPosition}
>
<div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu">
<Section>
<SectionTitle>
<FormattedMessage
id="xpack.infra.nodeContextMenu.title"
defaultMessage="{inventoryName} details"
values={{ inventoryName: inventoryModel.singularDisplayName }}
/>
</SectionTitle>
{inventoryId.label && (
<SectionSubtitle>
<div style={{ wordBreak: 'break-all' }}>
<FormattedMessage
id="xpack.infra.nodeContextMenu.description"
defaultMessage="View details for {label} {value}"
values={{ label: inventoryId.label, value: inventoryId.value }}
/>
</div>
</SectionSubtitle>
)}
<SectionLinks>
<SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} />
<SectionLink {...nodeDetailMenuItem} />
<SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} />
<SectionLink {...uptimeMenuItem} />
</SectionLinks>
</Section>
</div>
</ActionMenu>
<AlertFlyout
options={{ filterQuery: `${nodeType}: ${node.id}` }}
setVisible={setFlyoutVisible}
visible={flyoutVisible}
/>
</>
);
};

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { DocumentTitle } from '../../components/document_title';
import { HelpCenterContent } from '../../components/help_center_content';
import { RoutedTabs } from '../../components/navigation/routed_tabs';
@ -24,9 +25,11 @@ import { MetricsSettingsPage } from './settings';
import { AppNavigation } from '../../components/navigation/app_navigation';
import { SourceLoadingPage } from '../../components/source_loading_page';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { AlertDropdown } from '../../components/alerting/metrics/alert_dropdown';
export const InfrastructurePage = ({ match }: RouteComponentProps) => {
const uiCapabilities = useKibana().services.application?.capabilities;
return (
<Source.Provider sourceId="default">
<ColumnarPage>
@ -59,31 +62,38 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => {
defaultMessage: 'Metrics',
})}
>
<RoutedTabs
tabs={[
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', {
defaultMessage: 'Inventory',
}),
pathname: '/inventory',
},
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', {
defaultMessage: 'Metrics Explorer',
}),
pathname: '/explorer',
},
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.settingsTabTitle', {
defaultMessage: 'Settings',
}),
pathname: '/settings',
},
]}
/>
<EuiFlexGroup gutterSize={'none'} alignItems={'center'}>
<EuiFlexItem>
<RoutedTabs
tabs={[
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.inventoryTabTitle', {
defaultMessage: 'Inventory',
}),
pathname: '/inventory',
},
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.metricsExplorerTabTitle', {
defaultMessage: 'Metrics Explorer',
}),
pathname: '/explorer',
},
{
app: 'metrics',
title: i18n.translate('xpack.infra.homePage.settingsTabTitle', {
defaultMessage: 'Settings',
}),
pathname: '/settings',
},
]}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertDropdown />
</EuiFlexItem>
</EuiFlexGroup>
</AppNavigation>
<Switch>

View file

@ -29,6 +29,8 @@ import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/pl
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public';
import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public';
import { LogsRouter, MetricsRouter } from './routers';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public';
import { getAlertType } from './components/alerting/metrics/metric_threshold_alert_type';
export type ClientSetup = void;
export type ClientStart = void;
@ -38,6 +40,7 @@ export interface ClientPluginsSetup {
data: DataPublicPluginSetup;
usageCollection: UsageCollectionSetup;
dataEnhanced: DataEnhancedSetup;
triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup;
}
export interface ClientPluginsStart {
@ -58,6 +61,8 @@ export class Plugin
setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) {
registerFeatures(pluginsSetup.home);
pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getAlertType());
core.application.register({
id: 'logs',
title: i18n.translate('xpack.infra.logs.pluginTitle', {
@ -76,7 +81,8 @@ export class Plugin
coreStart,
plugins,
params,
LogsRouter
LogsRouter,
pluginsSetup.triggers_actions_ui
);
},
});
@ -99,7 +105,8 @@ export class Plugin
coreStart,
plugins,
params,
MetricsRouter
MetricsRouter,
pluginsSetup.triggers_actions_ui
);
},
});

View file

@ -0,0 +1,32 @@
/*
* 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 * as React from 'react';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public';
interface ContextProps {
triggersActionsUI: TriggersAndActionsUIPublicPluginSetup | null;
}
export const TriggerActionsContext = React.createContext<ContextProps>({
triggersActionsUI: null,
});
interface Props {
triggersActionsUI: TriggersAndActionsUIPublicPluginSetup;
}
export const TriggersActionsProvider: React.FC<Props> = props => {
return (
<TriggerActionsContext.Provider
value={{
triggersActionsUI: props.triggersActionsUI,
}}
>
{props.children}
</TriggerActionsContext.Provider>
);
};