From 7359ba44d0d7e3001aba9dc31b97d228585fbac1 Mon Sep 17 00:00:00 2001
From: Dominik Prokop <dominik.prokop@grafana.com>
Date: Thu, 3 Jun 2021 04:43:47 +0200
Subject: [PATCH] Timeline/Status grid panel: Add tooltip support (#35005)

* Timeline/Status grid tooltip support first pass

* Tooltips workin

* Use getValueFormat to get the duration

* Separate boxes highlight from tooltip interpolation

* Separate state timeline tooltip component, rely on field display color to retrieve color of series

* create an onHover/onLeave API and optimize implementation

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
---
 .../src/components/VizTooltip/SeriesTable.tsx |  11 +-
 .../VizTooltip/VizTooltipContainer.tsx        |   2 +-
 .../src/components/VizTooltip/index.tsx       |   2 +-
 packages/grafana-ui/src/components/index.ts   |   2 +
 .../uPlot/config/UPlotConfigBuilder.ts        |  14 +--
 .../uPlot/plugins/TooltipPlugin.tsx           | 109 ++++++++++--------
 .../grafana-ui/src/components/uPlot/types.ts  |   5 +-
 .../grafana-ui/src/options/builder/text.tsx   |  11 --
 .../src/options/builder/tooltip.tsx           |  22 +++-
 packages/grafana-ui/src/options/models.gen.ts |  13 ++-
 public/app/plugins/panel/barchart/bars.ts     |   8 +-
 public/app/plugins/panel/barchart/utils.ts    |   2 +-
 .../state-timeline/StateTimelinePanel.tsx     |  43 ++++++-
 .../state-timeline/StateTimelineTooltip.tsx   |  85 ++++++++++++++
 .../panel/state-timeline/TimelineChart.tsx    |   6 +-
 .../plugins/panel/state-timeline/module.tsx   |   6 +-
 .../plugins/panel/state-timeline/timeline.ts  |  55 ++++++---
 .../app/plugins/panel/state-timeline/types.ts |   4 +-
 .../panel/state-timeline/utils.test.ts        |  80 ++++++++++++-
 .../app/plugins/panel/state-timeline/utils.ts |  60 +++++++++-
 .../status-history/StatusHistoryPanel.tsx     |  11 +-
 .../plugins/panel/status-history/module.tsx   |   6 +-
 .../app/plugins/panel/status-history/types.ts |   5 +-
 23 files changed, 431 insertions(+), 131 deletions(-)
 create mode 100644 public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx

diff --git a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx
index 107f31345ea..df3b00b259b 100644
--- a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx
+++ b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx
@@ -10,7 +10,7 @@ import { useStyles } from '../../themes';
 export interface SeriesTableRowProps {
   color?: string;
   label?: string;
-  value: string | GraphSeriesValue;
+  value?: string | GraphSeriesValue;
   isActive?: boolean;
 }
 
@@ -46,7 +46,10 @@ const getSeriesTableRowStyles = (theme: GrafanaTheme) => {
   };
 };
 
