[Uptime] Ml detection of duration anomalies (#59785)

* add flyout

* add state

* update state

* ad job

* update

* updat

* add ml analyze button

* update api

* use differential colors for duration chart

* remove duration chart gql

* update type

* type fix

* fix tyoe

* update translation

* update test

* update conflicts

* update anomaly record

* chart

* added annotations

* update error handling

* update

* update types

* fixed types

* fix types

* update types

* update

* update

* remove unnecessary change

* remove unnecessary change

* fix type

* update

* save

* update pr

* update tets

* update job deletion

* update

* update tets

* upadte tests

* fix types

* update title text

* update types

* fixed tests

* update tests and types

* updated types

* fix PR feedback

* unit test

* update more types

* update test and manage  job

* resolve conflicts

* types

* remove unnecessary change

* revert ml code

* revert ml code

* fixed formatting issues pointed by pr feedback
This commit is contained in:
Shahzad 2020-03-24 12:34:43 +01:00 committed by GitHub
parent 4dbcb3c0e9
commit d31e5f524f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 2522 additions and 180 deletions

View file

@ -26,6 +26,7 @@ export * from './field_icon';
export * from './table_list_view';
export * from './split_panel';
export { ValidatedDualRange } from './validated_range';
export * from './notifications';
export { Markdown, MarkdownSimple } from './markdown';
export { reactToUiComponent, uiToReactComponent } from './adapters';
export { useUrlTracker } from './use_url_tracker';

View file

@ -16,4 +16,10 @@ export enum API_URLS {
PING_HISTOGRAM = `/api/uptime/ping/histogram`,
SNAPSHOT_COUNT = `/api/uptime/snapshot/count`,
FILTERS = `/api/uptime/filters`,
ML_MODULE_JOBS = `/api/ml/modules/jobs_exist/`,
ML_SETUP_MODULE = '/api/ml/modules/setup/',
ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`,
ML_CAPABILITIES = '/api/ml/ml_capabilities',
ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`,
}

View file

@ -15,6 +15,10 @@ export enum STATUS {
DOWN = 'down',
}
export const ML_JOB_ID = 'high_latency_by_geo';
export const ML_MODULE_ID = 'uptime_heartbeat';
export const UNNAMED_LOCATION = 'Unnamed-location';
export const SHORT_TS_LOCALE = 'en-short-locale';

View file

@ -7,10 +7,21 @@
import React, { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useUrlParams } from '../../../hooks';
import { getMonitorDurationAction } from '../../../state/actions';
import {
getAnomalyRecordsAction,
getMLCapabilitiesAction,
getMonitorDurationAction,
} from '../../../state/actions';
import { DurationChartComponent } from '../../functional/charts';
import { selectDurationLines } from '../../../state/selectors';
import {
anomaliesSelector,
hasMLFeatureAvailable,
hasMLJobSelector,
selectDurationLines,
} from '../../../state/selectors';
import { UptimeRefreshContext } from '../../../contexts';
import { getMLJobId } from '../../../state/api/ml_anomaly';
import { JobStat } from '../../../../../../../plugins/ml/common/types/data_recognizer';
interface Props {
monitorId: string;
@ -18,24 +29,58 @@ interface Props {
export const DurationChart: React.FC<Props> = ({ monitorId }: Props) => {
const [getUrlParams] = useUrlParams();
const { dateRangeStart, dateRangeEnd } = getUrlParams();
const {
dateRangeStart,
dateRangeEnd,
absoluteDateRangeStart,
absoluteDateRangeEnd,
} = getUrlParams();
const { monitor_duration, loading } = useSelector(selectDurationLines);
const { durationLines, loading } = useSelector(selectDurationLines);
const isMLAvailable = useSelector(hasMLFeatureAvailable);
const { data: mlJobs, loading: jobsLoading } = useSelector(hasMLJobSelector);
const hasMLJob =
!!mlJobs?.jobsExist &&
!!mlJobs.jobs.find((job: JobStat) => job.id === getMLJobId(monitorId as string));
const anomalies = useSelector(anomaliesSelector);
const dispatch = useDispatch();
const { lastRefresh } = useContext(UptimeRefreshContext);
useEffect(() => {
dispatch(
getMonitorDurationAction({ monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd })
);
if (isMLAvailable) {
const anomalyParams = {
listOfMonitorIds: [monitorId],
dateStart: absoluteDateRangeStart,
dateEnd: absoluteDateRangeEnd,
};
dispatch(getAnomalyRecordsAction.get(anomalyParams));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId, isMLAvailable]);
useEffect(() => {
const params = { monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd };
dispatch(getMonitorDurationAction(params));
}, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId]);
useEffect(() => {
dispatch(getMLCapabilitiesAction.get());
}, [dispatch]);
return (
<DurationChartComponent
locationDurationLines={monitor_duration?.locationDurationLines ?? []}
loading={loading}
anomalies={anomalies}
hasMLJob={hasMLJob}
loading={loading || jobsLoading}
locationDurationLines={durationLines?.locationDurationLines ?? []}
/>
);
};

View file

@ -11,7 +11,7 @@ import { indexStatusSelector } from '../../../state/selectors';
import { EmptyStateComponent } from '../../functional/empty_state/empty_state';
export const EmptyState: React.FC = ({ children }) => {
const { data, loading, errors } = useSelector(indexStatusSelector);
const { data, loading, error } = useSelector(indexStatusSelector);
const dispatch = useDispatch();
@ -23,7 +23,7 @@ export const EmptyState: React.FC = ({ children }) => {
<EmptyStateComponent
statesIndexStatus={data}
loading={loading}
errors={errors}
errors={error ? [error] : undefined}
children={children as React.ReactElement}
/>
);

View file

@ -22,7 +22,8 @@ interface StateProps {
}
interface DispatchProps {
loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => void;
loadMonitorStatus: typeof getMonitorStatusAction;
loadSelectedMonitor: typeof getSelectedMonitorAction;
}
interface OwnProps {
@ -33,6 +34,7 @@ type Props = OwnProps & StateProps & DispatchProps;
const Container: React.FC<Props> = ({
loadMonitorStatus,
loadSelectedMonitor,
monitorId,
monitorStatus,
monitorLocations,
@ -43,8 +45,9 @@ const Container: React.FC<Props> = ({
const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = getUrlParams();
useEffect(() => {
loadMonitorStatus(dateStart, dateEnd, monitorId);
}, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh]);
loadMonitorStatus({ dateStart, dateEnd, monitorId });
loadSelectedMonitor({ monitorId });
}, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh, loadSelectedMonitor]);
return (
<MonitorStatusBarComponent
@ -61,20 +64,8 @@ const mapStateToProps = (state: AppState, ownProps: OwnProps) => ({
});
const mapDispatchToProps = (dispatch: Dispatch<any>): DispatchProps => ({
loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => {
dispatch(
getMonitorStatusAction({
monitorId,
dateStart,
dateEnd,
})
);
dispatch(
getSelectedMonitorAction({
monitorId,
})
);
},
loadSelectedMonitor: params => dispatch(getSelectedMonitorAction(params)),
loadMonitorStatus: params => dispatch(getMonitorStatusAction(params)),
});
// @ts-ignore TODO: Investigate typescript issues here

View file

@ -52,6 +52,8 @@ exports[`MonitorCharts component renders the component without errors 1`] = `
}
>
<DurationChartComponent
anomalies={null}
hasMLJob={false}
loading={false}
locationDurationLines={
Array [

View file

@ -64,8 +64,10 @@ describe('MonitorCharts component', () => {
it('renders the component without errors', () => {
const component = shallowWithRouter(
<DurationChartComponent
locationDurationLines={chartResponse.monitorChartsData.locationDurationLines}
loading={false}
hasMLJob={false}
anomalies={null}
locationDurationLines={chartResponse.monitorChartsData.locationDurationLines}
/>
);
expect(component).toMatchSnapshot();

View file

@ -0,0 +1,54 @@
/*
* 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 from 'react';
import moment from 'moment';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n/react';
const Header = styled.div`
font-weight: bold;
padding-left: 4px;
`;
const RecordSeverity = styled.div`
font-weight: bold;
border-left: 4px solid ${props => props.color};
padding-left: 2px;
`;
const TimeDiv = styled.div`
font-weight: 500;
border-bottom: 1px solid gray;
padding-bottom: 2px;
`;
export const AnnotationTooltip = ({ details }: { details: string }) => {
const data = JSON.parse(details);
function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
return (
<>
<TimeDiv>{moment(data.time).format('lll')}</TimeDiv>
<Header>
<FormattedMessage
id="xpack.uptime.charts.mlAnnotation.header"
defaultMessage="Score: {score}"
values={{ score: data.score.toFixed(2) }}
/>
</Header>
<RecordSeverity color={data.color}>
<FormattedMessage
id="xpack.uptime.charts.mlAnnotation.severity"
defaultMessage="Severity: {severity}"
values={{ severity: capitalizeFirstLetter(data.severity) }}
/>
</RecordSeverity>
</>
);
};

View file

@ -4,12 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts';
import { EuiPanel, EuiTitle } from '@elastic/eui';
import React from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui';
import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts';
import { SeriesIdentifier } from '@elastic/charts/dist/chart_types/xy_chart/utils/series';
import { getChartDateLabel } from '../../../lib/helper';
import { LocationDurationLine } from '../../../../common/types';
import { DurationLineSeriesList } from './duration_line_series_list';
@ -17,6 +18,9 @@ import { ChartWrapper } from './chart_wrapper';
import { useUrlParams } from '../../../hooks';
import { getTickFormat } from './get_tick_format';
import { ChartEmptyState } from './chart_empty_state';
import { DurationAnomaliesBar } from './duration_line_bar_list';
import { MLIntegrationComponent } from '../../monitor_details/ml/ml_integeration';
import { AnomalyRecords } from '../../../state/actions';
interface DurationChartProps {
/**
@ -29,6 +33,10 @@ interface DurationChartProps {
* To represent the loading spinner on chart
*/
loading: boolean;
hasMLJob: boolean;
anomalies: AnomalyRecords | null;
}
/**
@ -37,29 +45,64 @@ interface DurationChartProps {
* milliseconds.
* @param props The props required for this component to render properly
*/
export const DurationChartComponent = ({ locationDurationLines, loading }: DurationChartProps) => {
export const DurationChartComponent = ({
locationDurationLines,
anomalies,
loading,
hasMLJob,
}: DurationChartProps) => {
const hasLines = locationDurationLines.length > 0;
const [getUrlParams, updateUrlParams] = useUrlParams();
const { absoluteDateRangeStart: min, absoluteDateRangeEnd: max } = getUrlParams();
const [hiddenLegends, setHiddenLegends] = useState<string[]>([]);
const onBrushEnd = (minX: number, maxX: number) => {
updateUrlParams({
dateRangeStart: moment(minX).toISOString(),
dateRangeEnd: moment(maxX).toISOString(),
});
};
const legendToggleVisibility = (legendItem: SeriesIdentifier | null) => {
if (legendItem) {
setHiddenLegends(prevState => {
if (prevState.includes(legendItem.specId)) {
return [...prevState.filter(item => item !== legendItem.specId)];
} else {
return [...prevState, legendItem.specId];
}
});
}
};
return (
<>
<EuiPanel paddingSize="m">
<EuiTitle size="xs">
<h4>
<FormattedMessage
id="xpack.uptime.monitorCharts.monitorDuration.titleLabel"
defaultMessage="Monitor duration"
description="The 'ms' is an abbreviation for milliseconds."
/>
</h4>
</EuiTitle>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h4>
{hasMLJob ? (
<FormattedMessage
id="xpack.uptime.monitorCharts.monitorDuration.titleLabelWithAnomaly"
defaultMessage="Monitor duration (Anomalies: {noOfAnomalies})"
values={{ noOfAnomalies: anomalies?.anomalies?.length ?? 0 }}
/>
) : (
<FormattedMessage
id="xpack.uptime.monitorCharts.monitorDuration.titleLabel"
defaultMessage="Monitor duration"
/>
)}
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MLIntegrationComponent />
</EuiFlexItem>
</EuiFlexGroup>
<ChartWrapper height="400px" loading={loading}>
{hasLines ? (
<Chart>
@ -69,6 +112,7 @@ export const DurationChartComponent = ({ locationDurationLines, loading }: Durat
showLegendExtra
legendPosition={Position.Bottom}
onBrushEnd={onBrushEnd}
onLegendItemClick={legendToggleVisibility}
/>
<Axis
id="bottom"
@ -89,6 +133,7 @@ export const DurationChartComponent = ({ locationDurationLines, loading }: Durat
})}
/>
<DurationLineSeriesList lines={locationDurationLines} />
<DurationAnomaliesBar anomalies={anomalies} hiddenLegends={hiddenLegends} />
</Chart>
) : (
<ChartEmptyState

View file

@ -0,0 +1,91 @@
/*
* 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 from 'react';
import moment from 'moment';
import { AnnotationTooltipFormatter, RectAnnotation } from '@elastic/charts';
import { RectAnnotationDatum } from '@elastic/charts/dist/chart_types/xy_chart/utils/specs';
import { AnnotationTooltip } from './annotation_tooltip';
import { ANOMALY_SEVERITY } from '../../../../../../../plugins/ml/common/constants/anomalies';
import {
getSeverityColor,
getSeverityType,
} from '../../../../../../../plugins/ml/common/util/anomaly_utils';
interface Props {
anomalies: any;
hiddenLegends: string[];
}
export const DurationAnomaliesBar = ({ anomalies, hiddenLegends }: Props) => {
const anomalyAnnotations: Map<string, { rect: RectAnnotationDatum[]; color: string }> = new Map();
Object.keys(ANOMALY_SEVERITY).forEach(severityLevel => {
anomalyAnnotations.set(severityLevel.toLowerCase(), { rect: [], color: '' });
});
if (anomalies?.anomalies) {
const records = anomalies.anomalies;
records.forEach((record: any) => {
let recordObsvLoc = record.source['observer.geo.name']?.[0] ?? 'N/A';
if (recordObsvLoc === '') {
recordObsvLoc = 'N/A';
}
if (hiddenLegends.length && hiddenLegends.includes(`loc-avg-${recordObsvLoc}`)) {
return;
}
const severityLevel = getSeverityType(record.severity);
const tooltipData = {
time: record.source.timestamp,
score: record.severity,
severity: severityLevel,
color: getSeverityColor(record.severity),
};
const anomalyRect = {
coordinates: {
x0: moment(record.source.timestamp).valueOf(),
x1: moment(record.source.timestamp)
.add(record.source.bucket_span, 's')
.valueOf(),
},
details: JSON.stringify(tooltipData),
};
anomalyAnnotations.get(severityLevel)!.rect.push(anomalyRect);
anomalyAnnotations.get(severityLevel)!.color = getSeverityColor(record.severity);
});
}
const getRectStyle = (color: string) => {
return {
fill: color,
opacity: 1,
strokeWidth: 2,
stroke: color,
};
};
const tooltipFormatter: AnnotationTooltipFormatter = (details?: string) => {
return <AnnotationTooltip details={details || ''} />;
};
return (
<>
{Array.from(anomalyAnnotations).map(([keyIndex, rectAnnotation]) => {
return rectAnnotation.rect.length > 0 ? (
<RectAnnotation
dataValues={rectAnnotation.rect}
key={keyIndex}
id={keyIndex}
style={getRectStyle(rectAnnotation.color)}
renderTooltip={tooltipFormatter}
/>
) : null;
})}
</>
);
};

View file

@ -749,17 +749,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = `
<EmptyStateComponent
errors={
Array [
Object {
"extensions": undefined,
"locations": undefined,
"message": "An error occurred",
"name": "foo",
"nodes": undefined,
"originalError": undefined,
"path": undefined,
"positions": undefined,
"source": undefined,
},
[error: There was an error fetching your data.],
]
}
intl={
@ -870,17 +860,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = `
<EmptyStateError
errors={
Array [
Object {
"extensions": undefined,
"locations": undefined,
"message": "An error occurred",
"name": "foo",
"nodes": undefined,
"originalError": undefined,
"path": undefined,
"positions": undefined,
"source": undefined,
},
[error: There was an error fetching your data.],
]
}
>
@ -904,7 +884,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = `
body={
<React.Fragment>
<p>
An error occurred
There was an error fetching your data.
</p>
</React.Fragment>
}
@ -971,9 +951,9 @@ exports[`EmptyState component renders error message when an error occurs 1`] = `
className="euiText euiText--medium"
>
<p
key="An error occurred"
key="There was an error fetching your data."
>
An error occurred
There was an error fetching your data.
</p>
</div>
</EuiText>

View file

@ -7,8 +7,9 @@
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { EmptyStateComponent } from '../empty_state';
import { GraphQLError } from 'graphql';
import { StatesIndexStatus } from '../../../../../common/runtime_types';
import { IHttpFetchError } from '../../../../../../../../../target/types/core/public/http';
import { HttpFetchError } from '../../../../../../../../../src/core/public/http/http_fetch_error';
describe('EmptyState component', () => {
let statesIndexStatus: StatesIndexStatus;
@ -41,18 +42,8 @@ describe('EmptyState component', () => {
});
it(`renders error message when an error occurs`, () => {
const errors: GraphQLError[] = [
{
message: 'An error occurred',
locations: undefined,
path: undefined,
nodes: undefined,
source: undefined,
positions: undefined,
originalError: undefined,
extensions: undefined,
name: 'foo',
},
const errors: IHttpFetchError[] = [
new HttpFetchError('There was an error fetching your data.', 'error', {} as any),
];
const component = mountWithIntl(
<EmptyStateComponent statesIndexStatus={null} errors={errors} loading={false}>

View file

@ -10,12 +10,13 @@ import { EmptyStateError } from './empty_state_error';
import { EmptyStateLoading } from './empty_state_loading';
import { DataMissing } from './data_missing';
import { StatesIndexStatus } from '../../../../common/runtime_types';
import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http';
interface EmptyStateProps {
children: JSX.Element[] | JSX.Element;
statesIndexStatus: StatesIndexStatus | null;
loading: boolean;
errors?: Error[];
errors?: IHttpFetchError[];
}
export const EmptyStateComponent = ({

View file

@ -7,9 +7,10 @@
import { EuiEmptyPrompt, EuiPanel, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { Fragment } from 'react';
import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http';
interface EmptyStateErrorProps {
errors: Error[];
errors: IHttpFetchError[];
}
export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => {

View file

@ -64,3 +64,10 @@ export const UP = i18n.translate('xpack.uptime.monitorList.statusColumn.upLabel'
export const DOWN = i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', {
defaultMessage: 'Down',
});
export const RESPONSE_ANOMALY_SCORE = i18n.translate(
'xpack.uptime.monitorList.anomalyColumn.label',
{
defaultMessage: 'Response Anomaly Score',
}
);

View file

@ -29,7 +29,7 @@ import { Ping, PingResults } from '../../../../common/graphql/types';
import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper';
import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order';
import { pingsQuery } from '../../../queries';
import { LocationName } from './../location_name';
import { LocationName } from './location_name';
import { Pagination } from './../monitor_list';
import { PingListExpandedRowComponent } from './expanded_row';

View file

@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ML Confirm Job Delete shallow renders without errors 1`] = `
<EuiOverlayMask>
<EuiConfirmModal
buttonColor="danger"
cancelButtonText="Cancel"
confirmButtonText="Delete"
defaultFocusedButton="confirm"
onCancel={[MockFunction]}
onConfirm={[MockFunction]}
title="Delete anomaly detection job?"
>
<p>
<FormattedMessage
defaultMessage="Are you sure you want to delete this job?"
id="xpack.uptime.monitorDetails.ml.confirmDeleteMessage"
values={Object {}}
/>
</p>
<p>
<FormattedMessage
defaultMessage="Deleting a job can be time consuming. It will be deleted in the background and data may not disappear instantly."
id="xpack.uptime.monitorDetails.ml.deleteJobWarning"
values={Object {}}
/>
</p>
</EuiConfirmModal>
</EuiOverlayMask>
`;
exports[`ML Confirm Job Delete shallow renders without errors while loading 1`] = `
<EuiOverlayMask>
<EuiConfirmModal
buttonColor="danger"
cancelButtonText="Cancel"
confirmButtonText="Delete"
defaultFocusedButton="confirm"
onCancel={[MockFunction]}
onConfirm={[MockFunction]}
title="Delete anomaly detection job?"
>
<p>
<FormattedMessage
defaultMessage="Deleting jobs..."
id="xpack.uptime.monitorDetails.ml.deleteMessage"
values={Object {}}
/>
)
</p>
<EuiLoadingSpinner
size="xl"
/>
</EuiConfirmModal>
</EuiOverlayMask>
`;

View file

@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShowLicenseInfo renders without errors 1`] = `
Array [
<div
class="euiCallOut euiCallOut--primary license-info-trial"
>
<div
class="euiCallOutHeader"
>
<div
aria-hidden="true"
class="euiCallOutHeader__icon"
data-euiicon-type="help"
/>
<span
class="euiCallOutHeader__title"
>
Start free 14-day trial
</span>
</div>
<div
class="euiText euiText--small"
>
<p>
In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.
</p>
<a
class="euiButton euiButton--primary"
href="/app/kibana#/management/elasticsearch/license_management/home"
rel="noopener noreferrer"
target="_blank"
>
<span
class="euiButton__content"
>
<span
class="euiButton__text"
>
Start free 14-day trial
</span>
</span>
</a>
</div>
</div>,
<div
class="euiSpacer euiSpacer--l"
/>,
]
`;
exports[`ShowLicenseInfo shallow renders without errors 1`] = `
<Fragment>
<EuiCallOut
className="license-info-trial"
color="primary"
iconType="help"
title="Start free 14-day trial"
>
<p>
In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.
</p>
<EuiButton
color="primary"
href="/app/kibana#/management/elasticsearch/license_management/home"
target="_blank"
>
Start free 14-day trial
</EuiButton>
</EuiCallOut>
<EuiSpacer />
</Fragment>
`;

View file

@ -0,0 +1,240 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ML Flyout component renders without errors 1`] = `
<EuiFlyout
closeButtonAriaLabel="Closes this dialog"
hideCloseButton={false}
maxWidth={false}
onClose={[Function]}
ownFocus={false}
size="s"
>
<EuiFlyoutHeader>
<EuiTitle>
<h2>
Enable anomaly detection
</h2>
</EuiTitle>
<EuiSpacer
size="s"
/>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<ShowLicenseInfo />
<EuiText>
<p>
Here you can create a machine learning job to calculate anomaly scores on
response durations for Uptime Monitor. Once enabled, the monitor duration chart on the details page
will show the expected bounds and annotate the graph with anomalies. You can also potentially
identify periods of increased latency across geographical regions.
</p>
<p>
<FormattedMessage
defaultMessage="Once a job is created, you can manage it and see more details in the {mlJobsPageLink}."
id="xpack.uptime.ml.enableAnomalyDetectionPanel.manageMLJobDescription"
values={
Object {
"mlJobsPageLink": <ForwardRef
href="/app/ml"
>
Machine Learning jobs management page
</ForwardRef>,
}
}
/>
</p>
<p>
<em>
Note: It might take a few minutes for the job to begin calculating results.
</em>
</p>
</EuiText>
<EuiSpacer />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup
alignItems="flexEnd"
justifyContent="spaceBetween"
>
<EuiFlexItem
grow={false}
>
<EuiButton
disabled={true}
fill={true}
isLoading={false}
onClick={[Function]}
>
Create new job
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
`;
exports[`ML Flyout component shows license info if no ml available 1`] = `
<div>
<div
data-focus-guard="true"
style="width:1px;height:0px;padding:0;overflow:hidden;position:fixed;top:1px;left:1px"
tabindex="0"
/>
<div
data-focus-guard="true"
style="width:1px;height:0px;padding:0;overflow:hidden;position:fixed;top:1px;left:1px"
tabindex="1"
/>
<div
data-focus-lock-disabled="false"
>
<div
class="euiFlyout euiFlyout--small"
role="dialog"
tabindex="0"
>
<button
aria-label="Closes this dialog"
class="euiButtonIcon euiButtonIcon--text euiFlyout__closeButton"
data-test-subj="euiFlyoutCloseButton"
type="button"
>
<div
aria-hidden="true"
class="euiButtonIcon__icon"
data-euiicon-type="cross"
/>
</button>
<div
class="euiFlyoutHeader"
>
<h2
class="euiTitle euiTitle--medium"
>
Enable anomaly detection
</h2>
<div
class="euiSpacer euiSpacer--s"
/>
</div>
<div
class="euiFlyoutBody"
>
<div
class="euiFlyoutBody__overflow"
>
<div
class="euiFlyoutBody__overflowContent"
>
<div
class="euiCallOut euiCallOut--primary license-info-trial"
>
<div
class="euiCallOutHeader"
>
<div
aria-hidden="true"
class="euiCallOutHeader__icon"
data-euiicon-type="help"
/>
<span
class="euiCallOutHeader__title"
>
Start free 14-day trial
</span>
</div>
<div
class="euiText euiText--small"
>
<p>
In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.
</p>
<a
class="euiButton euiButton--primary"
href="/app/kibana#/management/elasticsearch/license_management/home"
rel="noopener noreferrer"
target="_blank"
>
<span
class="euiButton__content"
>
<span
class="euiButton__text"
>
Start free 14-day trial
</span>
</span>
</a>
</div>
</div>
<div
class="euiSpacer euiSpacer--l"
/>
<div
class="euiText euiText--medium"
>
<p>
Here you can create a machine learning job to calculate anomaly scores on
response durations for Uptime Monitor. Once enabled, the monitor duration chart on the details page
will show the expected bounds and annotate the graph with anomalies. You can also potentially
identify periods of increased latency across geographical regions.
</p>
<p>
Once a job is created, you can manage it and see more details in the
<a
class="euiLink euiLink--primary"
href="/app/ml"
rel="noreferrer"
>
Machine Learning jobs management page
</a>
.
</p>
<p>
<em>
Note: It might take a few minutes for the job to begin calculating results.
</em>
</p>
</div>
<div
class="euiSpacer euiSpacer--l"
/>
</div>
</div>
</div>
<div
class="euiFlyoutFooter"
>
<div
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsFlexEnd euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<button
class="euiButton euiButton--primary euiButton--fill"
disabled=""
type="button"
>
<span
class="euiButton__content"
>
<span
class="euiButton__text"
>
Create new job
</span>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div
data-focus-guard="true"
style="width:1px;height:0px;padding:0;overflow:hidden;position:fixed;top:1px;left:1px"
tabindex="0"
/>
</div>
`;

View file

@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ML Integrations renders without errors 1`] = `
<div
class="euiPopover euiPopover--anchorDownCenter"
>
<div
class="euiPopover__anchor"
>
<button
class="euiButtonEmpty euiButtonEmpty--primary"
type="button"
>
<span
class="euiButtonEmpty__content"
>
<div
aria-hidden="true"
class="euiButtonEmpty__icon"
data-euiicon-type="machineLearningApp"
/>
<span
class="euiButtonEmpty__text"
>
Enable anomaly detection
</span>
</span>
</button>
</div>
</div>
`;
exports[`ML Integrations shallow renders without errors 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<MLIntegrationComponent />
</ContextProvider>
`;

View file

@ -0,0 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ML JobLink renders without errors 1`] = `
<a
class="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--small"
href="/app/ml#/explorer?_g=(ml:(jobIds:!(testMonitor_high_latency_by_geo)),refreshInterval:(pause:!t,value:0),time:(from:'',to:''))&_a=(mlExplorerFilter:(filterActive:!t,filteredFields:!(monitor.id,testMonitor)),mlExplorerSwimlane:(viewByFieldName:observer.geo.name))"
rel="noopener noreferrer"
target="_blank"
>
<span
class="euiButtonEmpty__content"
>
<span
class="euiButtonEmpty__text"
/>
</span>
</a>
`;
exports[`ML JobLink shallow renders without errors 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<MLJobLink
basePath=""
dateRange={
Object {
"from": "",
"to": "",
}
}
monitorId="testMonitor"
/>
</ContextProvider>
`;

View file

@ -0,0 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Manage ML Job renders without errors 1`] = `
<div
class="euiPopover euiPopover--anchorDownCenter"
>
<div
class="euiPopover__anchor"
>
<button
class="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--iconRight"
type="button"
>
<span
class="euiButtonEmpty__content"
>
<div
aria-hidden="true"
class="euiButtonEmpty__icon"
data-euiicon-type="arrowDown"
/>
<span
class="euiButtonEmpty__text"
>
Anomaly detection
</span>
</span>
</button>
</div>
</div>
`;
exports[`Manage ML Job shallow renders without errors 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<ManageMLJobComponent
hasMLJob={true}
onEnableJob={[MockFunction]}
onJobDelete={[MockFunction]}
/>
</ContextProvider>
`;

View file

@ -0,0 +1,25 @@
/*
* 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 from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { ConfirmJobDeletion } from '../confirm_delete';
describe('ML Confirm Job Delete', () => {
it('shallow renders without errors', () => {
const wrapper = shallowWithIntl(
<ConfirmJobDeletion loading={false} onConfirm={jest.fn()} onCancel={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
});
it('shallow renders without errors while loading', () => {
const wrapper = shallowWithIntl(
<ConfirmJobDeletion loading={true} onConfirm={jest.fn()} onCancel={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 from 'react';
import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { ShowLicenseInfo } from '../license_info';
describe('ShowLicenseInfo', () => {
it('shallow renders without errors', () => {
const wrapper = shallowWithIntl(<ShowLicenseInfo />);
expect(wrapper).toMatchSnapshot();
});
it('renders without errors', () => {
const wrapper = renderWithIntl(<ShowLicenseInfo />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,113 @@
/*
* 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 from 'react';
import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { MLFlyoutView } from '../ml_flyout';
import { UptimeSettingsContext } from '../../../../contexts';
import { CLIENT_DEFAULTS } from '../../../../../common/constants';
import { License } from '../../../../../../../../plugins/licensing/common/license';
const expiredLicense = new License({
signature: 'test signature',
license: {
expiryDateInMillis: 0,
mode: 'platinum',
status: 'expired',
type: 'platinum',
uid: '1',
},
features: {
ml: {
isAvailable: false,
isEnabled: false,
},
},
});
const validLicense = new License({
signature: 'test signature',
license: {
expiryDateInMillis: 30000,
mode: 'platinum',
status: 'active',
type: 'platinum',
uid: '2',
},
features: {
ml: {
isAvailable: true,
isEnabled: true,
},
},
});
describe('ML Flyout component', () => {
const createJob = () => {};
const onClose = () => {};
const { DATE_RANGE_START, DATE_RANGE_END } = CLIENT_DEFAULTS;
it('renders without errors', () => {
const wrapper = shallowWithIntl(
<MLFlyoutView
isCreatingJob={false}
onClickCreate={createJob}
onClose={onClose}
canCreateMLJob={true}
/>
);
expect(wrapper).toMatchSnapshot();
});
it('shows license info if no ml available', () => {
const value = {
license: expiredLicense,
basePath: '',
dateRangeStart: DATE_RANGE_START,
dateRangeEnd: DATE_RANGE_END,
isApmAvailable: true,
isInfraAvailable: true,
isLogsAvailable: true,
};
const wrapper = renderWithIntl(
<UptimeSettingsContext.Provider value={value}>
<MLFlyoutView
isCreatingJob={false}
onClickCreate={createJob}
onClose={onClose}
canCreateMLJob={true}
/>
</UptimeSettingsContext.Provider>
);
const licenseComponent = wrapper.find('.license-info-trial');
expect(licenseComponent.length).toBe(1);
expect(wrapper).toMatchSnapshot();
});
it('able to create job if valid license is available', () => {
const value = {
license: validLicense,
basePath: '',
dateRangeStart: DATE_RANGE_START,
dateRangeEnd: DATE_RANGE_END,
isApmAvailable: true,
isInfraAvailable: true,
isLogsAvailable: true,
};
const wrapper = renderWithIntl(
<UptimeSettingsContext.Provider value={value}>
<MLFlyoutView
isCreatingJob={false}
onClickCreate={createJob}
onClose={onClose}
canCreateMLJob={true}
/>
</UptimeSettingsContext.Provider>
);
const licenseComponent = wrapper.find('.license-info-trial');
expect(licenseComponent.length).toBe(0);
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 from 'react';
import { MLIntegrationComponent } from '../ml_integeration';
import { renderWithRouter, shallowWithRouter } from '../../../../lib';
import * as redux from 'react-redux';
describe('ML Integrations', () => {
beforeEach(() => {
const spy = jest.spyOn(redux, 'useDispatch');
spy.mockReturnValue(jest.fn());
const spy1 = jest.spyOn(redux, 'useSelector');
spy1.mockReturnValue(true);
});
it('shallow renders without errors', () => {
const wrapper = shallowWithRouter(<MLIntegrationComponent />);
expect(wrapper).toMatchSnapshot();
});
it('renders without errors', () => {
const wrapper = renderWithRouter(<MLIntegrationComponent />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,25 @@
/*
* 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 from 'react';
import { renderWithRouter, shallowWithRouter } from '../../../../lib';
import { MLJobLink } from '../ml_job_link';
describe('ML JobLink', () => {
it('shallow renders without errors', () => {
const wrapper = shallowWithRouter(
<MLJobLink dateRange={{ to: '', from: '' }} basePath="" monitorId="testMonitor" />
);
expect(wrapper).toMatchSnapshot();
});
it('renders without errors', () => {
const wrapper = renderWithRouter(
<MLJobLink dateRange={{ to: '', from: '' }} basePath="" monitorId="testMonitor" />
);
expect(wrapper).toMatchSnapshot();
});
});

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 React from 'react';
import { ManageMLJobComponent } from '../manage_ml_job';
import * as redux from 'react-redux';
import { renderWithRouter, shallowWithRouter } from '../../../../lib';
describe('Manage ML Job', () => {
it('shallow renders without errors', () => {
const spy = jest.spyOn(redux, 'useSelector');
spy.mockReturnValue(true);
const wrapper = shallowWithRouter(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
});
it('renders without errors', () => {
const spy = jest.spyOn(redux, 'useSelector');
spy.mockReturnValue(true);
const wrapper = renderWithRouter(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
});
});

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 React from 'react';
import { EuiOverlayMask, EuiConfirmModal, EuiLoadingSpinner } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import * as labels from './translations';
interface Props {
loading: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmJobDeletion: React.FC<Props> = ({ loading, onConfirm, onCancel }) => {
return (
<EuiOverlayMask>
<EuiConfirmModal
title={labels.JOB_DELETION_CONFIRMATION}
onCancel={onCancel}
onConfirm={onConfirm}
cancelButtonText="Cancel"
confirmButtonText="Delete"
buttonColor="danger"
defaultFocusedButton="confirm"
>
{!loading ? (
<p>
<FormattedMessage
id="xpack.uptime.monitorDetails.ml.confirmDeleteMessage"
defaultMessage="Are you sure you want to delete this job?"
/>
</p>
) : (
<p>
<FormattedMessage
id="xpack.uptime.monitorDetails.ml.deleteMessage"
defaultMessage="Deleting jobs..."
/>
)
</p>
)}
{!loading ? (
<p>
<FormattedMessage
id="xpack.uptime.monitorDetails.ml.deleteJobWarning"
defaultMessage="Deleting a job can be time consuming.
It will be deleted in the background and data may not disappear instantly."
/>
</p>
) : (
<EuiLoadingSpinner size="xl" />
)}
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,33 @@
/*
* 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 { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui';
import { UptimeSettingsContext } from '../../../contexts';
import * as labels from './translations';
export const ShowLicenseInfo = () => {
const { basePath } = useContext(UptimeSettingsContext);
return (
<>
<EuiCallOut
className="license-info-trial"
title={labels.START_TRAIL}
color="primary"
iconType="help"
>
<p>{labels.START_TRAIL_DESC}</p>
<EuiButton
color="primary"
href={basePath + `/app/kibana#/management/elasticsearch/license_management/home`}
target="_blank"
>
{labels.START_TRAIL}
</EuiButton>
</EuiCallOut>
<EuiSpacer />
</>
);
};

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 React, { useContext, useState } from 'react';
import { EuiButtonEmpty, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { canDeleteMLJobSelector } from '../../../state/selectors';
import { UptimeSettingsContext } from '../../../contexts';
import * as labels from './translations';
import { getMLJobLinkHref } from './ml_job_link';
import { useUrlParams } from '../../../hooks';
interface Props {
hasMLJob: boolean;
onEnableJob: () => void;
onJobDelete: () => void;
}
export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Props) => {
const [isPopOverOpen, setIsPopOverOpen] = useState(false);
const { basePath } = useContext(UptimeSettingsContext);
const canDeleteMLJob = useSelector(canDeleteMLJobSelector);
const [getUrlParams] = useUrlParams();
const { dateRangeStart, dateRangeEnd } = getUrlParams();
let { monitorId } = useParams();
monitorId = atob(monitorId || '');
const button = (
<EuiButtonEmpty
iconType={hasMLJob ? 'arrowDown' : 'machineLearningApp'}
iconSide={hasMLJob ? 'right' : 'left'}
onClick={hasMLJob ? () => setIsPopOverOpen(true) : onEnableJob}
disabled={hasMLJob && !canDeleteMLJob}
>
{hasMLJob ? labels.ANOMALY_DETECTION : labels.ENABLE_ANOMALY_DETECTION}
</EuiButtonEmpty>
);
const panels = [
{
id: 0,
title: labels.MANAGE_ANOMALY_DETECTION,
items: [
{
name: labels.EXPLORE_IN_ML_APP,
icon: <EuiIcon type="dataVisualizer" size="m" />,
href: getMLJobLinkHref({
basePath,
monitorId,
dateRange: { from: dateRangeStart, to: dateRangeEnd },
}),
target: '_blank',
},
{
name: labels.DISABLE_ANOMALY_DETECTION,
icon: <EuiIcon type="trash" size="m" />,
onClick: () => {
setIsPopOverOpen(false);
onJobDelete();
},
},
],
},
];
return (
<EuiPopover button={button} isOpen={isPopOverOpen} closePopover={() => setIsPopOverOpen(false)}>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
);
};

View file

@ -0,0 +1,86 @@
/*
* 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 {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiLink,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import * as labels from './translations';
import { UptimeSettingsContext } from '../../../contexts';
import { ShowLicenseInfo } from './license_info';
interface Props {
isCreatingJob: boolean;
onClickCreate: () => void;
onClose: () => void;
canCreateMLJob: boolean;
}
export function MLFlyoutView({ isCreatingJob, onClickCreate, onClose, canCreateMLJob }: Props) {
const { basePath, license } = useContext(UptimeSettingsContext);
const isLoadingMLJob = false;
const hasPlatinumLicense = license?.getFeature('ml')?.isAvailable;
return (
<EuiFlyout onClose={onClose} size="s">
<EuiFlyoutHeader>
<EuiTitle>
<h2>{labels.ENABLE_ANOMALY_DETECTION}</h2>
</EuiTitle>
<EuiSpacer size="s" />
</EuiFlyoutHeader>
<EuiFlyoutBody>
{!hasPlatinumLicense && <ShowLicenseInfo />}
<EuiText>
<p>{labels.CREAT_ML_JOB_DESC}</p>
<p>
<FormattedMessage
id="xpack.uptime.ml.enableAnomalyDetectionPanel.manageMLJobDescription"
defaultMessage="Once a job is created, you can manage it and see more details in the {mlJobsPageLink}."
values={{
mlJobsPageLink: (
<EuiLink href={basePath + '/app/ml'}>{labels.ML_MANAGEMENT_PAGE}</EuiLink>
),
}}
/>
</p>
<p>
<em>{labels.TAKE_SOME_TIME_TEXT}</em>
</p>
</EuiText>
<EuiSpacer />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => onClickCreate()}
fill
isLoading={isCreatingJob}
disabled={isCreatingJob || isLoadingMLJob || !hasPlatinumLicense || !canCreateMLJob}
>
{labels.CREATE_NEW_JOB}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}

View file

@ -0,0 +1,154 @@
/*
* 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, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import {
canCreateMLJobSelector,
hasMLJobSelector,
hasNewMLJobSelector,
isMLJobCreatingSelector,
} from '../../../state/selectors';
import { createMLJobAction, getExistingMLJobAction } from '../../../state/actions';
import { MLJobLink } from './ml_job_link';
import * as labels from './translations';
import {
useKibana,
KibanaReactNotifications,
} from '../../../../../../../../src/plugins/kibana_react/public';
import { MLFlyoutView } from './ml_flyout';
import { ML_JOB_ID } from '../../../../common/constants';
import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts';
import { useUrlParams } from '../../../hooks';
interface Props {
onClose: () => void;
}
const showMLJobNotification = (
notifications: KibanaReactNotifications,
monitorId: string,
basePath: string,
range: { to: string; from: string },
success: boolean,
message = ''
) => {
if (success) {
notifications.toasts.success({
title: <p>{labels.JOB_CREATED_SUCCESS_TITLE}</p>,
body: (
<p>
{labels.JOB_CREATED_SUCCESS_MESSAGE}
<MLJobLink monitorId={monitorId} basePath={basePath} dateRange={range}>
{labels.VIEW_JOB}
</MLJobLink>
</p>
),
toastLifeTimeMs: 5000,
});
} else {
notifications.toasts.warning({
title: <p>{labels.JOB_CREATION_FAILED}</p>,
body: message ?? <p>{labels.JOB_CREATION_FAILED_MESSAGE}</p>,
toastLifeTimeMs: 5000,
});
}
};
export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => {
const { notifications } = useKibana();
const dispatch = useDispatch();
const { data: hasMLJob, error } = useSelector(hasNewMLJobSelector);
const isMLJobCreating = useSelector(isMLJobCreatingSelector);
const { basePath } = useContext(UptimeSettingsContext);
const { refreshApp } = useContext(UptimeRefreshContext);
let { monitorId } = useParams();
monitorId = atob(monitorId || '');
const createMLJob = () => dispatch(createMLJobAction.get({ monitorId: monitorId as string }));
const canCreateMLJob = useSelector(canCreateMLJobSelector);
const { data: uptimeJobs } = useSelector(hasMLJobSelector);
const hasExistingMLJob = !!uptimeJobs?.jobsExist;
const [isCreatingJob, setIsCreatingJob] = useState(false);
const [getUrlParams] = useUrlParams();
const { dateRangeStart, dateRangeEnd } = getUrlParams();
useEffect(() => {
if (isCreatingJob && !isMLJobCreating) {
if (hasMLJob) {
showMLJobNotification(
notifications,
monitorId as string,
basePath,
{ to: dateRangeEnd, from: dateRangeStart },
true
);
const loadMLJob = (jobId: string) =>
dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string }));
loadMLJob(ML_JOB_ID);
refreshApp();
} else {
showMLJobNotification(
notifications,
monitorId as string,
basePath,
{ to: dateRangeEnd, from: dateRangeStart },
false,
error?.body?.message
);
}
setIsCreatingJob(false);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
hasMLJob,
notifications,
onClose,
isCreatingJob,
error,
isMLJobCreating,
monitorId,
dispatch,
basePath,
]);
useEffect(() => {
if (hasExistingMLJob) {
setIsCreatingJob(true);
dispatch(createMLJobAction.get({ monitorId: monitorId as string }));
}
}, [dispatch, hasExistingMLJob, monitorId]);
if (hasExistingMLJob) {
return null;
}
const createAnomalyJob = () => {
setIsCreatingJob(true);
createMLJob();
};
return (
<MLFlyoutView
canCreateMLJob={!!canCreateMLJob}
isCreatingJob={isMLJobCreating}
onClickCreate={createAnomalyJob}
onClose={onClose}
/>
);
};

View file

@ -0,0 +1,111 @@
/*
* 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, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { MachineLearningFlyout } from './ml_flyout_container';
import {
hasMLFeatureAvailable,
hasMLJobSelector,
isMLJobDeletedSelector,
isMLJobDeletingSelector,
} from '../../../state/selectors';
import { deleteMLJobAction, getExistingMLJobAction, resetMLState } from '../../../state/actions';
import { ConfirmJobDeletion } from './confirm_delete';
import { UptimeRefreshContext } from '../../../contexts';
import { getMLJobId } from '../../../state/api/ml_anomaly';
import * as labels from './translations';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ManageMLJobComponent } from './manage_ml_job';
import { JobStat } from '../../../../../../../plugins/ml/common/types/data_recognizer';
export const MLIntegrationComponent = () => {
const [isMlFlyoutOpen, setIsMlFlyoutOpen] = useState(false);
const [isConfirmDeleteJobOpen, setIsConfirmDeleteJobOpen] = useState(false);
const { lastRefresh, refreshApp } = useContext(UptimeRefreshContext);
const { notifications } = useKibana();
let { monitorId } = useParams();
monitorId = atob(monitorId || '');
const dispatch = useDispatch();
const isMLAvailable = useSelector(hasMLFeatureAvailable);
const deleteMLJob = () => dispatch(deleteMLJobAction.get({ monitorId: monitorId as string }));
const isMLJobDeleting = useSelector(isMLJobDeletingSelector);
const { data: jobDeletionSuccess } = useSelector(isMLJobDeletedSelector);
const { data: uptimeJobs } = useSelector(hasMLJobSelector);
const hasMLJob =
!!uptimeJobs?.jobsExist &&
!!uptimeJobs.jobs.find((job: JobStat) => job.id === getMLJobId(monitorId as string));
useEffect(() => {
if (isMLAvailable) {
dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string }));
}
}, [dispatch, isMLAvailable, monitorId, lastRefresh]);
useEffect(() => {
if (isConfirmDeleteJobOpen && jobDeletionSuccess?.[getMLJobId(monitorId as string)]?.deleted) {
setIsConfirmDeleteJobOpen(false);
notifications.toasts.success({
title: <p>{labels.JOB_DELETION}</p>,
body: <p>{labels.JOB_DELETION_SUCCESS}</p>,
toastLifeTimeMs: 3000,
});
dispatch(resetMLState());
refreshApp();
}
}, [
isMLJobDeleting,
isConfirmDeleteJobOpen,
jobDeletionSuccess,
monitorId,
refreshApp,
notifications.toasts,
dispatch,
]);
const onEnableJobClick = () => {
setIsMlFlyoutOpen(true);
};
const closeFlyout = () => {
setIsMlFlyoutOpen(false);
};
const confirmDeleteMLJob = () => {
setIsConfirmDeleteJobOpen(true);
};
return (
<>
<ManageMLJobComponent
hasMLJob={hasMLJob as boolean}
onEnableJob={onEnableJobClick}
onJobDelete={confirmDeleteMLJob}
/>
{isMlFlyoutOpen && <MachineLearningFlyout onClose={closeFlyout} />}
{isConfirmDeleteJobOpen && (
<ConfirmJobDeletion
onConfirm={deleteMLJob}
loading={isMLJobDeleting}
onCancel={() => {
setIsConfirmDeleteJobOpen(false);
}}
/>
)}
</>
);
};

View file

@ -0,0 +1,52 @@
/*
* 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 from 'react';
import url from 'url';
import { EuiButtonEmpty } from '@elastic/eui';
import rison, { RisonValue } from 'rison-node';
import { getMLJobId } from '../../../state/api/ml_anomaly';
interface Props {
monitorId: string;
basePath: string;
dateRange: {
to: string;
from: string;
};
}
export const getMLJobLinkHref = ({ basePath, monitorId, dateRange }: Props) => {
const query = {
ml: { jobIds: [getMLJobId(monitorId)] },
refreshInterval: { pause: true, value: 0 },
time: dateRange,
};
const queryParams = {
mlExplorerFilter: {
filterActive: true,
filteredFields: ['monitor.id', monitorId],
},
mlExplorerSwimlane: {
viewByFieldName: 'observer.geo.name',
},
};
const path = '/explorer';
return url.format({
pathname: basePath + '/app/ml',
hash:
`${path}?_g=${rison.encode(query as RisonValue)}` +
(monitorId ? `&_a=${rison.encode(queryParams as RisonValue)}` : ''),
});
};
export const MLJobLink: React.FC<Props> = ({ basePath, monitorId, dateRange, children }) => {
const href = getMLJobLinkHref({ basePath, monitorId, dateRange });
return <EuiButtonEmpty children={children} size="s" href={href} target="_blank" />;
};

View file

@ -0,0 +1,150 @@
/*
* 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';
export const JOB_CREATED_SUCCESS_TITLE = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreatedNotificationTitle',
{
defaultMessage: 'Job successfully created',
}
);
export const JOB_CREATED_SUCCESS_MESSAGE = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreatedNotificationText',
{
defaultMessage:
'The analysis is now running for response duration chart. It might take a while before results are added to the response times graph.',
}
);
export const JOB_CREATION_FAILED = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle',
{
defaultMessage: 'Job creation failed',
}
);
export const JOB_CREATION_FAILED_MESSAGE = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreationFailedNotificationText',
{
defaultMessage:
'Your current license may not allow for creating machine learning jobs, or this job may already exist.',
}
);
export const JOB_DELETION = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobDeletionNotificationTitle',
{
defaultMessage: 'Job deleted',
}
);
export const JOB_DELETION_SUCCESS = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobDeletionSuccessNotificationText',
{
defaultMessage: 'Job is successfully deleted',
}
);
export const JOB_DELETION_CONFIRMATION = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobDeletionConfirmLabel',
{
defaultMessage: 'Delete anomaly detection job?',
}
);
export const VIEW_JOB = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText',
{
defaultMessage: 'View job',
}
);
export const EXPLORE_IN_ML_APP = i18n.translate('xpack.uptime.ml.durationChart.exploreInMlApp', {
defaultMessage: 'Explore in ML App',
});
export const ENABLE_ANOMALY_DETECTION = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle',
{
defaultMessage: 'Enable anomaly detection',
}
);
export const ANOMALY_DETECTION = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.anomalyDetectionTitle',
{
defaultMessage: 'Anomaly detection',
}
);
export const DISABLE_ANOMALY_DETECTION = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.disableAnomalyDetectionTitle',
{
defaultMessage: 'Disable anomaly detection',
}
);
export const MANAGE_ANOMALY_DETECTION = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.manageAnomalyDetectionTitle',
{
defaultMessage: 'Manage anomaly detection',
}
);
export const VIEW_EXISTING_JOB = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText',
{
defaultMessage: 'View existing job',
}
);
export const ML_MANAGEMENT_PAGE = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText',
{
defaultMessage: 'Machine Learning jobs management page',
}
);
export const TAKE_SOME_TIME_TEXT = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.manageMLJobDescription.noteText',
{
defaultMessage: 'Note: It might take a few minutes for the job to begin calculating results.',
}
);
export const CREATE_NEW_JOB = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.createNewJobButtonLabel',
{
defaultMessage: 'Create new job',
}
);
export const CREAT_ML_JOB_DESC = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.createMLJobDescription',
{
defaultMessage: `Here you can create a machine learning job to calculate anomaly scores on
response durations for Uptime Monitor. Once enabled, the monitor duration chart on the details page
will show the expected bounds and annotate the graph with anomalies. You can also potentially
identify periods of increased latency across geographical regions.`,
}
);
export const START_TRAIL = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.startTrial',
{
defaultMessage: 'Start free 14-day trial',
}
);
export const START_TRAIL_DESC = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.startTrialDesc',
{
defaultMessage:
'In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license.',
}
);

View file

@ -9,9 +9,11 @@ import { UptimeAppProps } from '../uptime_app';
import { CLIENT_DEFAULTS, CONTEXT_DEFAULTS } from '../../common/constants';
import { CommonlyUsedRange } from '../components/functional/uptime_date_picker';
import { useUrlParams } from '../hooks';
import { ILicense } from '../../../../../plugins/licensing/common/types';
export interface UptimeSettingsContextValues {
basePath: string;
license?: ILicense | null;
dateRangeStart: string;
dateRangeEnd: string;
isApmAvailable: boolean;
@ -39,14 +41,29 @@ const defaultContext: UptimeSettingsContextValues = {
export const UptimeSettingsContext = createContext(defaultContext);
export const UptimeSettingsContextProvider: React.FC<UptimeAppProps> = ({ children, ...props }) => {
const { basePath, isApmAvailable, isInfraAvailable, isLogsAvailable, commonlyUsedRanges } = props;
const {
basePath,
isApmAvailable,
isInfraAvailable,
isLogsAvailable,
commonlyUsedRanges,
plugins,
} = props;
const [getUrlParams] = useUrlParams();
const { dateRangeStart, dateRangeEnd } = getUrlParams();
let license: ILicense | null = null;
// @ts-ignore
plugins.licensing.license$.subscribe((licenseItem: ILicense) => {
license = licenseItem;
});
const value = useMemo(() => {
return {
license,
basePath,
isApmAvailable,
isInfraAvailable,
@ -56,6 +73,7 @@ export const UptimeSettingsContextProvider: React.FC<UptimeAppProps> = ({ childr
dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END,
};
}, [
license,
basePath,
isApmAvailable,
isInfraAvailable,

View file

@ -10,5 +10,6 @@ export * from './ui';
export * from './monitor_status';
export * from './index_patternts';
export * from './ping';
export * from './ml_anomaly';
export * from './monitor_duration';
export * from './index_status';

View file

@ -7,4 +7,4 @@
import { createAsyncAction } from './utils';
import { StatesIndexStatus } from '../../../common/runtime_types';
export const indexStatusAction = createAsyncAction<StatesIndexStatus & Error>('GET INDEX STATUS');
export const indexStatusAction = createAsyncAction<any, StatesIndexStatus>('GET INDEX STATUS');

View file

@ -0,0 +1,46 @@
/*
* 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 { createAction } from 'redux-actions';
import { createAsyncAction } from './utils';
import { PrivilegesResponse } from '../../../../../../plugins/ml/common/types/privileges';
import { AnomaliesTableRecord } from '../../../../../../plugins/ml/common/types/anomalies';
import { CreateMLJobSuccess, DeleteJobResults, MonitorIdParam } from './types';
import { JobExistResult } from '../../../../../../plugins/ml/common/types/data_recognizer';
export const resetMLState = createAction('RESET_ML_STATE');
export const getExistingMLJobAction = createAsyncAction<MonitorIdParam, JobExistResult>(
'GET_EXISTING_ML_JOB'
);
export const createMLJobAction = createAsyncAction<MonitorIdParam, CreateMLJobSuccess | null>(
'CREATE_ML_JOB'
);
export const getMLCapabilitiesAction = createAsyncAction<any, PrivilegesResponse>(
'GET_ML_CAPABILITIES'
);
export const deleteMLJobAction = createAsyncAction<MonitorIdParam, DeleteJobResults>(
'DELETE_ML_JOB'
);
export interface AnomalyRecordsParams {
dateStart: number;
dateEnd: number;
listOfMonitorIds: string[];
anomalyThreshold?: number;
}
export interface AnomalyRecords {
anomalies: AnomaliesTableRecord[];
interval: string;
}
export const getAnomalyRecordsAction = createAsyncAction<AnomalyRecordsParams, AnomalyRecords>(
'GET_ANOMALY_RECORDS'
);

View file

@ -7,6 +7,7 @@
import { createAction } from 'redux-actions';
import { QueryParams } from './types';
import { MonitorDurationResult } from '../../../common/types';
import { IHttpFetchError } from '../../../../../../../target/types/core/public/http';
type MonitorQueryParams = QueryParams & { monitorId: string };
@ -14,4 +15,6 @@ export const getMonitorDurationAction = createAction<MonitorQueryParams>('GET_MO
export const getMonitorDurationActionSuccess = createAction<MonitorDurationResult>(
'GET_MONITOR_DURATION_SUCCESS'
);
export const getMonitorDurationActionFail = createAction<Error>('GET_MONITOR_DURATION_FAIL');
export const getMonitorDurationActionFail = createAction<IHttpFetchError>(
'GET_MONITOR_DURATION_FAIL'
);

View file

@ -5,11 +5,21 @@
*/
import { Action } from 'redux-actions';
import { IHttpFetchError } from '../../../../../../../target/types/core/public/http';
export interface AsyncAction {
get: (payload?: any) => Action<any>;
success: (payload?: any) => Action<any>;
fail: (payload?: any) => Action<any>;
export interface AsyncAction<Payload, SuccessPayload> {
get: (payload: Payload) => Action<Payload>;
success: (payload: SuccessPayload) => Action<SuccessPayload>;
fail: (payload: IHttpFetchError) => Action<IHttpFetchError>;
}
export interface AsyncAction1<Payload, SuccessPayload> {
get: (payload?: Payload) => Action<Payload>;
success: (payload: SuccessPayload) => Action<SuccessPayload>;
fail: (payload: IHttpFetchError) => Action<IHttpFetchError>;
}
export interface MonitorIdParam {
monitorId: string;
}
export interface QueryParams {
@ -27,3 +37,15 @@ export interface MonitorDetailsActionPayload {
dateEnd: string;
location?: string;
}
export interface CreateMLJobSuccess {
count: number;
jobId: string;
}
export interface DeleteJobResults {
[id: string]: {
[status: string]: boolean;
error?: any;
};
}

View file

@ -5,12 +5,18 @@
*/
import { createAction } from 'redux-actions';
import { AsyncAction } from './types';
import { AsyncAction, AsyncAction1 } from './types';
import { IHttpFetchError } from '../../../../../../../target/types/core/public/http';
export function createAsyncAction<Payload>(actionStr: string): AsyncAction {
export function createAsyncAction<Payload, SuccessPayload>(
actionStr: string
): AsyncAction1<Payload, SuccessPayload>;
export function createAsyncAction<Payload, SuccessPayload>(
actionStr: string
): AsyncAction<Payload, SuccessPayload> {
return {
get: createAction<Payload>(actionStr),
success: createAction<Payload>(`${actionStr}_SUCCESS`),
fail: createAction<Payload>(`${actionStr}_FAIL`),
success: createAction<SuccessPayload>(`${actionStr}_SUCCESS`),
fail: createAction<IHttpFetchError>(`${actionStr}_FAIL`),
};
}

View file

@ -0,0 +1,88 @@
/*
* 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 moment from 'moment';
import { apiService } from './utils';
import { AnomalyRecords, AnomalyRecordsParams } from '../actions';
import { API_URLS, INDEX_NAMES, ML_JOB_ID, ML_MODULE_ID } from '../../../common/constants';
import { PrivilegesResponse } from '../../../../../../plugins/ml/common/types/privileges';
import { CreateMLJobSuccess, DeleteJobResults, MonitorIdParam } from '../actions/types';
import { DataRecognizerConfigResponse } from '../../../../../../plugins/ml/common/types/modules';
import { JobExistResult } from '../../../../../../plugins/ml/common/types/data_recognizer';
export const getMLJobId = (monitorId: string) => `${monitorId}_${ML_JOB_ID}`;
export const getMLCapabilities = async (): Promise<PrivilegesResponse> => {
return await apiService.get(API_URLS.ML_CAPABILITIES);
};
export const getExistingJobs = async (): Promise<JobExistResult> => {
return await apiService.get(API_URLS.ML_MODULE_JOBS + ML_MODULE_ID);
};
export const createMLJob = async ({
monitorId,
}: MonitorIdParam): Promise<CreateMLJobSuccess | null> => {
const url = API_URLS.ML_SETUP_MODULE + ML_MODULE_ID;
const data = {
prefix: `${monitorId}_`,
useDedicatedIndex: false,
startDatafeed: true,
start: moment()
.subtract(24, 'h')
.valueOf(),
indexPatternName: INDEX_NAMES.HEARTBEAT,
query: {
bool: {
filter: [
{
term: {
'monitor.id': monitorId,
},
},
],
},
},
};
const response: DataRecognizerConfigResponse = await apiService.post(url, data);
if (response?.jobs?.[0]?.id === getMLJobId(monitorId) && response?.jobs?.[0]?.success) {
return {
count: 1,
jobId: response?.jobs?.[0]?.id,
};
} else {
return null;
}
};
export const deleteMLJob = async ({ monitorId }: MonitorIdParam): Promise<DeleteJobResults> => {
const data = { jobIds: [getMLJobId(monitorId)] };
return await apiService.post(API_URLS.ML_DELETE_JOB, data);
};
export const fetchAnomalyRecords = async ({
dateStart,
dateEnd,
listOfMonitorIds,
anomalyThreshold,
}: AnomalyRecordsParams): Promise<AnomalyRecords> => {
const data = {
jobIds: listOfMonitorIds.map((monitorId: string) => getMLJobId(monitorId)),
criteriaFields: [],
influencers: [],
aggregationInterval: 'auto',
threshold: anomalyThreshold ?? 25,
earliestMs: dateStart,
latestMs: dateEnd,
dateFormatTz: Intl.DateTimeFormat().resolvedOptions().timeZone,
maxRecords: 500,
maxExamples: 10,
};
return apiService.post(API_URLS.ML_ANOMALIES_RESULT, data);
};

View file

@ -7,6 +7,9 @@
import { call, put } from 'redux-saga/effects';
import { fetchEffectFactory } from '../fetch_effect';
import { indexStatusAction } from '../../actions';
import { HttpFetchError } from '../../../../../../../../src/core/public/http/http_fetch_error';
import { StatesIndexStatus } from '../../../../common/runtime_types';
import { fetchIndexStatus } from '../../api';
describe('fetch saga effect factory', () => {
const asyncAction = indexStatusAction;
@ -15,7 +18,7 @@ describe('fetch saga effect factory', () => {
it('works with success workflow', () => {
const indexStatusResult = { indexExists: true, docCount: 2712532 };
const fetchStatus = async () => {
const fetchStatus = async (): Promise<StatesIndexStatus> => {
return { indexExists: true, docCount: 2712532 };
};
fetchEffect = fetchEffectFactory(
@ -25,6 +28,7 @@ describe('fetch saga effect factory', () => {
)(calledAction);
let next = fetchEffect.next();
// @ts-ignore TODO, dig deeper for TS issues here
expect(next.value).toEqual(call(fetchStatus, calledAction.payload));
const successResult = put(asyncAction.success(indexStatusResult));
@ -35,18 +39,21 @@ describe('fetch saga effect factory', () => {
});
it('works with error workflow', () => {
const indexStatusResultError = new Error('no heartbeat index found');
const fetchStatus = async () => {
return indexStatusResultError;
};
const indexStatusResultError = new HttpFetchError(
'No heartbeat index found.',
'error',
{} as any
);
fetchEffect = fetchEffectFactory(
fetchStatus,
fetchIndexStatus,
asyncAction.success,
asyncAction.fail
)(calledAction);
let next = fetchEffect.next();
expect(next.value).toEqual(call(fetchStatus, calledAction.payload));
// @ts-ignore TODO, dig deeper for TS issues here
expect(next.value).toEqual(call(fetchIndexStatus, calledAction.payload));
const errorResult = put(asyncAction.fail(indexStatusResultError));
@ -56,18 +63,21 @@ describe('fetch saga effect factory', () => {
});
it('works with throw error workflow', () => {
const unExpectedError = new Error('no url found, so throw error');
const fetchStatus = async () => {
return await fetch('/some/url');
};
const unExpectedError = new HttpFetchError(
'No url found for the call, so throw error.',
'error',
{} as any
);
fetchEffect = fetchEffectFactory(
fetchStatus,
fetchIndexStatus,
asyncAction.success,
asyncAction.fail
)(calledAction);
let next = fetchEffect.next();
expect(next.value).toEqual(call(fetchStatus, calledAction.payload));
// @ts-ignore TODO, dig deeper for TS issues here
expect(next.value).toEqual(call(fetchIndexStatus, calledAction.payload));
const unexpectedErrorResult = put(asyncAction.fail(unExpectedError));

View file

@ -6,6 +6,7 @@
import { call, put } from 'redux-saga/effects';
import { Action } from 'redux-actions';
import { IHttpFetchError } from '../../../../../../../target/types/core/public/http';
/**
* Factory function for a fetch effect. It expects three action creators,
@ -21,24 +22,23 @@ import { Action } from 'redux-actions';
export function fetchEffectFactory<T, R, S, F>(
fetch: (request: T) => Promise<R>,
success: (response: R) => Action<S>,
fail: (error: Error) => Action<F>
fail: (error: IHttpFetchError) => Action<F>
) {
return function*(action: Action<T>) {
try {
const response = yield call(fetch, action.payload);
if (response instanceof Error) {
// eslint-disable-next-line no-console
console.error(response);
yield put(fail(response));
yield put(fail(response as IHttpFetchError));
} else {
yield put(success(response));
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
yield put(fail(error));
yield put(fail(error as IHttpFetchError));
}
};
}

View file

@ -13,6 +13,7 @@ import { fetchDynamicSettingsEffect, setDynamicSettingsEffect } from './dynamic_
import { fetchIndexPatternEffect } from './index_pattern';
import { fetchPingHistogramEffect } from './ping';
import { fetchMonitorDurationEffect } from './monitor_duration';
import { fetchMLJobEffect } from './ml_anomaly';
import { fetchIndexStatusEffect } from './index_status';
export function* rootEffect() {
@ -24,6 +25,7 @@ export function* rootEffect() {
yield fork(setDynamicSettingsEffect);
yield fork(fetchIndexPatternEffect);
yield fork(fetchPingHistogramEffect);
yield fork(fetchMLJobEffect);
yield fork(fetchMonitorDurationEffect);
yield fork(fetchIndexStatusEffect);
}

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 { takeLatest } from 'redux-saga/effects';
import {
getMLCapabilitiesAction,
getExistingMLJobAction,
createMLJobAction,
getAnomalyRecordsAction,
deleteMLJobAction,
} from '../actions';
import { fetchEffectFactory } from './fetch_effect';
import {
getExistingJobs,
createMLJob,
fetchAnomalyRecords,
deleteMLJob,
getMLCapabilities,
} from '../api/ml_anomaly';
export function* fetchMLJobEffect() {
yield takeLatest(
getExistingMLJobAction.get,
fetchEffectFactory(getExistingJobs, getExistingMLJobAction.success, getExistingMLJobAction.fail)
);
yield takeLatest(
createMLJobAction.get,
fetchEffectFactory(createMLJob, createMLJobAction.success, createMLJobAction.fail)
);
yield takeLatest(
getAnomalyRecordsAction.get,
fetchEffectFactory(
fetchAnomalyRecords,
getAnomalyRecordsAction.success,
getAnomalyRecordsAction.fail
)
);
yield takeLatest(
deleteMLJobAction.get,
fetchEffectFactory(deleteMLJob, deleteMLJobAction.success, deleteMLJobAction.fail)
);
yield takeLatest(
getMLCapabilitiesAction.get,
fetchEffectFactory(
getMLCapabilities,
getMLCapabilitiesAction.success,
getMLCapabilitiesAction.fail
)
);
}

View file

@ -15,6 +15,7 @@ import { indexPatternReducer } from './index_pattern';
import { pingReducer } from './ping';
import { monitorDurationReducer } from './monitor_duration';
import { indexStatusReducer } from './index_status';
import { mlJobsReducer } from './ml_anomaly';
export const rootReducer = combineReducers({
monitor: monitorReducer,
@ -25,6 +26,7 @@ export const rootReducer = combineReducers({
dynamicSettings: dynamicSettingsReducer,
indexPattern: indexPatternReducer,
ping: pingReducer,
ml: mlJobsReducer,
monitorDuration: monitorDurationReducer,
indexStatus: indexStatusReducer,
});

View file

@ -6,25 +6,23 @@
import { handleActions } from 'redux-actions';
import { indexStatusAction } from '../actions';
import { handleAsyncAction } from './utils';
import { IReducerState } from './types';
import { getAsyncInitialState, handleAsyncAction } from './utils';
import { AsyncInitialState } from './types';
import { StatesIndexStatus } from '../../../common/runtime_types';
export interface IndexStatusState extends IReducerState {
data: StatesIndexStatus | null;
export interface IndexStatusState {
indexStatus: AsyncInitialState<StatesIndexStatus | null>;
}
const initialState: IndexStatusState = {
data: null,
loading: false,
errors: [],
indexStatus: getAsyncInitialState(),
};
type PayLoad = StatesIndexStatus & Error;
export const indexStatusReducer = handleActions<IndexStatusState, PayLoad>(
{
...handleAsyncAction('data', indexStatusAction),
...handleAsyncAction('indexStatus', indexStatusAction),
},
initialState
);

View file

@ -0,0 +1,71 @@
/*
* 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 { handleActions } from 'redux-actions';
import {
getExistingMLJobAction,
createMLJobAction,
getAnomalyRecordsAction,
deleteMLJobAction,
resetMLState,
AnomalyRecords,
getMLCapabilitiesAction,
} from '../actions';
import { getAsyncInitialState, handleAsyncAction } from './utils';
import { IHttpFetchError } from '../../../../../../../target/types/core/public/http';
import { AsyncInitialState } from './types';
import { PrivilegesResponse } from '../../../../../../plugins/ml/common/types/privileges';
import { CreateMLJobSuccess, DeleteJobResults } from '../actions/types';
import { JobExistResult } from '../../../../../../plugins/ml/common/types/data_recognizer';
export interface MLJobState {
mlJob: AsyncInitialState<JobExistResult>;
createJob: AsyncInitialState<CreateMLJobSuccess>;
deleteJob: AsyncInitialState<DeleteJobResults>;
anomalies: AsyncInitialState<AnomalyRecords>;
mlCapabilities: AsyncInitialState<PrivilegesResponse>;
}
const initialState: MLJobState = {
mlJob: getAsyncInitialState(),
createJob: getAsyncInitialState(),
deleteJob: getAsyncInitialState(),
anomalies: getAsyncInitialState(),
mlCapabilities: getAsyncInitialState(),
};
type Payload = IHttpFetchError;
export const mlJobsReducer = handleActions<MLJobState>(
{
...handleAsyncAction<MLJobState, Payload>('mlJob', getExistingMLJobAction),
...handleAsyncAction<MLJobState, Payload>('mlCapabilities', getMLCapabilitiesAction),
...handleAsyncAction<MLJobState, Payload>('createJob', createMLJobAction),
...handleAsyncAction<MLJobState, Payload>('deleteJob', deleteMLJobAction),
...handleAsyncAction<MLJobState, Payload>('anomalies', getAnomalyRecordsAction),
...{
[String(resetMLState)]: state => ({
...state,
mlJob: {
loading: false,
data: null,
error: null,
},
createJob: {
data: null,
error: null,
loading: false,
},
deleteJob: {
data: null,
error: null,
loading: false,
},
}),
},
},
initialState
);

View file

@ -13,13 +13,13 @@ import {
import { MonitorDurationResult } from '../../../common/types';
export interface MonitorDuration {
monitor_duration: MonitorDurationResult | null;
durationLines: MonitorDurationResult | null;
errors: any[];
loading: boolean;
}
const initialState: MonitorDuration = {
monitor_duration: null,
durationLines: null,
loading: false,
errors: [],
};
@ -39,7 +39,7 @@ export const monitorDurationReducer = handleActions<MonitorDuration, PayLoad>(
) => ({
...state,
loading: false,
monitor_duration: { ...action.payload },
durationLines: { ...action.payload },
}),
[String(getMonitorDurationActionFail)]: (state: MonitorDuration, action: Action<Error>) => ({

View file

@ -4,7 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export interface IReducerState {
errors: Error[];
import { IHttpFetchError } from '../../../../../../../target/types/core/public/http';
export interface AsyncInitialState<ReduceStateType> {
data: ReduceStateType | null;
loading: boolean;
error?: IHttpFetchError | null;
}

View file

@ -6,28 +6,45 @@
import { Action } from 'redux-actions';
import { AsyncAction } from '../actions/types';
import { IReducerState } from './types';
export function handleAsyncAction<ReducerState extends IReducerState, Payload>(
export function handleAsyncAction<ReducerState, Payload>(
storeKey: string,
asyncAction: AsyncAction
asyncAction: AsyncAction<any, any>
) {
return {
[String(asyncAction.get)]: (state: ReducerState) => ({
...state,
loading: true,
[storeKey]: {
...(state as any)[storeKey],
loading: true,
},
}),
[String(asyncAction.success)]: (state: ReducerState, action: Action<any>) => ({
...state,
loading: false,
[storeKey]: action.payload === null ? action.payload : { ...action.payload },
[storeKey]: {
...(state as any)[storeKey],
data: action.payload === null ? action.payload : { ...action.payload },
loading: false,
},
}),
[String(asyncAction.fail)]: (state: ReducerState, action: Action<any>) => ({
...state,
errors: [...state.errors, action.payload],
loading: false,
[storeKey]: {
...(state as any)[storeKey],
data: null,
error: action.payload,
loading: false,
},
}),
};
}
export function getAsyncInitialState(initialData = null) {
return {
data: initialData,
loading: false,
error: null,
};
}

View file

@ -60,14 +60,28 @@ describe('state selectors', () => {
errors: [],
},
monitorDuration: {
monitor_duration: null,
durationLines: null,
loading: false,
errors: [],
},
ml: {
mlJob: {
data: null,
loading: false,
},
createJob: { data: null, loading: false },
deleteJob: { data: null, loading: false },
mlCapabilities: { data: null, loading: false },
anomalies: {
data: null,
loading: false,
},
},
indexStatus: {
loading: false,
data: null,
errors: [],
indexStatus: {
data: null,
loading: false,
},
},
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { createSelector } from 'reselect';
import { AppState } from '../../state';
// UI Selectors
@ -21,13 +22,9 @@ export const monitorLocationsSelector = (state: AppState, monitorId: string) =>
return state.monitor.monitorLocationsList?.get(monitorId);
};
export const selectSelectedMonitor = (state: AppState) => {
return state.monitorStatus.monitor;
};
export const selectSelectedMonitor = (state: AppState) => state.monitorStatus.monitor;
export const selectMonitorStatus = (state: AppState) => {
return state.monitorStatus.status;
};
export const selectMonitorStatus = (state: AppState) => state.monitorStatus.status;
export const selectDynamicSettings = (state: AppState) => {
return state.dynamicSettings;
@ -46,6 +43,36 @@ export const selectPingHistogram = ({ ping, ui }: AppState) => {
};
};
const mlCapabilitiesSelector = (state: AppState) => state.ml.mlCapabilities.data;
export const hasMLFeatureAvailable = createSelector(
mlCapabilitiesSelector,
mlCapabilities =>
mlCapabilities?.isPlatinumOrTrialLicense && mlCapabilities?.mlFeatureEnabledInSpace
);
export const canCreateMLJobSelector = createSelector(
mlCapabilitiesSelector,
mlCapabilities => mlCapabilities?.capabilities.canCreateJob
);
export const canDeleteMLJobSelector = createSelector(
mlCapabilitiesSelector,
mlCapabilities => mlCapabilities?.capabilities.canDeleteJob
);
export const hasMLJobSelector = ({ ml }: AppState) => ml.mlJob;
export const hasNewMLJobSelector = ({ ml }: AppState) => ml.createJob;
export const isMLJobCreatingSelector = ({ ml }: AppState) => ml.createJob.loading;
export const isMLJobDeletingSelector = ({ ml }: AppState) => ml.deleteJob.loading;
export const isMLJobDeletedSelector = ({ ml }: AppState) => ml.deleteJob;
export const anomaliesSelector = ({ ml }: AppState) => ml.anomalies.data;
export const selectDurationLines = ({ monitorDuration }: AppState) => {
return monitorDuration;
};
@ -60,5 +87,5 @@ export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }:
});
export const indexStatusSelector = ({ indexStatus }: AppState) => {
return indexStatus;
return indexStatus.indexStatus;
};

View file

@ -326,7 +326,7 @@
"redux": "^4.0.5",
"redux-actions": "^2.6.5",
"redux-observable": "^1.2.0",
"redux-saga": "^0.16.0",
"redux-saga": "^1.1.3",
"redux-thunk": "^2.3.0",
"redux-thunks": "^1.0.0",
"request": "^2.88.0",

View file

@ -36,3 +36,20 @@ export interface AnomalyRecordDoc {
// TODO provide the causes resource interface.
causes?: any[];
}
export interface AnomaliesTableRecord {
time: number;
source: AnomalyRecordDoc;
rowId: string;
jobId: string;
detectorIndex: number;
severity: number;
entityName?: string;
entityValue?: any;
influencers?: Array<{ [key: string]: any }>;
actual?: number[];
actualSort?: any;
typical?: number[];
typicalSort?: any;
metricDescriptionSort?: number;
}

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
export interface JobStat {
id: string;
earliestTimestampMs: number;
latestTimestampMs: number;
latestResultsTimestampMs: number;
}
export interface JobExistResult {
jobsExist: boolean;
jobs: JobStat[];
}

View file

@ -35,6 +35,7 @@ import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_lim
import { fieldsServiceProvider } from '../fields_service';
import { jobServiceProvider } from '../job_service';
import { resultsServiceProvider } from '../results_service';
import { JobExistResult, JobStat } from '../../../common/types/data_recognizer';
const ML_DIR = 'ml';
const KIBANA_DIR = 'kibana';
@ -80,18 +81,6 @@ export interface RecognizeResult {
logo: { icon: string } | null;
}
interface JobStat {
id: string;
earliestTimestampMs: number;
latestTimestampMs: number;
latestResultsTimestampMs: number;
}
interface JobExistResult {
jobsExist: boolean;
jobs: JobStat[];
}
interface ObjectExistResult {
id: string;
type: string;

View file

@ -0,0 +1,3 @@
{
"icon": "uptimeApp"
}

View file

@ -0,0 +1,26 @@
{
"id": "uptime_heartbeat",
"title": "Uptime: Heartbeat",
"description": "Detect latency issues in heartbeat monitors.",
"type": "Heartbeat data",
"logoFile": "logo.json",
"defaultIndexPattern": "heartbeat-*",
"query": {
"bool": {
"filter": [{ "term": { "agent.type": "heartbeat" } }]
}
},
"jobs": [
{
"id": "high_latency_by_geo",
"file": "high_latency_by_geo.json"
}
],
"datafeeds": [
{
"id": "datafeed-high_latency_by_geo",
"file": "datafeed_high_latency_by_geo.json",
"job_id": "high_latency_by_geo"
}
]
}

View file

@ -0,0 +1,13 @@
{
"job_id": "JOB_ID",
"indices": ["INDEX_PATTERN_NAME"],
"max_empty_searches": 10,
"query": {
"bool": {
"must": [
{ "bool": { "filter": { "term": { "agent.type": "heartbeat" } } } },
{ "bool": { "filter": { "term": { "event.dataset": "uptime" } } } }
]
}
}
}

View file

@ -0,0 +1,29 @@
{
"job_type": "anomaly_detector",
"groups": ["uptime"],
"description": "Uptime Heartbeat: Identify periods of increased latency across geographical regions.",
"analysis_config": {
"bucket_span": "15m",
"detectors": [
{
"detector_description": "increased latency",
"function": "high_mean",
"field_name": "monitor.duration.us",
"partition_field_name": "observer.geo.name",
"use_null": true
}
],
"influencers": ["monitor.id", "monitor.name", "observer.geo.name"]
},
"allow_lazy_open": true,
"analysis_limits": {
"model_memory_limit": "32mb"
},
"data_description": {
"time_field": "@timestamp"
},
"custom_settings": {
"created_by": "ml-module-uptime-heartbeat",
"custom_urls": []
}
}

View file

@ -4,24 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AnomalyRecordDoc } from '../../../common/types/anomalies';
export interface AnomaliesTableRecord {
time: number;
source: AnomalyRecordDoc;
rowId: string;
jobId: string;
detectorIndex: number;
severity: number;
entityName?: string;
entityValue?: any;
influencers?: Array<{ [key: string]: any }>;
actual?: number[];
actualSort?: any;
typical?: number[];
typicalSort?: any;
metricDescriptionSort?: number;
}
import { AnomaliesTableRecord } from '../../../common/types/anomalies';
export function buildAnomalyTableItems(
anomalyRecords: any,

View file

@ -8,11 +8,11 @@ import _ from 'lodash';
import moment from 'moment';
import { SearchResponse } from 'elasticsearch';
import { APICaller } from 'kibana/server';
import { buildAnomalyTableItems, AnomaliesTableRecord } from './build_anomaly_table_items';
import { buildAnomalyTableItems } from './build_anomaly_table_items';
import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns';
import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search';
import { getPartitionFieldsValuesFactory } from './get_partition_fields_values';
import { AnomalyRecordDoc } from '../../../common/types/anomalies';
import { AnomaliesTableRecord, AnomalyRecordDoc } from '../../../common/types/anomalies';
// Service for carrying out Elasticsearch queries to obtain data for the
// ML Results dashboards.

View file

@ -3078,6 +3078,50 @@
react-lifecycles-compat "^3.0.4"
warning "^3.0.0"
"@redux-saga/core@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@redux-saga/core/-/core-1.1.3.tgz#3085097b57a4ea8db5528d58673f20ce0950f6a4"
integrity sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg==
dependencies:
"@babel/runtime" "^7.6.3"
"@redux-saga/deferred" "^1.1.2"
"@redux-saga/delay-p" "^1.1.2"
"@redux-saga/is" "^1.1.2"
"@redux-saga/symbols" "^1.1.2"
"@redux-saga/types" "^1.1.0"
redux "^4.0.4"
typescript-tuple "^2.2.1"
"@redux-saga/deferred@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/deferred/-/deferred-1.1.2.tgz#59937a0eba71fff289f1310233bc518117a71888"
integrity sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ==
"@redux-saga/delay-p@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/delay-p/-/delay-p-1.1.2.tgz#8f515f4b009b05b02a37a7c3d0ca9ddc157bb355"
integrity sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g==
dependencies:
"@redux-saga/symbols" "^1.1.2"
"@redux-saga/is@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/is/-/is-1.1.2.tgz#ae6c8421f58fcba80faf7cadb7d65b303b97e58e"
integrity sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w==
dependencies:
"@redux-saga/symbols" "^1.1.2"
"@redux-saga/types" "^1.1.0"
"@redux-saga/symbols@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@redux-saga/symbols/-/symbols-1.1.2.tgz#216a672a487fc256872b8034835afc22a2d0595d"
integrity sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ==
"@redux-saga/types@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@redux-saga/types/-/types-1.1.0.tgz#0e81ce56b4883b4b2a3001ebe1ab298b84237204"
integrity sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
@ -25312,10 +25356,12 @@ redux-observable@^1.2.0:
resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.2.0.tgz#ff51b6c6be2598e9b5e89fc36639186bb0e669c7"
integrity sha512-yeR90RP2WzZzCxxnQPlh2uFzyfFLsfXu8ROh53jGDPXVqj71uNDMmvi/YKQkd9ofiVoO4OYb1snbowO49tCEMg==
redux-saga@^0.16.0:
version "0.16.2"
resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-0.16.2.tgz#993662e86bc945d8509ac2b8daba3a8c615cc971"
integrity sha512-iIjKnRThI5sKPEASpUvySemjzwqwI13e3qP7oLub+FycCRDysLSAOwt958niZW6LhxfmS6Qm1BzbU70w/Koc4w==
redux-saga@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112"
integrity sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==
dependencies:
"@redux-saga/core" "^1.1.3"
redux-thunk@2.2.0:
version "2.2.0"
@ -29889,6 +29935,13 @@ typedarray@^0.0.6, typedarray@~0.0.5:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
typescript-compare@^0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/typescript-compare/-/typescript-compare-0.0.2.tgz#7ee40a400a406c2ea0a7e551efd3309021d5f425"
integrity sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==
dependencies:
typescript-logic "^0.0.0"
typescript-fsa-reducers@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/typescript-fsa-reducers/-/typescript-fsa-reducers-1.2.1.tgz#2af1a85f7b88fb0dfb9fa59d5da51a5d7ac6543f"
@ -29899,6 +29952,18 @@ typescript-fsa@^3.0.0:
resolved "https://registry.yarnpkg.com/typescript-fsa/-/typescript-fsa-3.0.0.tgz#3ad1cb915a67338e013fc21f67c9b3e0e110c912"
integrity sha512-xiXAib35i0QHl/+wMobzPibjAH5TJLDj+qGq5jwVLG9qR4FUswZURBw2qihBm0m06tHoyb3FzpnJs1GRhRwVag==
typescript-logic@^0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/typescript-logic/-/typescript-logic-0.0.0.tgz#66ebd82a2548f2b444a43667bec120b496890196"
integrity sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==
typescript-tuple@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/typescript-tuple/-/typescript-tuple-2.2.1.tgz#7d9813fb4b355f69ac55032e0363e8bb0f04dad2"
integrity sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==
dependencies:
typescript-compare "^0.0.2"
typescript@3.7.2, typescript@^3.0.1, typescript@^3.0.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.4.5, typescript@~3.7.2:
version "3.7.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.2.tgz#27e489b95fa5909445e9fef5ee48d81697ad18fb"