/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Any modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui'; import moment from 'moment-timezone'; import { unitOfTime } from 'moment'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { euiThemeVars } from '@osd/ui-shared-deps/theme'; import { AnnotationDomainType, Axis, Chart, HistogramBarSeries, LineAnnotation, Position, ScaleType, Settings, RectAnnotation, TooltipValue, TooltipType, ElementClickListener, XYChartElementEvent, BrushEndListener, Theme, } from '@elastic/charts'; import { i18n } from '@osd/i18n'; import { IUiSettingsClient } from 'opensearch-dashboards/public'; import { EuiChartThemeType } from '@elastic/eui/dist/eui_charts_theme'; import { Subscription, combineLatest } from 'rxjs'; import { getServices } from '../../../opensearch_dashboards_services'; import { Chart as IChart } from '../helpers/point_series'; export interface DiscoverHistogramProps { chartData: IChart; timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; } interface DiscoverHistogramState { chartsTheme: EuiChartThemeType['theme']; chartsBaseTheme: Theme; } function findIntervalFromDuration( dateValue: number, opensearchValue: number, opensearchUnit: unitOfTime.Base, timeZone: string ) { const date = moment.tz(dateValue, timeZone); const startOfDate = moment.tz(date, timeZone).startOf(opensearchUnit); const endOfDate = moment .tz(date, timeZone) .startOf(opensearchUnit) .add(opensearchValue, opensearchUnit); return endOfDate.valueOf() - startOfDate.valueOf(); } function getIntervalInMs( value: number, opensearchValue: number, opensearchUnit: unitOfTime.Base, timeZone: string ): number { switch (opensearchUnit) { case 's': return 1000 * opensearchValue; case 'ms': return 1 * opensearchValue; default: return findIntervalFromDuration(value, opensearchValue, opensearchUnit, timeZone); } } function getTimezone(uiSettings: IUiSettingsClient) { if (uiSettings.isDefault('dateFormat:tz')) { const detectedTimezone = moment.tz.guess(); if (detectedTimezone) return detectedTimezone; else return moment().format('Z'); } else { return uiSettings.get('dateFormat:tz', 'Browser'); } } export function findMinInterval( xValues: number[], opensearchValue: number, opensearchUnit: string, timeZone: string ): number { return xValues.reduce((minInterval, currentXvalue, index) => { let currentDiff = minInterval; if (index > 0) { currentDiff = Math.abs(xValues[index - 1] - currentXvalue); } const singleUnitInterval = getIntervalInMs( currentXvalue, opensearchValue, opensearchUnit as unitOfTime.Base, timeZone ); return Math.min(minInterval, singleUnitInterval, currentDiff); }, Number.MAX_SAFE_INTEGER); } export class DiscoverHistogram extends Component { public static propTypes = { chartData: PropTypes.object, timefilterUpdateHandler: PropTypes.func, }; private subscription?: Subscription; public state = { chartsTheme: getServices().theme.chartsDefaultTheme, chartsBaseTheme: getServices().theme.chartsDefaultBaseTheme, }; componentDidMount() { this.subscription = combineLatest( getServices().theme.chartsTheme$, getServices().theme.chartsBaseTheme$ ).subscribe(([chartsTheme, chartsBaseTheme]) => this.setState({ chartsTheme, chartsBaseTheme }) ); } componentWillUnmount() { if (this.subscription) { this.subscription.unsubscribe(); } } public onBrushEnd: BrushEndListener = ({ x }) => { if (!x) { return; } const [from, to] = x; this.props.timefilterUpdateHandler({ from, to }); }; public onElementClick = (xInterval: number): ElementClickListener => ([elementData]) => { const startRange = (elementData as XYChartElementEvent)[0].x; const range = { from: startRange, to: startRange + xInterval, }; this.props.timefilterUpdateHandler(range); }; public formatXValue = (val: string) => { const xAxisFormat = this.props.chartData.xAxisFormat.params!.pattern; return moment(val).format(xAxisFormat); }; public renderBarTooltip = (xInterval: number, domainStart: number, domainEnd: number) => ( headerData: TooltipValue ): JSX.Element | string => { const headerDataValue = headerData.value; const formattedValue = this.formatXValue(headerDataValue); const partialDataText = i18n.translate('discover.histogram.partialData.bucketTooltipText', { defaultMessage: 'The selected time range does not include this entire bucket, it may contain partial data.', }); if (headerDataValue < domainStart || headerDataValue + xInterval > domainEnd) { return ( {partialDataText}

{formattedValue}

); } return formattedValue; }; public render() { const uiSettings = getServices().uiSettings; const timeZone = getTimezone(uiSettings); const { chartData } = this.props; const { chartsTheme, chartsBaseTheme } = this.state; if (!chartData) { return null; } const data = chartData.values; /** * Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval]. * see https://github.com/elastic/kibana/issues/27410 * TODO: Once the Discover query has been update, we should change the below to use the new field */ const { intervalOpenSearchValue, intervalOpenSearchUnit, interval } = chartData.ordered; const xInterval = interval.asMilliseconds(); const xValues = chartData.xAxisOrderedValues; const lastXValue = xValues[xValues.length - 1]; const domain = chartData.ordered; const domainStart = domain.min.valueOf(); const domainEnd = domain.max.valueOf(); const domainMin = data[0]?.x > domainStart ? domainStart : data[0]?.x; const domainMax = domainEnd - xInterval > lastXValue ? domainEnd - xInterval : lastXValue; const xDomain = { min: domainMin, max: domainMax, minInterval: findMinInterval( xValues, intervalOpenSearchValue, intervalOpenSearchUnit, timeZone ), }; // Domain end of 'now' will be milliseconds behind current time, so we extend time by 1 minute and check if // the annotation is within this range; if so, the line annotation uses the domainEnd as its value const now = moment(); const isAnnotationAtEdge = moment(domainEnd).add(60000).isAfter(now) && now.isAfter(domainEnd); const lineAnnotationValue = isAnnotationAtEdge ? domainEnd : now; const lineAnnotationData = [ { dataValue: lineAnnotationValue, }, ]; const isDarkMode = uiSettings.get('theme:darkMode'); const lineAnnotationStyle = { line: { strokeWidth: 2, stroke: euiThemeVars.euiColorDanger, opacity: 0.7, }, }; const rectAnnotations = []; if (domainStart !== domainMin) { rectAnnotations.push({ coordinates: { x1: domainStart, }, }); } if (domainEnd !== domainMax) { rectAnnotations.push({ coordinates: { x0: domainEnd, }, }); } const rectAnnotationStyle = { stroke: isDarkMode ? euiThemeVars.euiColorLightShade : euiThemeVars.euiColorDarkShade, strokeWidth: 0, opacity: isDarkMode ? 0.6 : 0.2, fill: isDarkMode ? euiThemeVars.euiColorLightShade : euiThemeVars.euiColorDarkShade, }; const tooltipProps = { headerFormatter: this.renderBarTooltip(xInterval, domainStart, domainEnd), type: TooltipType.VerticalCursor, }; return ( ); } }