-const SeriesTableRow: React.FC<SeriesTableRowProps> = ({ color, label, value, isActive }) => {
+/**
+ * @public
+ */
+export const SeriesTableRow: React.FC<SeriesTableRowProps> = ({ color, label, value, isActive }) => {
   const styles = useStyles(getSeriesTableRowStyles);
 
   return (
@@ -56,8 +59,8 @@ const SeriesTableRow: React.FC<SeriesTableRowProps> = ({ color, label, value, is
           <SeriesIcon color={color} className={styles.icon} />
         </div>
       )}
-      <div className={cx(styles.seriesTableCell, styles.label)}>{label}</div>
-      <div className={cx(styles.seriesTableCell, styles.value)}>{value}</div>
+      {label && <div className={cx(styles.seriesTableCell, styles.label)}>{label}</div>}
+      {value && <div className={cx(styles.seriesTableCell, styles.value)}>{value}</div>}
     </div>
   );
 };
diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
index ce444577d13..411693e39cb 100644
--- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
+++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
@@ -11,7 +11,7 @@ import { Dimensions2D, GrafanaTheme2 } from '@grafana/data';
 export interface VizTooltipContainerProps extends HTMLAttributes<HTMLDivElement> {
   position: { x: number; y: number };
   offset: { x: number; y: number };
-  children?: JSX.Element;
+  children?: React.ReactNode;
 }
 
 /**
diff --git a/packages/grafana-ui/src/components/VizTooltip/index.tsx b/packages/grafana-ui/src/components/VizTooltip/index.tsx
index 534e96f6ee9..fcdd3363b83 100644
--- a/packages/grafana-ui/src/components/VizTooltip/index.tsx
+++ b/packages/grafana-ui/src/components/VizTooltip/index.tsx
@@ -1,4 +1,4 @@
 export { VizTooltip, VizTooltipContentProps, VizTooltipProps, ActiveDimensions } from './VizTooltip';
 export { VizTooltipContainer, VizTooltipContainerProps } from './VizTooltipContainer';
-export { SeriesTable, SeriesTableProps, SeriesTableRowProps } from './SeriesTable';
+export { SeriesTable, SeriesTableRow, SeriesTableProps, SeriesTableRowProps } from './SeriesTable';
 export { TooltipDisplayMode, VizTooltipOptions } from './models.gen';
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index 21214a8eb10..f900ea39c38 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -79,6 +79,7 @@ export {
   VizTooltipOptions,
   TooltipDisplayMode,
   SeriesTableProps,
+  SeriesTableRow,
   SeriesTableRowProps,
 } from './VizTooltip';
 export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater';
@@ -239,6 +240,7 @@ export { PlotLegend } from './uPlot/PlotLegend';
 export * from './uPlot/geometries';
 export * from './uPlot/plugins';
 export { usePlotContext } from './uPlot/context';
+export { PlotTooltipInterpolator } from './uPlot/types';
 export { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG';
 export { TimeSeries } from './TimeSeries/TimeSeries';
 export { useGraphNGContext } from './GraphNG/hooks';
diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts
index e415abc5477..0c9f705c301 100644
--- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts
+++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts
@@ -1,10 +1,5 @@
 import uPlot, { Cursor, Band, Hooks, Select } from 'uplot';
 import { defaultsDeep } from 'lodash';
-import { PlotConfig, TooltipInterpolator } from '../types';
-import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
-import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
-import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
-import { AxisPlacement } from '../config';
 import {
   DataFrame,
   DefaultTimeZone,
@@ -14,6 +9,11 @@ import {
   TimeRange,
   TimeZone,
 } from '@grafana/data';
+import { PlotConfig, PlotTooltipInterpolator } from '../types';
+import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
+import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
+import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
+import { AxisPlacement } from '../config';
 import { pluginLog } from '../utils';
 import { getThresholdsDrawHook, UPlotThresholdOptions } from './UPlotThresholds';
 
@@ -36,7 +36,7 @@ export class UPlotConfigBuilder {
    * Custom handler for closest datapoint and series lookup. Technicaly returns uPlots setCursor hook
    * that sets tooltips state.
    */
-  tooltipInterpolator: TooltipInterpolator | undefined = undefined;
+  tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
 
   constructor(timeZone: TimeZone = DefaultTimeZone) {
     this.tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName;
@@ -131,7 +131,7 @@ export class UPlotConfigBuilder {
     this.bands.push(band);
   }
 
-  setTooltipInterpolator(interpolator: TooltipInterpolator) {
+  setTooltipInterpolator(interpolator: PlotTooltipInterpolator) {
     this.tooltipInterpolator = interpolator;
   }
 
diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx
index ce4c8ce6736..fef6f365faf 100644
--- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx
+++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx
@@ -4,6 +4,7 @@ import { usePlotContext } from '../context';
 import {
   CartesianCoords2D,
   DataFrame,
+  FALLBACK_COLOR,
   FieldType,
   formattedValueToString,
   getDisplayProcessor,
@@ -17,10 +18,13 @@ import { useTheme2 } from '../../../themes/ThemeContext';
 import uPlot from 'uplot';
 
 interface TooltipPluginProps {
-  mode?: TooltipDisplayMode;
   timeZone: TimeZone;
   data: DataFrame;
   config: UPlotConfigBuilder;
+  mode?: TooltipDisplayMode;
+  // Allows custom tooltip content rendering. Exposes aligned data frame with relevant indexes for data inspection
+  // Use field.state.origin indexes from alignedData frame field to get access to original data frame and field index.
+  renderTooltip?: (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => React.ReactNode;
 }
 
 const TOOLTIP_OFFSET = 10;
@@ -32,6 +36,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
   mode = TooltipDisplayMode.Single,
   timeZone,
   config,
+  renderTooltip,
   ...otherProps
 }) => {
   const theme = useTheme2();
@@ -39,6 +44,8 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
   const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
   const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
   const [coords, setCoords] = useState<CartesianCoords2D | null>(null);
+  const plotInstance = plotCtx.plot;
+
   const pluginId = `TooltipPlugin`;
 
   // Debug logs
@@ -109,8 +116,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
     }
   }, [plotCtx, config, setFocusedPointIdx, setFocusedSeriesIdx, setCoords]);
 
-  const plotInstance = plotCtx.plot;
-  if (!plotInstance || focusedPointIdx === null) {
+  if (!plotInstance || focusedPointIdx === null || mode === TooltipDisplayMode.None) {
     return null;
   }
 
@@ -120,61 +126,62 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
     return null;
   }
   const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
-  let tooltip = null;
+  let tooltip: React.ReactNode = null;
 
   const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text;
 
-  // when interacting with a point in single mode
-  if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
-    const field = otherProps.data.fields[focusedSeriesIdx];
-    const plotSeries = plotInstance.series;
+  if (!renderTooltip) {
+    // when interacting with a point in single mode
+    if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
+      const field = otherProps.data.fields[focusedSeriesIdx];
 
-    const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
-    const value = fieldFmt(field.values.get(focusedPointIdx));
+      const fieldFmt = field.display || getDisplayProcessor({ field, timeZone, theme });
+      const display = fieldFmt(field.values.get(focusedPointIdx));
 
-    tooltip = (
-      <SeriesTable
-        series={[
-          {
-            // TODO: align with uPlot typings
-            color: (plotSeries[focusedSeriesIdx!].stroke as any)(),
-            label: getFieldDisplayName(field, otherProps.data),
-            value: value ? formattedValueToString(value) : null,
-          },
-        ]}
-        timestamp={xVal}
-      />
-    );
-  }
-
-  if (mode === TooltipDisplayMode.Multi) {
-    let series: SeriesTableRowProps[] = [];
-    const plotSeries = plotInstance.series;
-
-    for (let i = 0; i < plotSeries.length; i++) {
-      const frame = otherProps.data;
-      const field = frame.fields[i];
-      if (
-        field === xField ||
-        field.type === FieldType.time ||
-        field.type !== FieldType.number ||
-        field.config.custom?.hideFrom?.tooltip
-      ) {
-        continue;
-      }
-
-      const value = field.display!(otherProps.data.fields[i].values.get(focusedPointIdx));
-
-      series.push({
-        // TODO: align with uPlot typings
-        color: (plotSeries[i].stroke as any)!(),
-        label: getFieldDisplayName(field, frame),
-        value: value ? formattedValueToString(value) : null,
-        isActive: focusedSeriesIdx === i,
-      });
+      tooltip = (
+        <SeriesTable
+          series={[
+            {
+              color: display.color || FALLBACK_COLOR,
+              label: getFieldDisplayName(field, otherProps.data),
+              value: display ? formattedValueToString(display) : null,
+            },
+          ]}
+          timestamp={xVal}
+        />
+      );
     }
 
-    tooltip = <SeriesTable series={series} timestamp={xVal} />;
+    if (mode === TooltipDisplayMode.Multi) {
+      let series: SeriesTableRowProps[] = [];
+      const plotSeries = plotInstance.series;
+
+      for (let i = 0; i < plotSeries.length; i++) {
+        const frame = otherProps.data;
+        const field = frame.fields[i];
+        if (
+          field === xField ||
+          field.type === FieldType.time ||
+          field.type !== FieldType.number ||
+          field.config.custom?.hideFrom?.tooltip
+        ) {
+          continue;
+        }
+
+        const display = field.display!(otherProps.data.fields[i].values.get(focusedPointIdx));
+
+        series.push({
+          color: display.color || FALLBACK_COLOR,
+          label: getFieldDisplayName(field, frame),
+          value: display ? formattedValueToString(display) : null,
+          isActive: focusedSeriesIdx === i,
+        });
+      }
+
+      tooltip = <SeriesTable series={series} timestamp={xVal} />;
+    }
+  } else {
+    tooltip = renderTooltip(otherProps.data, focusedSeriesIdx, focusedPointIdx);
   }
 
   return (
diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts
index 33d346cf291..7c4de95e6bb 100755
--- a/packages/grafana-ui/src/components/uPlot/types.ts
+++ b/packages/grafana-ui/src/components/uPlot/types.ts
@@ -28,7 +28,10 @@ export abstract class PlotConfigBuilder<P, T> {
   abstract getConfig(): T;
 }
 
-export type TooltipInterpolator = (
+/**
+ * @alpha
+ */
+export type PlotTooltipInterpolator = (
   updateActiveSeriesIdx: (sIdx: number | null) => void,
   updateActiveDatapointIdx: (dIdx: number | null) => void,
   updateTooltipPosition: (clear?: boolean) => void
diff --git a/packages/grafana-ui/src/options/builder/text.tsx b/packages/grafana-ui/src/options/builder/text.tsx
index 2f87e8d64a2..2523d45a6a6 100644
--- a/packages/grafana-ui/src/options/builder/text.tsx
+++ b/packages/grafana-ui/src/options/builder/text.tsx
@@ -1,17 +1,6 @@
 import { OptionsWithTextFormatting } from '../models.gen';
 import { PanelOptionsEditorBuilder } from '@grafana/data';
 
-/**
- * Explicit control for visualization text settings
- * @public
- **/
-export interface VizTextDisplayOptions {
-  /* Explicit title text size */
-  titleSize?: number;
-  /* Explicit value text size */
-  valueSize?: number;
-}
-
 /**
  * Adds common text control options to a visualization options
  * @param builder
diff --git a/packages/grafana-ui/src/options/builder/tooltip.tsx b/packages/grafana-ui/src/options/builder/tooltip.tsx
index 95ccdb2a40b..a5656da603c 100644
--- a/packages/grafana-ui/src/options/builder/tooltip.tsx
+++ b/packages/grafana-ui/src/options/builder/tooltip.tsx
@@ -1,7 +1,21 @@
 import { OptionsWithTooltip } from '../models.gen';
 import { PanelOptionsEditorBuilder } from '@grafana/data';
 
-export function addTooltipOptions<T extends OptionsWithTooltip>(builder: PanelOptionsEditorBuilder<T>) {
+export function addTooltipOptions<T extends OptionsWithTooltip>(
+  builder: PanelOptionsEditorBuilder<T>,
+  singleOnly = false
+) {
+  const options = singleOnly
+    ? [
+        { value: 'single', label: 'Single' },
+        { value: 'none', label: 'Hidden' },
+      ]
+    : [
+        { value: 'single', label: 'Single' },
+        { value: 'multi', label: 'All' },
+        { value: 'none', label: 'Hidden' },
+      ];
+
   builder.addRadio({
     path: 'tooltip.mode',
     name: 'Tooltip mode',
@@ -9,11 +23,7 @@ export function addTooltipOptions<T extends OptionsWithTooltip>(builder: PanelOp
     description: '',
     defaultValue: 'single',
     settings: {
-      options: [
-        { value: 'single', label: 'Single' },
-        { value: 'multi', label: 'All' },
-        { value: 'none', label: 'Hidden' },
-      ],
+      options,
     },
   });
 }
diff --git a/packages/grafana-ui/src/options/models.gen.ts b/packages/grafana-ui/src/options/models.gen.ts
index 45cd632b50a..5fbfb5241d8 100644
--- a/packages/grafana-ui/src/options/models.gen.ts
+++ b/packages/grafana-ui/src/options/models.gen.ts
@@ -1,7 +1,16 @@
 // TODO: this should be generated with cue
-
 import { VizLegendOptions, VizTooltipOptions } from '../components';
-import { VizTextDisplayOptions } from './builder/text';
+
+/**
+ * Explicit control for visualization text settings
+ * @public
+ **/
+export interface VizTextDisplayOptions {
+  /* Explicit title text size */
+  titleSize?: number;
+  /* Explicit value text size */
+  valueSize?: number;
+}
 
 /**
  * @public
diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts
index 44948594110..47000cdaeb4 100644
--- a/public/app/plugins/panel/barchart/bars.ts
+++ b/public/app/plugins/panel/barchart/bars.ts
@@ -1,11 +1,9 @@
 import uPlot, { Axis, Series } from 'uplot';
 import { pointWithin, Quadtree, Rect } from './quadtree';
 import { distribute, SPACE_BETWEEN } from './distribute';
-import { TooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
 import { BarValueVisibility, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config';
 import { CartesianCoords2D, GrafanaTheme2 } from '@grafana/data';
-import { calculateFontSize, measureText } from '@grafana/ui';
-import { VizTextDisplayOptions } from '@grafana/ui/src/options/builder';
+import { calculateFontSize, measureText, PlotTooltipInterpolator, VizTextDisplayOptions } from '@grafana/ui';
 
 const groupDistr = SPACE_BETWEEN;
 const barDistr = SPACE_BETWEEN;
@@ -311,7 +309,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
   };
 
   // handle hover interaction with quadtree probing
-  const interpolateBarChartTooltip: TooltipInterpolator = (
+  const interpolateTooltip: PlotTooltipInterpolator = (
     updateActiveSeriesIdx,
     updateActiveDatapointIdx,
     updateTooltipPosition
@@ -368,7 +366,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
     // hooks
     init,
     drawClear,
-    interpolateBarChartTooltip,
+    interpolateTooltip,
   };
 }
 
diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts
index 48d592fa0d7..9c39f0d849c 100644
--- a/public/app/plugins/panel/barchart/utils.ts
+++ b/public/app/plugins/panel/barchart/utils.ts
@@ -80,7 +80,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
   builder.addHook('drawClear', config.drawClear);
   builder.addHook('draw', config.draw);
 
-  builder.setTooltipInterpolator(config.interpolateBarChartTooltip);
+  builder.setTooltipInterpolator(config.interpolateTooltip);
 
   builder.addScale({
     scaleKey: 'x',
diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx
index e88c140e777..5212f7e0372 100755
--- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx
+++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx
@@ -1,9 +1,10 @@
-import React, { useMemo } from 'react';
-import { PanelProps } from '@grafana/data';
-import { useTheme2, ZoomPlugin } from '@grafana/ui';
+import React, { useCallback, useMemo } from 'react';
+import { DataFrame, PanelProps } from '@grafana/data';
+import { TooltipPlugin, useTheme2, ZoomPlugin } from '@grafana/ui';
 import { TimelineMode, TimelineOptions } from './types';
 import { TimelineChart } from './TimelineChart';
 import { prepareTimelineFields, prepareTimelineLegendItems } from './utils';
+import { StateTimelineTooltip } from './StateTimelineTooltip';
 
 interface TimelinePanelProps extends PanelProps<TimelineOptions> {}
 
@@ -32,6 +33,26 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
     theme,
   ]);
 
+  const renderCustomTooltip = useCallback(
+    (alignedData: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => {
+      // Not caring about multi mode in StateTimeline
+      if (seriesIdx === null || datapointIdx === null) {
+        return null;
+      }
+
+      return (
+        <StateTimelineTooltip
+          data={data.series}
+          alignedData={alignedData}
+          seriesIdx={seriesIdx}
+          datapointIdx={datapointIdx}
+          timeZone={timeZone}
+        />
+      );
+    },
+    [timeZone, data]
+  );
+
   if (!frames || warn) {
     return (
       <div className="panel-empty">
@@ -51,10 +72,22 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
       height={height}
       legendItems={legendItems}
       {...options}
-      // hardcoded
       mode={TimelineMode.Changes}
     >
-      {(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
+      {(config, alignedFrame) => {
+        return (
+          <>
+            <ZoomPlugin config={config} onZoom={onChangeTimeRange} />
+            <TooltipPlugin
+              data={alignedFrame}
+              config={config}
+              mode={options.tooltip.mode}
+              timeZone={timeZone}
+              renderTooltip={renderCustomTooltip}
+            />
+          </>
+        );
+      }}
     </TimelineChart>
   );
 };
diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx
new file mode 100644
index 00000000000..53eb510aa11
--- /dev/null
+++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import {
+  DataFrame,
+  FALLBACK_COLOR,
+  formattedValueToString,
+  getDisplayProcessor,
+  getFieldDisplayName,
+  getValueFormat,
+  TimeZone,
+} from '@grafana/data';
+import { SeriesTableRow, useTheme2 } from '@grafana/ui';
+import { findNextStateIndex } from './utils';
+
+interface StateTimelineTooltipProps {
+  data: DataFrame[];
+  alignedData: DataFrame;
+  seriesIdx: number;
+  datapointIdx: number;
+  timeZone: TimeZone;
+}
+
+export const StateTimelineTooltip: React.FC<StateTimelineTooltipProps> = ({
+  data,
+  alignedData,
+  seriesIdx,
+  datapointIdx,
+  timeZone,
+}) => {
+  const theme = useTheme2();
+
+  const xField = alignedData.fields[0];
+  const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme });
+
+  const field = alignedData.fields[seriesIdx!];
+  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;
+
+  const nextStateIdx = findNextStateIndex(field, datapointIdx!);
+  let nextStateTs;
+  if (nextStateIdx) {
+    nextStateTs = xField.values.get(nextStateIdx!);
+  }
+
+  const stateTs = xField.values.get(datapointIdx!);
+
+  let toFragment = null;
+  let durationFragment = null;
+
+  if (nextStateTs) {
+    const duration = nextStateTs && formattedValueToString(getValueFormat('dtdurationms')(nextStateTs - stateTs, 0));
+    durationFragment = (
+      <>
+        <br />
+        <strong>Duration:</strong> {duration}
+      </>
+    );
+    toFragment = (
+      <>
+        {' to'} <strong>{xFieldFmt(xField.values.get(nextStateIdx!)).text}</strong>
+      </>
+    );
+  }
+
+  return (
+    <div style={{ fontSize: theme.typography.bodySmall.fontSize }}>
+      {fieldDisplayName}
+      <br />
+      <SeriesTableRow label={display.text} color={display.color || FALLBACK_COLOR} isActive />
+      From <strong>{xFieldFmt(xField.values.get(datapointIdx!)).text}</strong>
+      {toFragment}
+      {durationFragment}
+    </div>
+  );
+};
+
+StateTimelineTooltip.displayName = 'StateTimelineTooltip';
diff --git a/public/app/plugins/panel/state-timeline/TimelineChart.tsx b/public/app/plugins/panel/state-timeline/TimelineChart.tsx
index bf073ad7701..ae1ef810cc4 100755
--- a/public/app/plugins/panel/state-timeline/TimelineChart.tsx
+++ b/public/app/plugins/panel/state-timeline/TimelineChart.tsx
@@ -13,12 +13,14 @@ import {
 } from '@grafana/ui';
 import { DataFrame, FieldType, TimeRange } from '@grafana/data';
 import { preparePlotConfigBuilder } from './utils';
-import { TimelineMode, TimelineValueAlignment } from './types';
+import { TimelineMode, TimelineOptions, TimelineValueAlignment } from './types';
 
 /**
  * @alpha
  */
-export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {
+export interface TimelineProps
+  extends TimelineOptions,
+    Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {
   mode: TimelineMode;
   rowHeight: number;
   showValue: BarValueVisibility;
diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx
index 64648ddcdc4..18ea3c45b6b 100755
--- a/public/app/plugins/panel/state-timeline/module.tsx
+++ b/public/app/plugins/panel/state-timeline/module.tsx
@@ -1,8 +1,7 @@
 import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
 import { StateTimelinePanel } from './StateTimelinePanel';
 import { TimelineOptions, TimelineFieldConfig, defaultPanelOptions, defaultTimelineFieldConfig } from './types';
-import { BarValueVisibility } from '@grafana/ui';
-import { addLegendOptions } from '@grafana/ui/src/options/builder';
+import { BarValueVisibility, commonOptionsBuilder } from '@grafana/ui';
 import { timelinePanelChangedHandler } from './migrations';
 
 export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(StateTimelinePanel)
@@ -84,5 +83,6 @@ export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(Stat
         defaultValue: defaultPanelOptions.rowHeight,
       });
 
-    addLegendOptions(builder, false);
+    commonOptionsBuilder.addLegendOptions(builder, false);
+    commonOptionsBuilder.addTooltipOptions(builder, true);
   });
diff --git a/public/app/plugins/panel/state-timeline/timeline.ts b/public/app/plugins/panel/state-timeline/timeline.ts
index df163655729..ead53e042af 100644
--- a/public/app/plugins/panel/state-timeline/timeline.ts
+++ b/public/app/plugins/panel/state-timeline/timeline.ts
@@ -1,6 +1,6 @@
-import uPlot, { Series, Cursor } from 'uplot';
+import uPlot, { Cursor, Series } from 'uplot';
 import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG';
-import { Quadtree, Rect, pointWithin } from 'app/plugins/panel/barchart/quadtree';
+import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree';
 import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute';
 import { TimelineFieldConfig, TimelineMode, TimelineValueAlignment } from './types';
 import { GrafanaTheme2, TimeRange } from '@grafana/data';
@@ -47,8 +47,8 @@ export interface TimelineCoreOptions {
   getTimeRange: () => TimeRange;
   formatValue?: (seriesIdx: number, value: any) => string;
   getFieldConfig: (seriesIdx: number) => TimelineFieldConfig;
-  onHover?: (seriesIdx: number, valueIdx: number) => void;
-  onLeave?: (seriesIdx: number, valueIdx: number) => void;
+  onHover?: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
+  onLeave?: () => void;
 }
 
 /**
@@ -69,8 +69,8 @@ export function getConfig(opts: TimelineCoreOptions) {
     getTimeRange,
     getValueColor,
     getFieldConfig,
-    // onHover,
-    // onLeave,
+    onHover,
+    onLeave,
   } = opts;
 
   let qt: Quadtree;
@@ -382,16 +382,24 @@ export function getConfig(opts: TimelineCoreOptions) {
     hovered[i] = o;
   }
 
+  let hoveredAtCursor: Rect | null = null;
+
   function hoverMulti(cx: number, cy: number) {
+    let foundAtCursor: Rect | null = null;
+
     for (let i = 0; i < numSeries; i++) {
       let found: Rect | null = null;
 
       if (cx >= 0) {
-        cy = yMids[i];
+        let cy2 = yMids[i];
 
-        qt.get(cx, cy, 1, 1, (o) => {
-          if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
+        qt.get(cx, cy2, 1, 1, (o) => {
+          if (pointWithin(cx, cy2, o.x, o.y, o.x + o.w, o.y + o.h)) {
             found = o;
+
+            if (Math.abs(cy - cy2) <= o.h / 2) {
+              foundAtCursor = o;
+            }
           }
         });
       }
@@ -404,21 +412,40 @@ export function getConfig(opts: TimelineCoreOptions) {
         setHoverMark(i, null);
       }
     }
+
+    if (foundAtCursor) {
+      if (foundAtCursor !== hoveredAtCursor) {
+        hoveredAtCursor = foundAtCursor;
+        // @ts-ignore
+        onHover && onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
+      }
+    } else if (hoveredAtCursor) {
+      hoveredAtCursor = null;
+      onLeave && onLeave();
+    }
   }
 
   function hoverOne(cx: number, cy: number) {
-    let found: Rect | null = null;
+    let foundAtCursor: Rect | null = null;
 
     qt.get(cx, cy, 1, 1, (o) => {
       if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
-        found = o;
+        foundAtCursor = o;
       }
     });
 
-    if (found) {
-      setHoverMark(0, found);
-    } else if (hovered[0] != null) {
+    if (foundAtCursor) {
+      setHoverMark(0, foundAtCursor);
+
+      if (foundAtCursor !== hoveredAtCursor) {
+        hoveredAtCursor = foundAtCursor;
+        // @ts-ignore
+        onHover && onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
+      }
+    } else if (hoveredAtCursor) {
       setHoverMark(0, null);
+      hoveredAtCursor = null;
+      onLeave && onLeave();
     }
   }
 
diff --git a/public/app/plugins/panel/state-timeline/types.ts b/public/app/plugins/panel/state-timeline/types.ts
index 4397da50a35..6364dd4a778 100644
--- a/public/app/plugins/panel/state-timeline/types.ts
+++ b/public/app/plugins/panel/state-timeline/types.ts
@@ -1,9 +1,9 @@
-import { HideableFieldConfig, BarValueVisibility, OptionsWithLegend } from '@grafana/ui';
+import { HideableFieldConfig, BarValueVisibility, OptionsWithLegend, OptionsWithTooltip } from '@grafana/ui';
 
 /**
  * @alpha
  */
-export interface TimelineOptions extends OptionsWithLegend {
+export interface TimelineOptions extends OptionsWithLegend, OptionsWithTooltip {
   mode: TimelineMode; // not in the saved model!
 
   showValue: BarValueVisibility;
diff --git a/public/app/plugins/panel/state-timeline/utils.test.ts b/public/app/plugins/panel/state-timeline/utils.test.ts
index fac08d5fed7..0124bd1e60a 100644
--- a/public/app/plugins/panel/state-timeline/utils.test.ts
+++ b/public/app/plugins/panel/state-timeline/utils.test.ts
@@ -1,5 +1,5 @@
-import { FieldType, toDataFrame } from '@grafana/data';
-import { prepareTimelineFields } from './utils';
+import { ArrayVector, FieldType, toDataFrame } from '@grafana/data';
+import { findNextStateIndex, prepareTimelineFields } from './utils';
 
 describe('prepare timeline graph', () => {
   it('errors with no time fields', () => {
@@ -58,3 +58,79 @@ describe('prepare timeline graph', () => {
     `);
   });
 });
+
+describe('findNextStateIndex', () => {
+  it('handles leading datapoint index', () => {
+    const field = {
+      name: 'time',
+      type: FieldType.number,
+      values: new ArrayVector([1, undefined, undefined, 2, undefined, undefined]),
+    } as any;
+    const result = findNextStateIndex(field, 0);
+    expect(result).toEqual(3);
+  });
+
+  it('handles trailing datapoint index', () => {
+    const field = {
+      name: 'time',
+      type: FieldType.number,
+      values: new ArrayVector([1, undefined, undefined, 2, undefined, 3]),
+    } as any;
+    const result = findNextStateIndex(field, 5);
+    expect(result).toEqual(null);
+  });
+
+  it('handles trailing undefined', () => {
+    const field = {
+      name: 'time',
+      type: FieldType.number,
+      values: new ArrayVector([1, undefined, undefined, 2, undefined, 3, undefined]),
+    } as any;
+    const result = findNextStateIndex(field, 5);
+    expect(result).toEqual(null);
+  });
+
+  it('handles datapoint index inside range', () => {
+    const field = {
+      name: 'time',
+      type: FieldType.number,
+      values: new ArrayVector([
+        1,
+        undefined,
+        undefined,
+        3,
+        undefined,
+        undefined,
+        undefined,
+        undefined,
+        2,
+        undefined,
+        undefined,
+      ]),
+    } as any;
+    const result = findNextStateIndex(field, 3);
+    expect(result).toEqual(8);
+  });
+
+  describe('single data points', () => {
+    const field = {
+      name: 'time',
+      type: FieldType.number,
+      values: new ArrayVector([1, 3, 2]),
+    } as any;
+
+    test('leading', () => {
+      const result = findNextStateIndex(field, 0);
+      expect(result).toEqual(1);
+    });
+    test('trailing', () => {
+      const result = findNextStateIndex(field, 2);
+      expect(result).toEqual(null);
+    });
+
+    test('inside', () => {
+      const result = findNextStateIndex(field, 1);
+      expect(result).toEqual(2);
+    });
+  });
+});
diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts
index a261bbd4e88..f13926898a7 100644
--- a/public/app/plugins/panel/state-timeline/utils.ts
+++ b/public/app/plugins/panel/state-timeline/utils.ts
@@ -26,6 +26,7 @@ import {
 import { TimelineCoreOptions, getConfig } from './timeline';
 import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config';
 import { TimelineFieldConfig, TimelineOptions } from './types';
+import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
 
 const defaultConfig: TimelineFieldConfig = {
   lineWidth: 0,
@@ -95,21 +96,46 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
     getTimeRange,
     // hardcoded formatter for state values
     formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
-    // TODO: unimplemeted for now
-    onHover: (seriesIdx: number, valueIdx: number) => {
-      console.log('hover', { seriesIdx, valueIdx });
+    onHover: (seriesIndex, valueIndex) => {
+      hoveredSeriesIdx = seriesIndex;
+      hoveredDataIdx = valueIndex;
     },
-    onLeave: (seriesIdx: number, valueIdx: number) => {
-      console.log('leave', { seriesIdx, valueIdx });
+    onLeave: () => {
+      hoveredSeriesIdx = null;
+      hoveredDataIdx = null;
     },
   };
 
+  let hoveredSeriesIdx: number | null = null;
+  let hoveredDataIdx: number | null = null;
+
   const coreConfig = getConfig(opts);
 
   builder.addHook('init', coreConfig.init);
   builder.addHook('drawClear', coreConfig.drawClear);
   builder.addHook('setCursor', coreConfig.setCursor);
 
+  // in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook
+  // which fires after the above setCursor hook, so can take advantage of hoveringOver
+  // already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor
+  const interpolateTooltip: PlotTooltipInterpolator = (
+    updateActiveSeriesIdx,
+    updateActiveDatapointIdx,
+    updateTooltipPosition
+  ) => (u: uPlot) => {
+    if (hoveredSeriesIdx != null) {
+      // @ts-ignore
+      updateActiveSeriesIdx(hoveredSeriesIdx);
+      // @ts-ignore
+      updateActiveDatapointIdx(hoveredDataIdx);
+      updateTooltipPosition();
+    } else {
+      updateTooltipPosition(true);
+    }
+  };
+
+  builder.setTooltipInterpolator(interpolateTooltip);
+
   builder.setCursor(coreConfig.cursor);
 
   builder.addScale({
@@ -366,3 +392,27 @@ function allNonTimeFields(frames: DataFrame[]): Field[] {
   }
   return fields;
 }
+
+export function findNextStateIndex(field: Field, datapointIdx: number) {
+  let end;
+  let rightPointer = datapointIdx + 1;
+
+  if (rightPointer === field.values.length) {
+    return null;
+  }
+
+  while (end === undefined) {
+    if (rightPointer === field.values.length) {
+      return null;
+    }
+    const rightValue = field.values.get(rightPointer);
+
+    if (rightValue !== undefined) {
+      end = rightPointer;
+    } else {
+      rightPointer++;
+    }
+  }
+
+  return end;
+}
diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
index 90b5a2f163c..8d2906ff0d4 100755
--- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
+++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
@@ -1,6 +1,6 @@
 import React, { useMemo } from 'react';
 import { PanelProps } from '@grafana/data';
-import { useTheme2, ZoomPlugin } from '@grafana/ui';
+import { TooltipPlugin, useTheme2, ZoomPlugin } from '@grafana/ui';
 import { StatusPanelOptions } from './types';
 import { TimelineChart } from '../state-timeline/TimelineChart';
 import { TimelineMode } from '../state-timeline/types';
@@ -64,7 +64,14 @@ export const StatusHistoryPanel: React.FC<TimelinePanelProps> = ({
       // hardcoded
       mode={TimelineMode.Samples}
     >
-      {(config) => <ZoomPlugin config={config} onZoom={onChangeTimeRange} />}
+      {(config, alignedFrame) => {
+        return (
+          <>
+            <ZoomPlugin config={config} onZoom={onChangeTimeRange} />
+            <TooltipPlugin data={alignedFrame} config={config} mode={options.tooltip.mode} timeZone={timeZone} />
+          </>
+        );
+      }}
     </TimelineChart>
   );
 };
diff --git a/public/app/plugins/panel/status-history/module.tsx b/public/app/plugins/panel/status-history/module.tsx
index 8d6893023d9..e83d1239372 100755
--- a/public/app/plugins/panel/status-history/module.tsx
+++ b/public/app/plugins/panel/status-history/module.tsx
@@ -1,8 +1,7 @@
 import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
 import { StatusHistoryPanel } from './StatusHistoryPanel';
 import { StatusPanelOptions, StatusFieldConfig, defaultStatusFieldConfig } from './types';
-import { BarValueVisibility } from '@grafana/ui';
-import { addLegendOptions } from '@grafana/ui/src/options/builder';
+import { BarValueVisibility, commonOptionsBuilder } from '@grafana/ui';
 
 export const plugin = new PanelPlugin<StatusPanelOptions, StatusFieldConfig>(StatusHistoryPanel)
   .useFieldConfig({
@@ -75,5 +74,6 @@ export const plugin = new PanelPlugin<StatusPanelOptions, StatusFieldConfig>(Sta
         },
       });
 
-    addLegendOptions(builder, false);
+    commonOptionsBuilder.addLegendOptions(builder, false);
+    commonOptionsBuilder.addTooltipOptions(builder, true);
   });
diff --git a/public/app/plugins/panel/status-history/types.ts b/public/app/plugins/panel/status-history/types.ts
index 8c06f64bc79..b9840d9fe31 100644
--- a/public/app/plugins/panel/status-history/types.ts
+++ b/public/app/plugins/panel/status-history/types.ts
@@ -1,10 +1,9 @@
-import { VizLegendOptions, HideableFieldConfig, BarValueVisibility } from '@grafana/ui';
+import { HideableFieldConfig, BarValueVisibility, OptionsWithTooltip, OptionsWithLegend } from '@grafana/ui';
 
 /**
  * @alpha
  */
-export interface StatusPanelOptions {
-  legend: VizLegendOptions;
+export interface StatusPanelOptions extends OptionsWithTooltip, OptionsWithLegend {
   showValue: BarValueVisibility;
   rowHeight: number;
   colWidth?: number;