import { cloneDeep } from 'lodash' ; import moment from 'moment' ; import rison , { RisonValue } from 'rison-node' ; import React , { useEffect , useState } from 'react' ; import { EuiButtonIcon , EuiContextMenuItem , EuiContextMenuPanel , EuiPopover } from '@elastic/eui' ; import { FormattedMessage } from '@kbn/i18n-react' ; import { i18n } from '@kbn/i18n' ; import { ES_FIELD_TYPES } from '@kbn/field-types' ; import { mlJobService } from '../../services/job_service' ; import { getDataViewIdFromName } from '../../util/index_utils' ; import { formatHumanReadableDateTimeSeconds , timeFormatter , } from '../../../../common/util/date_utils' ; import { parseInterval } from '../../../../common/util/parse_interval' ; import { ml } from '../../services/ml_api_service' ; import { replaceStringTokens } from '../../util/string_utils' ; import { getUrlForRecord , openCustomUrlWindow } from '../../util/custom_url_utils' ; import { ML_APP_LOCATOR , ML_PAGES } from '../../../../common/constants/locator' ; import { SEARCH_QUERY_LANGUAGE } from '../../../../common/constants/search' ; import { escapeDoubleQuotes } from '../../explorer/explorer_utils' ; import { isCategorizationAnomaly , isRuleSupported } from '../../../../common/util/anomaly_utils' ; import { checkPermission } from '../../capabilities/check_capabilities' ; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public' ; import { CustomUrlAnomalyRecordDoc , KibanaUrlConfig } from '../../../../common/types/custom_urls' ; import { TimeRangeBounds } from '../../util/time_buckets' ; import { MlKibanaReactContextValue } from '../../contexts/kibana' ; import { getFieldTypeFromMapping } from '../../services/mapping_service' ; import { AnomaliesTableRecord } from '../../../../common/types/anomalies' ; interface LinksMenuProps { anomaly : AnomaliesTableRecord ; bounds : TimeRangeBounds ; showViewSeriesLink : boolean ; isAggregatedData : boolean ; interval : 'day' | 'hour' | 'second' ; showRuleEditorFlyout : ( anomaly : AnomaliesTableRecord ) => void ; kibana : MlKibanaReactContextValue ; } export const LinksMenuUI = ( props ) => { const [ isPopoverOpen , setPopoverOpen ] = useState ( false ) ; const [ openInDiscoverUrl , setOpenInDiscoverUrl ] = useState < string | undefined > ( ) ; useEffect ( ( ) => { let unmounted = false ; const generateDiscoverUrl = async ( ) => { const { services : { share } , } = props . kibana ; const discoverLocator = share . url . locators . get ( 'DISCOVER_APP_LOCATOR' ) ; if ( ! discoverLocator ) { console . error ( 'No locator for Discover detected' ) ; return ; } const job = mlJobService . getJob ( props . anomaly . jobId ) ; const index = job . datafeed_config . indices [ 0 ] ; const interval = props . interval ; const dataViewId = ( await getDataViewIdFromName ( index ) ) || index ; const record = props . anomaly . source ; const earliestMoment = moment ( record . timestamp ) . startOf ( interval ) ; if ( interval === 'hour' ) { earliestMoment . subtract ( 1 , 'h' ) ; } let latestMoment = moment ( record . timestamp ) . add ( record . bucket_span , 's' ) ; if ( props . isAggregatedData === true ) { latestMoment = moment ( record . timestamp ) . endOf ( interval ) ; if ( interval === 'hour' ) { latestMoment . add ( 1 , 'h' ) ; } } const from = timeFormatter ( earliestMoment . unix ( ) * 1000 ) ; const to = timeFormatter ( latestMoment . unix ( ) * 1000 ) ; let kqlQuery = '' ; if ( record . influencers ) { kqlQuery = record . influencers . map ( ( influencer ) => `"${ influencer . influencer_field_name }":"${ influencer . influencer_field_values [ 0 ] ?? '' }"` ) . join ( ' AND ' ) ; } const url = await discoverLocator . getRedirectUrl ( { indexPatternId : dataViewId , refreshInterval : { display : 'Off' , pause : true , value : 0 , } , timeRange : { from , to , mode : 'absolute' , } , query : { language : 'kuery' , query : kqlQuery , } , sort : [ [ 'timestamp, asc' ] ] , } ) ; if ( ! unmounted ) { setOpenInDiscoverUrl ( url ) ; } } ; if ( ! isCategorizationAnomaly ( props . anomaly ) ) { generateDiscoverUrl ( ) ; } return ( ) => { unmounted = true ; } ; } , [ JSON . stringify ( props . anomaly ) ] ) ; const onButtonClick = ( ) => setPopoverOpen ( ! isPopoverOpen ) ; const closePopover = ( ) => setPopoverOpen ( false ) ; const openCustomUrl = ( customUrl ) => { const { anomaly , interval , isAggregatedData } = props ; console . log ( 'Anomalies Table - open customUrl for record:' , anomaly ) ; const record = cloneDeep ( anomaly . source ) as CustomUrlAnomalyRecordDoc ; const timestamp = record . timestamp ; const configuredUrlValue = customUrl . url_value ; const timeRangeInterval = customUrl . time_range !== undefined ? parseInterval ( customUrl . time_range ) : null ; const basePath = props . kibana . services . http . basePath . get ( ) ; if ( configuredUrlValue . includes ( '$earliest$' ) ) { let earliestMoment = moment ( timestamp ) ; if ( timeRangeInterval !== null ) { earliestMoment . subtract ( timeRangeInterval ) ; } else { earliestMoment = moment ( timestamp ) . startOf ( interval ) ; if ( interval === 'hour' ) { earliestMoment . subtract ( 1 , 'h' ) ; } } record . earliest = earliestMoment . toISOString ( ) ; } if ( configuredUrlValue . includes ( '$latest$' ) ) { let latestMoment = moment ( timestamp ) . add ( record . bucket_span , 's' ) ; if ( timeRangeInterval !== null ) { latestMoment . add ( timeRangeInterval ) ; } else { if ( isAggregatedData === true ) { latestMoment = moment ( timestamp ) . endOf ( interval ) ; if ( interval === 'hour' ) { latestMoment . add ( 1 , 'h' ) ; } } } record . latest = latestMoment . toISOString ( ) ; } if ( ( configuredUrlValue . includes ( '$mlcategoryterms$' ) || configuredUrlValue . includes ( '$mlcategoryregex$' ) ) && record . mlcategory !== undefined ) { const jobId = record . job_id ; const categoryId = record . mlcategory [ 0 ] ; ml . results . getCategoryDefinition ( jobId , categoryId ) . then ( ( resp ) => { const termsArray = resp . terms . split ( ' ' ) . map ( ( term ) => `+${ term }` ) ; record . mlcategoryterms = termsArray . join ( ' ' ) ; record . mlcategoryregex = resp . regex ; const urlPath = replaceStringTokens ( customUrl . url_value , record , true ) ; openCustomUrlWindow ( urlPath , customUrl , basePath ) ; } ) . catch ( ( resp ) => { console . log ( 'openCustomUrl(): error loading categoryDefinition:' , resp ) ; const { toasts } = props . kibana . services . notifications ; toasts . addDanger ( i18n . translate ( 'xpack.ml.anomaliesTable.linksMenu.unableToOpenLinkErrorMessage' , { defaultMessage : 'Unable to open link as an error occurred loading details on category ID {categoryId}' , values : { categoryId , } , } ) ) ; } ) ; } else { const urlPath = getUrlForRecord ( customUrl , record as CustomUrlAnomalyRecordDoc ) ; openCustomUrlWindow ( urlPath , customUrl , basePath ) ; } } ; const viewSeries = async ( ) => { const { services : { share } , } = props . kibana ; const mlLocator = share . url . locators . get ( ML_APP_LOCATOR ) ; const record = props . anomaly . source ; const bounds = props . bounds ; if ( ! mlLocator ) { console . error ( 'Unable to detect locator for ML or bounds' ) ; return ; } if ( ! bounds || ! bounds . min || ! bounds . max ) { console . error ( 'Invalid bounds' ) ; return ; } const from = bounds . min . toISOString ( ) ; const to = bounds . max . toISOString ( ) ; const recordTime = moment ( record . timestamp ) ; const zoomFrom = recordTime . subtract ( 50 * record . bucket_span , 's' ) . toISOString ( ) ; const zoomTo = recordTime . add ( 100 * record . bucket_span , 's' ) . toISOString ( ) ; const entityCondition = { } ; if ( record . partition_field_name !== undefined && record . partition_field_value !== undefined ) { entityCondition [ record . partition_field_name ] = record . partition_field_value ; } if ( record . over_field_name !== undefined && record . over_field_value !== undefined ) { entityCondition [ record . over_field_name ] = record . over_field_value ; } if ( record . by_field_name !== undefined && record . by_field_value !== undefined ) { entityCondition [ record . by_field_name ] = record . by_field_value ; } const singleMetricViewerLink = await mlLocator . getUrl ( { page : ML_PAGES . SINGLE_METRIC_VIEWER , pageState : { jobIds : [ record . job_id ] , refreshInterval : { display : 'Off' , pause : true , value : 0 , } , timeRange : { from , to , mode : 'absolute' , } , zoom : { from : zoomFrom , to : zoomTo , } , detectorIndex : record . detector_index , entities : entityCondition , query_string : { analyze_wildcard : true , query : '*' , } , } , } , { absolute : true } ) ; window . open ( singleMetricViewerLink , '_blank' ) ; } ; const viewExamples = ( ) => { const categoryId = props . anomaly . entityValue ; const record = props . anomaly . source ; const job = mlJobService . getJob ( props . anomaly . jobId ) ; if ( job === undefined ) { console . log ( `viewExamples(): no job found with ID: ${ props . anomaly . jobId }` ) ; const { toasts } = props . kibana . services . notifications ; toasts . addDanger ( i18n . translate ( 'xpack.ml.anomaliesTable.linksMenu.unableToViewExamplesErrorMessage' , { defaultMessage : 'Unable to view examples as no details could be found for job ID {jobId}' , values : { jobId : props . anomaly . jobId , } , } ) ) ; return ; } const categorizationFieldName = job . analysis_config . categorization_field_name ; const datafeedIndices = job . datafeed_config . indices ; let i = 0 ; findFieldType ( datafeedIndices [ i ] ) ; const error = ( ) => { console . log ( `viewExamples(): error finding type of field ${ categorizationFieldName } in indices:` , datafeedIndices ) ; const { toasts } = props . kibana . services . notifications ; toasts . addDanger ( i18n . translate ( 'xpack.ml.anomaliesTable.linksMenu.noMappingCouldBeFoundErrorMessage' , { defaultMessage : 'Unable to view examples of documents with mlcategory {categoryId} ' + 'as no mapping could be found for the categorization field {categorizationFieldName}' , values : { categoryId , categorizationFieldName , } , } ) ) ; } ; const createAndOpenUrl = ( index , categorizationFieldType ) => { ml . results . getCategoryDefinition ( record . job_id , categoryId ) . then ( async ( resp ) => { const dataViewId = ( await getDataViewIdFromName ( index ) ) ?? index ; let query = null ; if ( categorizationFieldType === ES_FIELD_TYPES . KEYWORD ) { if ( resp . regex ) { query = { language : SEARCH_QUERY_LANGUAGE . LUCENE , query : `${ categorizationFieldName }:/${ resp . regex }/` , } ; } } else { if ( resp . terms ) { const escapedTerms = escapeDoubleQuotes ( resp . terms ) ; query = { language : SEARCH_QUERY_LANGUAGE . KUERY , query : `${ categorizationFieldName }:"` + escapedTerms . split ( ' ' ) . join ( `" and ${ categorizationFieldName }:"` ) + '"' , } ; } } const recordTime = moment ( record . timestamp ) ; const from = recordTime . toISOString ( ) ; const to = recordTime . add ( record . bucket_span , 's' ) . toISOString ( ) ; const _g = rison . encode ( { refreshInterval : { display : 'Off' , pause : true , value : 0 , } , time : { from , to , mode : 'absolute' , } , } ) ; const appStateProps = { index : dataViewId , filters : [ ] , } ; if ( query !== null ) { appStateProps . query = query ; } const _a = rison . encode ( appStateProps ) ; const { basePath } = props . kibana . services . http ; let path = basePath . get ( ) ; path += '/app/discover#/' ; path += '?_g=' + _g ; path += '&_a=' + encodeURIComponent ( _a ) ; window . open ( path , '_blank' ) ; } ) . catch ( ( resp ) => { console . log ( 'viewExamples(): error loading categoryDefinition:' , resp ) ; const { toasts } = props . kibana . services . notifications ; toasts . addDanger ( i18n . translate ( 'xpack.ml.anomaliesTable.linksMenu.loadingDetailsErrorMessage' , { defaultMessage : 'Unable to view examples as an error occurred loading details on category ID {categoryId}' , values : { categoryId , } , } ) ) ; } ) ; } ; function findFieldType ( index ) { getFieldTypeFromMapping ( index , categorizationFieldName ) . then ( ( resp ) => { if ( resp !== '' ) { createAndOpenUrl ( datafeedIndices . join ( ) , resp ) ; } else { i ++ ; if ( i < datafeedIndices . length ) { findFieldType ( datafeedIndices [ i ] ) ; } else { error ( ) ; } } } ) . catch ( ( ) => { error ( ) ; } ) ; } } ; const { anomaly , showViewSeriesLink } = props ; const canConfigureRules = isRuleSupported ( anomaly . source ) && checkPermission ( 'canUpdateJob' ) ; const button = ( < EuiButtonIcon size = "s" color = "text" onClick = { onButtonClick } iconType = "gear" aria - label = { i18n . translate ( 'xpack.ml.anomaliesTable.linksMenu.selectActionAriaLabel' , { defaultMessage : 'Select action for anomaly at {time}' , values : { time : formatHumanReadableDateTimeSeconds ( anomaly . time ) } , } ) } data - test - subj = "mlAnomaliesListRowActionsButton" / > ) ; const items = [ ] ; if ( anomaly . customUrls !== undefined ) { anomaly . customUrls . forEach ( ( customUrl , index ) => { items . push ( < EuiContextMenuItem key = { `custom_url_${ index }` } icon = "popout" onClick = { ( ) => { closePopover ( ) ; openCustomUrl ( customUrl ) ; } } data - test - subj = { `mlAnomaliesListRowActionCustomUrlButton_${ index }` } > { customUrl . url_name } < /EuiContextMenuItem>); } ) ; } if ( openInDiscoverUrl ) { items . push ( < EuiContextMenuItem key = { `auto_raw_data_url` } icon = "discoverApp" href = { openInDiscoverUrl } data - test - subj = { `mlAnomaliesListRowAction_viewInDiscoverButton` } > < FormattedMessage id = "xpack.ml.anomaliesTable.linksMenu.viewInDiscover" defaultMessage = "View in Discover" / > < /EuiContextMenuItem>); } if ( showViewSeriesLink === true && anomaly . isTimeSeriesViewRecord === true ) { items . push ( < EuiContextMenuItem key = "view_series" icon = "visLine" onClick = { ( ) => { closePopover ( ) ; viewSeries ( ) ; } } data - test - subj = "mlAnomaliesListRowActionViewSeriesButton" > < FormattedMessage id = "xpack.ml.anomaliesTable.linksMenu.viewSeriesLabel" defaultMessage = "View series" / > < /EuiContextMenuItem>); } if ( isCategorizationAnomaly ( anomaly ) ) { items . push ( < EuiContextMenuItem key = "view_examples" icon = "popout" onClick = { ( ) => { closePopover ( ) ; viewExamples ( ) ; } } data - test - subj = "mlAnomaliesListRowActionViewExamplesButton" > < FormattedMessage id = "xpack.ml.anomaliesTable.linksMenu.viewExamplesLabel" defaultMessage = "View examples" / > < /EuiContextMenuItem>); } if ( canConfigureRules ) { items . push ( < EuiContextMenuItem key = "create_rule" icon = "controlsHorizontal" onClick = { ( ) => { closePopover ( ) ; props . showRuleEditorFlyout ( anomaly ) ; } } data - test - subj = "mlAnomaliesListRowActionConfigureRulesButton" > < FormattedMessage id = "xpack.ml.anomaliesTable.linksMenu.configureRulesLabel" defaultMessage = "Configure job rules" / > < /EuiContextMenuItem>); } return ( < EuiPopover button = { button } isOpen = { isPopoverOpen } closePopover = { closePopover } panelPaddingSize = "none" anchorPosition = "downLeft" > < EuiContextMenuPanel items = { items } data - test - subj = "mlAnomaliesListRowActionsMenu" / > < /EuiPopover>); } ; export const LinksMenu = withKibana ( LinksMenuUI ) ;