mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 08:05:43 -06:00
Frontend: Make datalinks work with status history and state timeline (#50226)
* fix datalinks for state and status panels * Add close button on tooltip * fix links from all series displayed in tooltip * Allow annotations creation, tweak UI * Nits * Remove unused property * fix returns made from review * setupUPlotConfig renamed to addTooltipSupport Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> Co-authored-by: Victor Marin <victor.marin@grafana.com>
This commit is contained in:
parent
c56aae6f63
commit
2054414d37
@ -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';
|
||||
|
||||
|
@ -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<SetStateAction<number | null>>;
|
||||
setFocusedPointIdx: Dispatch<SetStateAction<number | null>>;
|
||||
setCoords: Dispatch<SetStateAction<CartesianCoords2D | null>>;
|
||||
setCoords: Dispatch<SetStateAction<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>>;
|
||||
setHover: Dispatch<SetStateAction<HoverEvent | undefined>>;
|
||||
isToolTipOpen: MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
// 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
|
@ -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<Props> = ({
|
||||
id,
|
||||
}) => {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
const { eventBus } = usePanelContext();
|
||||
|
||||
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
|
||||
const [coords, setCoords] = useState<CartesianCoords2D | null>(null);
|
||||
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
|
||||
@ -105,6 +101,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
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<Props> = ({
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayCloseButton && (
|
||||
<>
|
||||
<CloseButton onClick={onCloseToolTip} />
|
||||
<div className={styles.closeButtonSpacer} />
|
||||
</>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DataHoverView
|
||||
data={info.aligned}
|
||||
@ -275,7 +285,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
>
|
||||
{(config) => {
|
||||
if (oldConfig.current !== config) {
|
||||
oldConfig.current = setupConfig({
|
||||
oldConfig.current = addTooltipSupport({
|
||||
config,
|
||||
onUPlotClick,
|
||||
setFocusedSeriesIdx,
|
||||
@ -294,7 +304,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
<Portal>
|
||||
{hover && coords && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.x, y: coords.y }}
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
@ -307,9 +317,3 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
</GraphNG>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
closeButtonSpacer: css`
|
||||
margin-bottom: 15px;
|
||||
`,
|
||||
});
|
||||
|
@ -200,10 +200,23 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{shouldDisplayCloseButton && (
|
||||
<>
|
||||
<CloseButton onClick={onCloseToolTip} />
|
||||
<div className={styles.closeButtonSpacer} />
|
||||
</>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<HeatmapHoverView data={info} hover={hover} showHistogram={options.tooltip.yHistogram} />
|
||||
</VizTooltipContainer>
|
||||
@ -214,9 +227,6 @@ export const HeatmapPanel: React.FC<HeatmapPanelProps> = ({
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
closeButtonSpacer: css`
|
||||
margin-bottom: 15px;
|
||||
`,
|
||||
colorScaleWrapper: css`
|
||||
margin-left: 25px;
|
||||
padding: 10px 0;
|
||||
|
@ -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<TimelineOptions> {}
|
||||
|
||||
/**
|
||||
@ -31,7 +34,28 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onChangeTimeRange,
|
||||
}) => {
|
||||
const theme = useTheme2();
|
||||
const { sync, canAddAnnotations } = usePanelContext();
|
||||
|
||||
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
|
||||
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(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<TimelinePanelProps> = ({
|
||||
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<TimelinePanelProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StateTimelineTooltip
|
||||
data={data}
|
||||
alignedData={alignedData}
|
||||
seriesIdx={seriesIdx}
|
||||
datapointIdx={datapointIdx}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<>
|
||||
{shouldDisplayCloseButton && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<StateTimelineTooltip
|
||||
data={data}
|
||||
alignedData={alignedData}
|
||||
seriesIdx={seriesIdx}
|
||||
datapointIdx={datapointIdx}
|
||||
timeZone={timeZone}
|
||||
onAnnotationAdd={onAnnotationAdd}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[timeZone, frames]
|
||||
[timeZone, frames, shouldDisplayCloseButton]
|
||||
);
|
||||
|
||||
if (!frames || warn) {
|
||||
@ -115,53 +161,60 @@ export const StateTimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
mode={TimelineMode.Changes}
|
||||
>
|
||||
{(config, alignedFrame) => {
|
||||
if (oldConfig.current !== config) {
|
||||
oldConfig.current = addTooltipSupport({
|
||||
config,
|
||||
onUPlotClick,
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
setCoords,
|
||||
setHover,
|
||||
isToolTipOpen,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
|
||||
<TooltipPlugin
|
||||
data={alignedFrame}
|
||||
sync={sync}
|
||||
config={config}
|
||||
mode={options.tooltip.mode}
|
||||
timeZone={timeZone}
|
||||
renderTooltip={renderCustomTooltip}
|
||||
/>
|
||||
|
||||
<OutsideRangePlugin config={config} onChangeTimeRange={onChangeTimeRange} />
|
||||
|
||||
{data.annotations && (
|
||||
<AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
|
||||
)}
|
||||
|
||||
{enableAnnotationCreation && (
|
||||
{enableAnnotationCreation ? (
|
||||
<AnnotationEditorPlugin data={alignedFrame} timeZone={timeZone} config={config}>
|
||||
{({ startAnnotating }) => {
|
||||
return (
|
||||
<ContextMenuPlugin
|
||||
data={alignedFrame}
|
||||
config={config}
|
||||
timeZone={timeZone}
|
||||
replaceVariables={replaceVariables}
|
||||
defaultItems={[
|
||||
{
|
||||
items: [
|
||||
{
|
||||
label: 'Add annotation',
|
||||
ariaLabel: 'Add annotation',
|
||||
icon: 'comment-alt',
|
||||
onClick: (e, p) => {
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
startAnnotating({ coords: p.coords });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Portal>
|
||||
{hover && coords && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx, () => {
|
||||
startAnnotating({ coords: { plotCanvas: coords.canvas, viewport: coords.viewport } });
|
||||
onCloseToolTip();
|
||||
})}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}}
|
||||
</AnnotationEditorPlugin>
|
||||
) : (
|
||||
<Portal>
|
||||
{hover && coords && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -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<StateTimelineTooltipProps> = ({
|
||||
@ -19,14 +28,34 @@ export const StateTimelineTooltip: React.FC<StateTimelineTooltipProps> = ({
|
||||
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<LinkModel<Field>> = [];
|
||||
const linkLookup = new Set<string>();
|
||||
|
||||
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<StateTimelineTooltipProps> = ({
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
<div
|
||||
style={{
|
||||
margin: theme.spacing(1, -1, -1, -1),
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}}
|
||||
>
|
||||
{onAnnotationAdd && <MenuItem label={'Add annotation'} icon={'comment-alt'} onClick={onAnnotationAdd} />}
|
||||
{links.length > 0 &&
|
||||
links.map((link, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
icon={'external-link-alt'}
|
||||
target={link.target}
|
||||
label={link.title}
|
||||
url={link.href}
|
||||
onClick={link.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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<StatusPanelOptions> {}
|
||||
|
||||
/**
|
||||
@ -27,6 +32,28 @@ export const StatusHistoryPanel: React.FC<TimelinePanelProps> = ({
|
||||
}) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
|
||||
const isToolTipOpen = useRef<boolean>(false);
|
||||
|
||||
const [hover, setHover] = useState<HoverEvent | undefined>(undefined);
|
||||
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; canvas: CartesianCoords2D } | null>(null);
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(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<TimelinePanelProps> = ({
|
||||
[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 && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CloseButton
|
||||
onClick={onCloseToolTip}
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<StatusHistoryTooltip
|
||||
data={data}
|
||||
alignedData={alignedData}
|
||||
seriesIdx={seriesIdx}
|
||||
datapointIdx={datapointIdx}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[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<TimelinePanelProps> = ({
|
||||
mode={TimelineMode.Samples}
|
||||
>
|
||||
{(config, alignedFrame) => {
|
||||
if (oldConfig.current !== config) {
|
||||
oldConfig.current = addTooltipSupport({
|
||||
config,
|
||||
onUPlotClick,
|
||||
setFocusedSeriesIdx,
|
||||
setFocusedPointIdx,
|
||||
setCoords,
|
||||
setHover,
|
||||
isToolTipOpen,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
|
||||
<TooltipPlugin data={alignedFrame} config={config} mode={options.tooltip.mode} timeZone={timeZone} />
|
||||
<Portal>
|
||||
{hover && coords && (
|
||||
<VizTooltipContainer
|
||||
position={{ x: coords.viewport.x, y: coords.viewport.y }}
|
||||
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
|
||||
allowPointerEvents={isToolTipOpen.current}
|
||||
>
|
||||
{renderCustomTooltip(alignedFrame, focusedSeriesIdx, focusedPointIdx)}
|
||||
</VizTooltipContainer>
|
||||
)}
|
||||
</Portal>
|
||||
<OutsideRangePlugin config={config} onChangeTimeRange={onChangeTimeRange} />
|
||||
</>
|
||||
);
|
||||
|
@ -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<StatusHistoryTooltipProps> = ({
|
||||
data,
|
||||
alignedData,
|
||||
seriesIdx,
|
||||
datapointIdx,
|
||||
timeZone,
|
||||
}) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
if (!data || datapointIdx == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const field = alignedData.fields[seriesIdx!];
|
||||
|
||||
const links: Array<LinkModel<Field>> = [];
|
||||
const linkLookup = new Set<string>();
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ fontSize: theme.typography.bodySmall.fontSize }}>
|
||||
{fieldDisplayName}
|
||||
<br />
|
||||
<SeriesTableRow label={display.text} color={display.color || FALLBACK_COLOR} isActive />
|
||||
</div>
|
||||
{links.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
margin: theme.spacing(1, -1, -1, -1),
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
icon={'external-link-alt'}
|
||||
target={link.target}
|
||||
label={link.title}
|
||||
url={link.href}
|
||||
onClick={link.onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StatusHistoryTooltip.displayName = 'StatusHistoryTooltip';
|
Loading…
Reference in New Issue
Block a user