diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx index 5f556fbcd34..1ee9c925fc8 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css'; import React, { useState, HTMLAttributes, useMemo, useRef, useLayoutEffect } from 'react'; -import useWindowSize from 'react-use/lib/useWindowSize'; +import { useWindowSize } from 'react-use'; import { Dimensions2D, GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/plugins/panel/barchart/config.ts b/packages/grafana-ui/src/components/uPlot/config/addTooltipSupport.ts similarity index 67% rename from public/app/plugins/panel/barchart/config.ts rename to packages/grafana-ui/src/components/uPlot/config/addTooltipSupport.ts index 1b8d0b818eb..4c919198ec0 100644 --- a/public/app/plugins/panel/barchart/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config/addTooltipSupport.ts @@ -1,8 +1,10 @@ import { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { CartesianCoords2D } from '@grafana/data'; -import { UPlotConfigBuilder } from '@grafana/ui'; -import { positionTooltip } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin'; + +import { positionTooltip } from '../plugins/TooltipPlugin'; + +import { UPlotConfigBuilder } from './UPlotConfigBuilder'; export type HoverEvent = { xIndex: number; @@ -16,14 +18,14 @@ type SetupConfigParams = { onUPlotClick: () => void; setFocusedSeriesIdx: Dispatch>; setFocusedPointIdx: Dispatch>; - setCoords: Dispatch>; + setCoords: Dispatch>; setHover: Dispatch>; isToolTipOpen: MutableRefObject; }; // This applies config hooks to setup tooltip listener. Ideally this could happen in the same `prepConfig` function // however the GraphNG structures do not allow access to the `setHover` callback -export const setupConfig = ({ +export const addTooltipSupport = ({ config, onUPlotClick, setFocusedSeriesIdx, @@ -32,13 +34,37 @@ export const setupConfig = ({ setHover, isToolTipOpen, }: SetupConfigParams): UPlotConfigBuilder => { + // Ensure tooltip is closed on config changes + isToolTipOpen.current = false; + + var onMouseLeave = () => { + if (!isToolTipOpen.current) { + setCoords(null); + } + }; + + let ref_parent: HTMLElement | null = null; + let ref_over: HTMLElement | null = null; config.addHook('init', (u) => { - u.root.parentElement?.addEventListener('click', onUPlotClick); - u.over.addEventListener('mouseleave', () => { - if (!isToolTipOpen.current) { - setCoords(null); - } - }); + ref_parent = u.root.parentElement; + ref_over = u.over; + ref_parent?.addEventListener('click', onUPlotClick); + ref_over.addEventListener('mouseleave', onMouseLeave); + }); + + var clearPopupIfOpened = () => { + if (isToolTipOpen.current) { + setCoords(null); + onUPlotClick(); + } + }; + + config.addHook('drawClear', clearPopupIfOpened); + + config.addHook('destroy', () => { + ref_parent?.removeEventListener('click', onUPlotClick); + ref_over?.removeEventListener('mouseleave', onMouseLeave); + clearPopupIfOpened(); }); let rect: DOMRect; @@ -65,7 +91,7 @@ export const setupConfig = ({ const { x, y } = positionTooltip(u, rect); if (x !== undefined && y !== undefined && !isToolTipOpen.current) { - setCoords({ x, y }); + setCoords({ canvas: { x: u.cursor.left!, y: u.cursor.top! }, viewport: { x, y } }); } }, u diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index b569a8321df..39203bdfcf9 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -1,4 +1,3 @@ -import { css } from '@emotion/css'; import React, { useMemo, useRef, useState } from 'react'; import { @@ -6,7 +5,6 @@ import { compareDataFrameStructures, DataFrame, getFieldDisplayName, - GrafanaTheme2, PanelProps, TimeRange, VizOrientation, @@ -22,19 +20,18 @@ import { UPlotConfigBuilder, UPLOT_AXIS_FONT_SIZE, usePanelContext, - useStyles2, useTheme2, VizLayout, VizLegend, VizTooltipContainer, } from '@grafana/ui'; import { PropDiffFn } from '@grafana/ui/src/components/GraphNG/GraphNG'; +import { HoverEvent, addTooltipSupport } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { DataHoverView } from '../geomap/components/DataHoverView'; import { getFieldLegendItem } from '../state-timeline/utils'; -import { HoverEvent, setupConfig } from './config'; import { PanelOptions } from './models.gen'; import { prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils'; @@ -75,14 +72,13 @@ export const BarChartPanel: React.FunctionComponent = ({ id, }) => { const theme = useTheme2(); - const styles = useStyles2(getStyles); const { eventBus } = usePanelContext(); const oldConfig = useRef(undefined); const isToolTipOpen = useRef(false); const [hover, setHover] = useState(undefined); - const [coords, setCoords] = useState(null); + const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null); const [focusedSeriesIdx, setFocusedSeriesIdx] = useState(null); const [focusedPointIdx, setFocusedPointIdx] = useState(null); const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); @@ -105,6 +101,7 @@ export const BarChartPanel: React.FunctionComponent = ({ const chartDisplay = 'viz' in info ? info : null; const structureRef = useRef(10000); + useMemo(() => { structureRef.current++; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -166,10 +163,23 @@ export const BarChartPanel: React.FunctionComponent = ({ return ( <> {shouldDisplayCloseButton && ( - <> - -
- +
+ +
)} = ({ > {(config) => { if (oldConfig.current !== config) { - oldConfig.current = setupConfig({ + oldConfig.current = addTooltipSupport({ config, onUPlotClick, setFocusedSeriesIdx, @@ -294,7 +304,7 @@ export const BarChartPanel: React.FunctionComponent = ({ {hover && coords && ( @@ -307,9 +317,3 @@ export const BarChartPanel: React.FunctionComponent = ({ ); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - closeButtonSpacer: css` - margin-bottom: 15px; - `, -}); diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index 5bd63eb3b45..d65b91820c0 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -200,10 +200,23 @@ export const HeatmapPanel: React.FC = ({ allowPointerEvents={isToolTipOpen.current} > {shouldDisplayCloseButton && ( - <> - -
- +
+ +
)} @@ -214,9 +227,6 @@ export const HeatmapPanel: React.FC = ({ }; const getStyles = (theme: GrafanaTheme2) => ({ - closeButtonSpacer: css` - margin-bottom: 15px; - `, colorScaleWrapper: css` margin-left: 25px; padding: 10px 0; diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index e189a12e3e7..51a7a9146a3 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -1,12 +1,13 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { DataFrame, FieldType, PanelProps } from '@grafana/data'; -import { TooltipPlugin, useTheme2, ZoomPlugin, usePanelContext } from '@grafana/ui'; +import { CartesianCoords2D, DataFrame, FieldType, PanelProps } from '@grafana/data'; +import { Portal, UPlotConfigBuilder, usePanelContext, useTheme2, VizTooltipContainer, ZoomPlugin } from '@grafana/ui'; +import { HoverEvent, addTooltipSupport } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; +import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { getLastStreamingDataFramePacket } from 'app/features/live/data/StreamingDataFrame'; import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; -import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; @@ -15,6 +16,8 @@ import { TimelineChart } from './TimelineChart'; import { TimelineMode, TimelineOptions } from './types'; import { prepareTimelineFields, prepareTimelineLegendItems } from './utils'; +const TOOLTIP_OFFSET = 10; + interface TimelinePanelProps extends PanelProps {} /** @@ -31,7 +34,28 @@ export const StateTimelinePanel: React.FC = ({ onChangeTimeRange, }) => { const theme = useTheme2(); - const { sync, canAddAnnotations } = usePanelContext(); + + const oldConfig = useRef(undefined); + const isToolTipOpen = useRef(false); + + const [hover, setHover] = useState(undefined); + const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null); + const [focusedSeriesIdx, setFocusedSeriesIdx] = useState(null); + const [focusedPointIdx, setFocusedPointIdx] = useState(null); + const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); + const { canAddAnnotations } = usePanelContext(); + + const onCloseToolTip = () => { + isToolTipOpen.current = false; + setCoords(null); + setShouldDisplayCloseButton(false); + }; + + const onUPlotClick = () => { + isToolTipOpen.current = !isToolTipOpen.current; + // Linking into useState required to re-render tooltip + setShouldDisplayCloseButton(isToolTipOpen.current); + }; const { frames, warn } = useMemo( () => prepareTimelineFields(data?.series, options.mergeValues ?? true, timeRange, theme), @@ -46,7 +70,7 @@ export const StateTimelinePanel: React.FC = ({ const timezones = useMemo(() => getTimezones(options.timezones, timeZone), [options.timezones, timeZone]); const renderCustomTooltip = useCallback( - (alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => { + (alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null, onAnnotationAdd?: () => void) => { const data = frames ?? []; // Count value fields in the state-timeline-ready frame const valueFieldsCount = data.reduce( @@ -73,16 +97,38 @@ export const StateTimelinePanel: React.FC = ({ } return ( - + <> + {shouldDisplayCloseButton && ( +
+ +
+ )} + + ); }, - [timeZone, frames] + [timeZone, frames, shouldDisplayCloseButton] ); if (!frames || warn) { @@ -115,53 +161,60 @@ export const StateTimelinePanel: React.FC = ({ mode={TimelineMode.Changes} > {(config, alignedFrame) => { + if (oldConfig.current !== config) { + oldConfig.current = addTooltipSupport({ + config, + onUPlotClick, + setFocusedSeriesIdx, + setFocusedPointIdx, + setCoords, + setHover, + isToolTipOpen, + }); + } return ( <> - + {data.annotations && ( )} - {enableAnnotationCreation && ( + {enableAnnotationCreation ? ( {({ startAnnotating }) => { return ( - { - if (!p) { - return; - } - startAnnotating({ coords: p.coords }); - }, - }, - ], - }, - ]} - /> + + {hover && coords && ( + + {renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx, () => { + startAnnotating({ coords: { plotCanvas: coords.canvas, viewport: coords.viewport } }); + onCloseToolTip(); + })} + + )} + ); }} + ) : ( + + {hover && coords && ( + + {renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)} + + )} + )} ); diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx index b09926cdb73..65047f08224 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx @@ -1,7 +1,15 @@ import React from 'react'; -import { DataFrame, FALLBACK_COLOR, getDisplayProcessor, getFieldDisplayName, TimeZone } from '@grafana/data'; -import { SeriesTableRow, useTheme2 } from '@grafana/ui'; +import { + DataFrame, + FALLBACK_COLOR, + Field, + getDisplayProcessor, + getFieldDisplayName, + TimeZone, + LinkModel, +} from '@grafana/data'; +import { MenuItem, SeriesTableRow, useTheme2 } from '@grafana/ui'; import { findNextStateIndex, fmtDuration } from './utils'; @@ -11,6 +19,7 @@ interface StateTimelineTooltipProps { seriesIdx: number; datapointIdx: number; timeZone: TimeZone; + onAnnotationAdd?: () => void; } export const StateTimelineTooltip: React.FC = ({ @@ -19,14 +28,34 @@ export const StateTimelineTooltip: React.FC = ({ seriesIdx, datapointIdx, timeZone, + onAnnotationAdd, }) => { const theme = useTheme2(); - const xField = alignedData.fields[0]; - const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme }); + if (!data || datapointIdx == null) { + return null; + } const field = alignedData.fields[seriesIdx!]; + const links: Array> = []; + const linkLookup = new Set(); + + if (field.getLinks) { + const v = field.values.get(datapointIdx); + const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; + field.getLinks({ calculatedValue: disp, valueRowIndex: datapointIdx }).forEach((link) => { + const key = `${link.title}/${link.href}`; + if (!linkLookup.has(key)) { + links.push(link); + linkLookup.add(key); + } + }); + } + + const xField = alignedData.fields[0]; + const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme }); + const dataFrameFieldIndex = field.state?.origin; const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme }); const value = field.values.get(datapointIdx!); @@ -66,13 +95,34 @@ export const StateTimelineTooltip: React.FC = ({ } return ( -
- {fieldDisplayName} -
- - From {xFieldFmt(xField.values.get(datapointIdx!)).text} - {toFragment} - {durationFragment} +
+
+ {fieldDisplayName} +
+ + From {xFieldFmt(xField.values.get(datapointIdx!)).text} + {toFragment} + {durationFragment} +
+
+ {onAnnotationAdd && } + {links.length > 0 && + links.map((link, i) => ( + + ))} +
); }; diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index cd3c25f8eb5..be01c2d78bb 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -1,7 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { PanelProps } from '@grafana/data'; -import { TooltipPlugin, useTheme2, ZoomPlugin } from '@grafana/ui'; +import { CartesianCoords2D, DataFrame, FieldType, PanelProps } from '@grafana/data'; +import { Portal, UPlotConfigBuilder, useTheme2, VizTooltipContainer, ZoomPlugin } from '@grafana/ui'; +import { HoverEvent, addTooltipSupport } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; +import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { TimelineChart } from '../state-timeline/TimelineChart'; import { TimelineMode } from '../state-timeline/types'; @@ -9,8 +11,11 @@ import { prepareTimelineFields, prepareTimelineLegendItems } from '../state-time import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; +import { StatusHistoryTooltip } from './StatusHistoryTooltip'; import { StatusPanelOptions } from './types'; +const TOOLTIP_OFFSET = 10; + interface TimelinePanelProps extends PanelProps {} /** @@ -27,6 +32,28 @@ export const StatusHistoryPanel: React.FC = ({ }) => { const theme = useTheme2(); + const oldConfig = useRef(undefined); + const isToolTipOpen = useRef(false); + + const [hover, setHover] = useState(undefined); + const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null); + const [focusedSeriesIdx, setFocusedSeriesIdx] = useState(null); + const [focusedPointIdx, setFocusedPointIdx] = useState(null); + const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); + + const onCloseToolTip = () => { + isToolTipOpen.current = false; + setCoords(null); + setShouldDisplayCloseButton(false); + }; + + const onUPlotClick = () => { + isToolTipOpen.current = !isToolTipOpen.current; + + // Linking into useState required to re-render tooltip + setShouldDisplayCloseButton(isToolTipOpen.current); + }; + const { frames, warn } = useMemo( () => prepareTimelineFields(data?.series, false, timeRange, theme), [data, timeRange, theme] @@ -37,6 +64,68 @@ export const StatusHistoryPanel: React.FC = ({ [frames, options.legend, theme] ); + const renderCustomTooltip = useCallback( + (alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => { + const data = frames ?? []; + + // Count value fields in the state-timeline-ready frame + const valueFieldsCount = data.reduce( + (acc, frame) => acc + frame.fields.filter((field) => field.type !== FieldType.time).length, + 0 + ); + + // Not caring about multi mode in StatusHistory + if (seriesIdx === null || datapointIdx === null) { + return null; + } + + /** + * There could be a case when the tooltip shows a data from one of a multiple query and the other query finishes first + * from refreshing. This causes data to be out of sync. alignedData - 1 because Time field doesn't count. + * Render nothing in this case to prevent error. + * See https://github.com/grafana/support-escalations/issues/932 + */ + if ( + (!alignedData.meta?.transformations?.length && alignedData.fields.length - 1 !== valueFieldsCount) || + !alignedData.fields[seriesIdx] + ) { + return null; + } + + return ( + <> + {shouldDisplayCloseButton && ( +
+ +
+ )} + + + ); + }, + [timeZone, frames, shouldDisplayCloseButton] + ); + const timezones = useMemo(() => getTimezones(options.timezones, timeZone), [options.timezones, timeZone]); if (!frames || warn) { @@ -74,10 +163,31 @@ export const StatusHistoryPanel: React.FC = ({ mode={TimelineMode.Samples} > {(config, alignedFrame) => { + if (oldConfig.current !== config) { + oldConfig.current = addTooltipSupport({ + config, + onUPlotClick, + setFocusedSeriesIdx, + setFocusedPointIdx, + setCoords, + setHover, + isToolTipOpen, + }); + } return ( <> - + + {hover && coords && ( + + {renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)} + + )} + ); diff --git a/public/app/plugins/panel/status-history/StatusHistoryTooltip.tsx b/public/app/plugins/panel/status-history/StatusHistoryTooltip.tsx new file mode 100644 index 00000000000..a22cffbc39d --- /dev/null +++ b/public/app/plugins/panel/status-history/StatusHistoryTooltip.tsx @@ -0,0 +1,94 @@ +import React from 'react'; + +import { + DataFrame, + FALLBACK_COLOR, + Field, + getDisplayProcessor, + getFieldDisplayName, + TimeZone, + LinkModel, +} from '@grafana/data'; +import { MenuItem, SeriesTableRow, useTheme2 } from '@grafana/ui'; + +interface StatusHistoryTooltipProps { + data: DataFrame[]; + alignedData: DataFrame; + seriesIdx: number; + datapointIdx: number; + timeZone: TimeZone; +} + +export const StatusHistoryTooltip: React.FC = ({ + data, + alignedData, + seriesIdx, + datapointIdx, + timeZone, +}) => { + const theme = useTheme2(); + + if (!data || datapointIdx == null) { + return null; + } + + const field = alignedData.fields[seriesIdx!]; + + const links: Array> = []; + const linkLookup = new Set(); + + if (field.getLinks) { + const v = field.values.get(datapointIdx); + const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; + field.getLinks({ calculatedValue: disp, valueRowIndex: datapointIdx }).forEach((link) => { + const key = `${link.title}/${link.href}`; + if (!linkLookup.has(key)) { + links.push(link); + linkLookup.add(key); + } + }); + } + + const dataFrameFieldIndex = field.state?.origin; + const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme }); + const value = field.values.get(datapointIdx!); + const display = fieldFmt(value); + const fieldDisplayName = dataFrameFieldIndex + ? getFieldDisplayName( + data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex], + data[dataFrameFieldIndex.frameIndex], + data + ) + : null; + + return ( +
+
+ {fieldDisplayName} +
+ +
+ {links.length > 0 && ( +
+ {links.map((link, i) => ( + + ))} +
+ )} +
+ ); +}; + +StatusHistoryTooltip.displayName = 'StatusHistoryTooltip';