VizTooltip: Replace ExemplarHoverView with VizTooltip components (#109369)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Adela Almasan
2025-08-11 20:35:36 +00:00
committed by GitHub
co-authored by Leon Sorokin
parent cff39476f3
commit 238961d3ea
12 changed files with 204 additions and 410 deletions
@@ -1,9 +1,9 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useLayoutEffect, useMemo, useRef, useState, ReactNode } from 'react';
import * as React from 'react';
import { useMountedState } from 'react-use';
import uPlot from 'uplot';
import { DataFrame, DataFrameFieldIndex } from '@grafana/data';
import { DataFrame } from '@grafana/data';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
@@ -14,11 +14,8 @@ interface EventsCanvasProps {
id: string;
config: UPlotConfigBuilder;
events: DataFrame[];
renderEventMarker: (dataFrame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => React.ReactNode;
mapEventToXYCoords: (
dataFrame: DataFrame,
dataFrameFieldIndex: DataFrameFieldIndex
) => { x: number; y: number } | undefined;
renderEventMarker: (dataFrame: DataFrame, rowIndex: number) => ReactNode;
mapEventToXYCoords: (dataFrame: DataFrame, rowIndex: number) => { x: number; y: number } | undefined;
}
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
@@ -50,14 +47,15 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords
for (let i = 0; i < events.length; i++) {
const frame = events[i];
for (let j = 0; j < frame.length; j++) {
const coords = mapEventToXYCoords(frame, { fieldIndex: j, frameIndex: i });
const coords = mapEventToXYCoords(frame, j);
if (!coords) {
continue;
}
markers.push(
<Marker {...coords} key={`${id}-marker-${i}-${j}`}>
{renderEventMarker(frame, { fieldIndex: j, frameIndex: i })}
{renderEventMarker(frame, j)}
</Marker>
);
}
@@ -1,12 +1,12 @@
import { render, screen } from '@testing-library/react';
import { ArrayDataFrame } from '@grafana/data';
import { arrayToDataFrame } from '@grafana/data';
import { DataHoverView } from './DataHoverView';
describe('DataHoverView component', () => {
it('should default to multi mode if mode is null or undefined', () => {
const data = new ArrayDataFrame([{ foo: 'bar' }]);
const data = arrayToDataFrame([{ foo: 'bar' }]);
render(<DataHoverView data={data} rowIndex={0} />);
expect(screen.queryByText('bar')).toBeInTheDocument();
@@ -1,116 +1,61 @@
import { css } from '@emotion/css';
import {
arrayUtils,
DataFrame,
Field,
formattedValueToString,
getFieldDisplayName,
GrafanaTheme2,
LinkModel,
} from '@grafana/data';
import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2, LinkModel } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SortOrder, TooltipDisplayMode } from '@grafana/schema';
import { TextLink, useStyles2 } from '@grafana/ui';
import { renderValue } from 'app/plugins/panel/geomap/utils/uiUtils';
import { ExemplarHoverView } from './ExemplarHoverView';
import { getDataLinks } from 'app/plugins/panel/status-history/utils';
export interface Props {
data?: DataFrame; // source data
rowIndex?: number | null; // the hover row
columnIndex?: number | null; // the hover column
sortOrder?: SortOrder;
mode?: TooltipDisplayMode | null;
header?: string;
padding?: number;
maxHeight?: number;
}
export interface DisplayValue {
name: string;
value: unknown;
valueString: string;
highlight: boolean;
}
export function getDisplayValuesAndLinks(
data: DataFrame,
rowIndex: number,
columnIndex?: number | null,
sortOrder?: SortOrder,
mode?: TooltipDisplayMode | null
) {
const fields = data.fields;
const hoveredField = columnIndex != null ? fields[columnIndex] : null;
export function getDisplayValuesAndLinks(data: DataFrame, rowIndex: number, columnIndex?: number) {
const visibleFields = data.fields.filter(
(f, i) => !Boolean(f.config.custom?.hideFrom?.tooltip) && (columnIndex == null || i === columnIndex)
);
const visibleFields = fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip));
const traceIDField = visibleFields.find((field) => field.name === 'traceID') || fields[0];
const orderedVisibleFields = [];
// Only include traceID if it's visible and put it in front.
if (visibleFields.filter((field) => traceIDField === field).length > 0) {
orderedVisibleFields.push(traceIDField);
}
orderedVisibleFields.push(...visibleFields.filter((field) => traceIDField !== field));
if (orderedVisibleFields.length === 0) {
if (visibleFields.length === 0) {
return null;
}
const displayValues: DisplayValue[] = [];
const links: Array<LinkModel<Field>> = [];
const linkLookup = new Set<string>();
for (const field of orderedVisibleFields) {
if (mode === TooltipDisplayMode.Single && field !== hoveredField) {
continue;
}
for (const field of visibleFields) {
const value = field.values[rowIndex];
const fieldDisplay = field.display ? field.display(value) : { text: `${value}`, numeric: +value };
if (field.getLinks) {
field.getLinks({ calculatedValue: fieldDisplay, valueRowIndex: rowIndex }).forEach((link) => {
const key = `${link.title}/${link.href}`;
if (!linkLookup.has(key)) {
links.push(link);
linkLookup.add(key);
}
});
}
links.push(...getDataLinks(field, rowIndex));
displayValues.push({
name: getFieldDisplayName(field, data),
value,
valueString: formattedValueToString(fieldDisplay),
highlight: field === hoveredField,
});
}
if (sortOrder && sortOrder !== SortOrder.None) {
displayValues.sort((a, b) => arrayUtils.sortValues(sortOrder)(a.value, b.value));
}
return { displayValues, links };
}
export const DataHoverView = ({
data,
rowIndex,
columnIndex,
sortOrder,
mode,
header,
padding = 0,
maxHeight,
}: Props) => {
export const DataHoverView = ({ data, rowIndex, header, padding = 0 }: Props) => {
const styles = useStyles2(getStyles, padding);
if (!data || rowIndex == null) {
return null;
}
const dispValuesAndLinks = getDisplayValuesAndLinks(data, rowIndex, columnIndex, sortOrder, mode);
const dispValuesAndLinks = getDisplayValuesAndLinks(data, rowIndex);
if (dispValuesAndLinks == null) {
return null;
@@ -118,10 +63,6 @@ export const DataHoverView = ({
const { displayValues, links } = dispValuesAndLinks;
if (header === 'Exemplar') {
return <ExemplarHoverView displayValues={displayValues} links={links} header={header} maxHeight={maxHeight} />;
}
return (
<div className={styles.wrapper}>
{header && (
@@ -154,6 +95,7 @@ export const DataHoverView = ({
</div>
);
};
const getStyles = (theme: GrafanaTheme2, padding = 0) => {
return {
wrapper: css({
@@ -1,117 +0,0 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { DataLinkButton, useStyles2 } from '@grafana/ui';
import { VizTooltipRow } from '@grafana/ui/internal';
import { renderValue } from 'app/plugins/panel/geomap/utils/uiUtils';
import { DisplayValue } from './DataHoverView';
export interface Props {
displayValues: DisplayValue[];
links?: LinkModel[];
header?: string;
maxHeight?: number;
}
export const ExemplarHoverView = ({ displayValues, links, header = 'Exemplar', maxHeight }: Props) => {
const styles = useStyles2(getStyles, 0, maxHeight);
const time = displayValues.find((val) => val.name === 'Time');
displayValues = displayValues.filter((val) => val.name !== 'Time'); // time?
return (
<div className={styles.exemplarWrapper}>
<div className={styles.exemplarHeader}>
<span className={styles.title}>{header}</span>
{time && <span className={styles.time}>{renderValue(time.valueString)}</span>}
</div>
<div className={styles.exemplarContent}>
{displayValues.map((displayValue, i) => {
return (
<VizTooltipRow
key={i}
label={displayValue.name}
value={renderValue(displayValue.valueString)}
justify={'space-between'}
isPinned={false}
/>
);
})}
</div>
{links && links.length > 0 && (
<div className={styles.exemplarFooter}>
{links.map((link, i) => (
<DataLinkButton link={link} key={i} buttonProps={{ size: 'md' }} />
))}
</div>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2, padding = 0, maxHeight?: number) => {
return {
exemplarWrapper: css({
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 4,
whiteSpace: 'pre',
borderRadius: theme.shape.radius.default,
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
}),
exemplarHeader: css({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(0.5),
color: theme.colors.text.secondary,
padding: theme.spacing(1),
}),
time: css({
color: theme.colors.text.primary,
}),
exemplarContent: css({
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 4,
borderTop: `1px solid ${theme.colors.border.medium}`,
padding: theme.spacing(1),
overflowY: 'auto',
maxHeight: maxHeight,
}),
exemplarFooter: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(1),
borderTop: `1px solid ${theme.colors.border.medium}`,
gap: 4,
}),
linkButton: css({
width: 'fit-content',
}),
label: css({
color: theme.colors.text.secondary,
fontWeight: 400,
textOverflow: 'ellipsis',
overflow: 'hidden',
marginRight: theme.spacing(0.5),
}),
value: css({
fontWeight: 500,
textOverflow: 'ellipsis',
overflow: 'hidden',
}),
title: css({
fontWeight: theme.typography.fontWeightMedium,
overflow: 'hidden',
display: 'inline-block',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
flexGrow: 1,
}),
};
};
@@ -0,0 +1,39 @@
import { LinkModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import {
VizTooltipContent,
VizTooltipFooter,
VizTooltipItem,
VizTooltipHeader,
VizTooltipWrapper,
} from '@grafana/ui/internal';
export interface Props {
items: VizTooltipItem[];
links?: LinkModel[];
isPinned: boolean;
maxHeight?: number;
}
export const ExemplarTooltip = ({ items, links, isPinned, maxHeight }: Props) => {
const timeItem = items.find((val) => val.label === 'Time');
return (
<VizTooltipWrapper>
<VizTooltipHeader
item={{
label: t('exemplar-tooltip-header', 'Exemplar'),
value: timeItem?.value ?? '',
}}
isPinned={isPinned}
/>
<VizTooltipContent
items={items.filter((item) => item !== timeItem)}
isPinned={isPinned}
maxHeight={maxHeight}
scrollable={maxHeight != null}
/>
<VizTooltipFooter dataLinks={links ?? []} />
</VizTooltipWrapper>
);
};
@@ -324,7 +324,13 @@ export const CandlestickPanel = ({
/>
<OutsideRangePlugin config={uplotConfig} onChangeTimeRange={onChangeTimeRange} />
{data.annotations && (
<ExemplarsPlugin config={uplotConfig} exemplars={data.annotations} timeZone={timeZone} />
<ExemplarsPlugin
config={uplotConfig}
exemplars={data.annotations}
timeZone={timeZone}
maxHeight={options.tooltip.maxHeight}
maxWidth={options.tooltip.maxWidth}
/>
)}
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
<ThresholdControlsPlugin
@@ -1,26 +0,0 @@
import { CSSProperties } from 'react';
import * as React from 'react';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
export function ExemplarModalHeader(props: { onClick: () => void; style?: React.CSSProperties }) {
const defaultStyle: CSSProperties = {
position: 'relative',
top: 'auto',
right: 'auto',
marginRight: 0,
};
return (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end',
paddingBottom: '6px',
}}
>
<CloseButton onClick={props.onClick} style={props.style ?? defaultStyle} />
</div>
);
}
@@ -27,7 +27,8 @@ import {
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView';
import { getDisplayValuesAndLinks } from 'app/features/visualization/data-hover/DataHoverView';
import { ExemplarTooltip } from 'app/features/visualization/data-hover/ExemplarTooltip';
import { getDataLinks, getFieldActions } from '../status-history/utils';
import { isTooltipScrollable } from '../timeseries/utils';
@@ -54,13 +55,23 @@ interface HeatmapTooltipProps {
export const HeatmapTooltip = (props: HeatmapTooltipProps) => {
if (props.seriesIdx === 2) {
const dispValuesAndLinks = getDisplayValuesAndLinks(props.dataRef.current!.exemplars!, props.dataIdxs[2]!);
if (dispValuesAndLinks == null) {
return null;
}
const { displayValues, links } = dispValuesAndLinks;
return (
<DataHoverView
data={props.dataRef.current!.exemplars}
rowIndex={props.dataIdxs[2]}
header={'Exemplar'}
padding={8}
<ExemplarTooltip
items={displayValues.map((dispVal) => ({
label: dispVal.name,
value: dispVal.valueString,
}))}
links={links}
maxHeight={props.maxHeight}
isPinned={props.isPinned}
/>
);
}
@@ -160,6 +160,7 @@ export const TimeSeriesPanel = ({
exemplars={data.annotations}
timeZone={timeZone}
maxHeight={options.tooltip.maxHeight}
maxWidth={options.tooltip.maxWidth}
/>
)}
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
@@ -9,40 +9,45 @@ import {
useHover,
useInteractions,
} from '@floating-ui/react';
import { CSSProperties, useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { DataFrame, DataFrameFieldIndex, Field, formattedValueToString, GrafanaTheme2, LinkModel } from '@grafana/data';
import { DataFrame, Field, formattedValueToString, GrafanaTheme2, LinkModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TimeZone } from '@grafana/schema';
import { Portal, UPlotConfigBuilder, useStyles2 } from '@grafana/ui';
import { DisplayValue } from 'app/features/visualization/data-hover/DataHoverView';
import { ExemplarHoverView } from 'app/features/visualization/data-hover/ExemplarHoverView';
import { VizTooltipItem } from '@grafana/ui/internal';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { ExemplarTooltip } from 'app/features/visualization/data-hover/ExemplarTooltip';
import { ExemplarModalHeader } from '../../heatmap/ExemplarModalHeader';
import { getDataLinks } from '../../status-history/utils';
interface ExemplarMarkerProps {
timeZone: TimeZone;
dataFrame: DataFrame;
dataFrameFieldIndex: DataFrameFieldIndex;
frameIndex: number;
rowIndex: number;
config: UPlotConfigBuilder;
exemplarColor?: string;
clickedExemplarFieldIndex: DataFrameFieldIndex | undefined;
setClickedExemplarFieldIndex: React.Dispatch<DataFrameFieldIndex | undefined>;
clickedRowIndex: number | undefined;
setClickedRowIndex: React.Dispatch<number | undefined>;
maxHeight?: number;
maxWidth?: number;
}
export const ExemplarMarker = ({
timeZone,
dataFrame,
dataFrameFieldIndex,
frameIndex,
rowIndex,
config,
exemplarColor,
clickedExemplarFieldIndex,
setClickedExemplarFieldIndex,
clickedRowIndex,
setClickedRowIndex,
maxHeight,
maxWidth,
}: ExemplarMarkerProps) => {
const styles = useStyles2(getExemplarMarkerStyles);
const styles = useStyles2(getExemplarMarkerStyles, maxWidth);
const [isOpen, setIsOpen] = useState(false);
const [isLocked, setIsLocked] = useState(false);
@@ -69,24 +74,19 @@ export const ExemplarMarker = ({
const dismiss = useDismiss(context);
const hover = useHover(context, {
handleClose: safePolygon(),
enabled: clickedExemplarFieldIndex === undefined,
enabled: clickedRowIndex === undefined,
});
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, hover]);
useEffect(() => {
if (
!(
clickedExemplarFieldIndex?.fieldIndex === dataFrameFieldIndex.fieldIndex &&
clickedExemplarFieldIndex?.frameIndex === dataFrameFieldIndex.frameIndex
)
) {
if (clickedRowIndex !== rowIndex) {
setIsLocked(false);
}
}, [clickedExemplarFieldIndex, dataFrameFieldIndex]);
}, [clickedRowIndex, rowIndex]);
const getSymbol = () => {
const symbols = [
return (
<rect
fill={exemplarColor}
key="diamond"
@@ -94,27 +94,38 @@ export const ExemplarMarker = ({
width="4.78985"
height="4.78985"
transform="rotate(45 3.38672 0)"
/>,
<path
fill={exemplarColor}
key="x"
d="M1.94444 3.49988L0 5.44432L1.55552 6.99984L3.49996 5.05539L5.4444 6.99983L6.99992 5.44431L5.05548 3.49988L6.99983 1.55552L5.44431 0L3.49996 1.94436L1.5556 0L8.42584e-05 1.55552L1.94444 3.49988Z"
/>,
<path fill={exemplarColor} key="triangle" d="M4 0L7.4641 6H0.535898L4 0Z" />,
<rect fill={exemplarColor} key="rectangle" width="5" height="5" />,
<path
fill={exemplarColor}
key="pentagon"
d="M3 0.5L5.85317 2.57295L4.76336 5.92705H1.23664L0.146831 2.57295L3 0.5Z"
/>,
<path
fill={exemplarColor}
key="plus"
d="m2.35672,4.2425l0,2.357l1.88558,0l0,-2.357l2.3572,0l0,-1.88558l-2.3572,0l0,-2.35692l-1.88558,0l0,2.35692l-2.35672,0l0,1.88558l2.35672,0z"
/>,
];
/>
);
return symbols[dataFrameFieldIndex.frameIndex % symbols.length];
// const symbols = [
// <rect
// fill={exemplarColor}
// key="diamond"
// x="3.38672"
// width="4.78985"
// height="4.78985"
// transform="rotate(45 3.38672 0)"
// />,
// <path
// fill={exemplarColor}
// key="x"
// d="M1.94444 3.49988L0 5.44432L1.55552 6.99984L3.49996 5.05539L5.4444 6.99983L6.99992 5.44431L5.05548 3.49988L6.99983 1.55552L5.44431 0L3.49996 1.94436L1.5556 0L8.42584e-05 1.55552L1.94444 3.49988Z"
// />,
// <path fill={exemplarColor} key="triangle" d="M4 0L7.4641 6H0.535898L4 0Z" />,
// <rect fill={exemplarColor} key="rectangle" width="5" height="5" />,
// <path
// fill={exemplarColor}
// key="pentagon"
// d="M3 0.5L5.85317 2.57295L4.76336 5.92705H1.23664L0.146831 2.57295L3 0.5Z"
// />,
// <path
// fill={exemplarColor}
// key="plus"
// d="m2.35672,4.2425l0,2.357l1.88558,0l0,-2.357l2.3572,0l0,-1.88558l-2.3572,0l0,-2.35692l-1.88558,0l0,2.35692l-2.35672,0l0,1.88558l2.35672,0z"
// />,
// ];
// return symbols[dataFrameFieldIndex.frameIndex % symbols.length];
};
const lockExemplarModal = () => {
@@ -122,70 +133,56 @@ export const ExemplarMarker = ({
};
const renderMarker = useCallback(() => {
//Put fields with links on the top
const fieldsWithLinks =
dataFrame.fields.filter((field) => field.config.links?.length && field.config.links?.length > 0) || [];
const orderedDataFrameFields = [
...fieldsWithLinks,
...dataFrame.fields.filter((field) => !fieldsWithLinks.includes(field)),
];
const onClose = () => {
setIsLocked(false);
setIsOpen(false);
setClickedExemplarFieldIndex(undefined);
setClickedRowIndex(undefined);
};
let displayValues: DisplayValue[] = [];
let links: LinkModel[] | undefined = [];
orderedDataFrameFields.map((field: Field, i) => {
const value = field.values[dataFrameFieldIndex.fieldIndex];
let items: VizTooltipItem[] = [];
let links: LinkModel[] = [];
if (field.config.links?.length) {
links?.push(...(field.getLinks?.({ valueRowIndex: dataFrameFieldIndex.fieldIndex }) || []));
}
dataFrame.fields.forEach((field: Field) => {
const value = field.values[rowIndex];
const fieldDisplay = field.display ? field.display(value) : { text: `${value}`, numeric: +value };
links.push(...getDataLinks(field, rowIndex));
displayValues.push({
name: field.name,
value,
valueString: formattedValueToString(fieldDisplay),
highlight: false,
const fieldDisplay = field.display?.(value) ?? { text: `${value}`, numeric: +value };
items.push({
label: field.state?.displayName ?? field.name,
value: formattedValueToString(fieldDisplay),
});
});
const exemplarHeaderCustomStyle: CSSProperties = {
position: 'relative',
top: '35px',
right: '5px',
marginRight: 0,
};
return (
<div className={styles.tooltip} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
{isLocked && <ExemplarModalHeader onClick={onClose} style={exemplarHeaderCustomStyle} />}
<ExemplarHoverView displayValues={displayValues} links={links} maxHeight={maxHeight} />
<div
className={cx(styles.tooltipWrapper, isLocked && styles.pinned)}
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
>
{isLocked && <CloseButton onClick={onClose} />}
<ExemplarTooltip items={items} links={links} isPinned={isLocked} maxHeight={maxHeight} />
</div>
);
}, [
dataFrame.fields,
dataFrameFieldIndex,
rowIndex,
styles,
isLocked,
setClickedExemplarFieldIndex,
setClickedRowIndex,
floatingStyles,
getFloatingProps,
refs.setFloating,
maxHeight,
]);
const seriesColor = config
.getSeries()
.find((s) => s.props.dataFrameFieldIndex?.frameIndex === dataFrameFieldIndex.frameIndex)?.props.lineColor;
const seriesColor = config.getSeries().find((s) => s.props.dataFrameFieldIndex?.frameIndex === frameIndex)
?.props.lineColor;
const onExemplarClick = () => {
setClickedExemplarFieldIndex(dataFrameFieldIndex);
setClickedRowIndex(rowIndex);
lockExemplarModal();
};
@@ -220,12 +217,7 @@ export const ExemplarMarker = ({
);
};
const getExemplarMarkerStyles = (theme: GrafanaTheme2) => {
const bg = theme.isDark ? theme.v1.palette.dark2 : theme.v1.palette.white;
const headerBg = theme.isDark ? theme.v1.palette.dark9 : theme.v1.palette.gray5;
const shadowColor = theme.isDark ? theme.v1.palette.black : theme.v1.palette.white;
const tableBgOdd = theme.isDark ? theme.v1.palette.dark3 : theme.v1.palette.gray6;
const getExemplarMarkerStyles = (theme: GrafanaTheme2, maxWidth: number | undefined) => {
return {
markerWrapper: css({
padding: '0 4px 4px 4px',
@@ -249,66 +241,6 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme2) => {
borderBottom: `4px solid ${theme.v1.palette.red}`,
pointerEvents: 'none',
}),
wrapper: css({
background: bg,
border: `1px solid ${headerBg}`,
borderRadius: theme.shape.borderRadius(2),
boxShadow: `0 0 20px ${shadowColor}`,
padding: theme.spacing(1),
}),
exemplarsTable: css({
width: '100%',
'tr td': {
padding: '5px 10px',
whiteSpace: 'nowrap',
borderBottom: `4px solid ${theme.components.panel.background}`,
},
tr: {
backgroundColor: theme.colors.background.primary,
'&:nth-child(even)': {
backgroundColor: tableBgOdd,
},
},
}),
valueWrapper: css({
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
columnGap: theme.spacing(1),
'> span': {
flexGrow: 0,
},
'> *': {
flex: '1 1',
alignSelf: 'center',
},
}),
tooltip: css({
background: 'none',
padding: 0,
overflowY: 'auto',
maxHeight: '95vh',
boxShadow: theme.shadows.z2,
}),
header: css({
background: headerBg,
padding: '6px 10px',
display: 'flex',
}),
title: css({
fontWeight: theme.typography.fontWeightMedium,
paddingRight: theme.spacing(2),
overflow: 'hidden',
display: 'inline-block',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
flexGrow: 1,
}),
body: css({
fontWeight: theme.typography.fontWeightMedium,
borderRadius: theme.shape.borderRadius(2),
overflow: 'hidden',
}),
marble: css({
display: 'block',
opacity: 0.5,
@@ -321,5 +253,18 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme2) => {
opacity: 1,
filter: 'drop-shadow(0 0 8px rgba(0, 0, 0, 0.5))',
}),
tooltipWrapper: css({
background: theme.colors.background.elevated,
maxWidth: maxWidth ?? 'none',
whiteSpace: 'pre',
borderRadius: theme.shape.radius.default,
position: 'fixed',
border: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z2,
userSelect: 'text',
}),
pinned: css({
boxShadow: theme.shadows.z3,
}),
};
};
@@ -1,14 +1,7 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
import uPlot from 'uplot';
import {
DataFrame,
DataFrameFieldIndex,
Labels,
TIME_SERIES_TIME_FIELD_NAME,
TIME_SERIES_VALUE_FIELD_NAME,
TimeZone,
} from '@grafana/data';
import { DataFrame, Labels, TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME, TimeZone } from '@grafana/data';
import { FIXED_UNIT, EventsCanvas, UPlotConfigBuilder } from '@grafana/ui';
import { ExemplarMarker } from './ExemplarMarker';
@@ -19,12 +12,20 @@ interface ExemplarsPluginProps {
timeZone: TimeZone;
visibleSeries?: VisibleExemplarLabels;
maxHeight?: number;
maxWidth?: number;
}
export const ExemplarsPlugin = ({ exemplars, timeZone, config, visibleSeries, maxHeight }: ExemplarsPluginProps) => {
export const ExemplarsPlugin = ({
exemplars,
timeZone,
config,
visibleSeries,
maxHeight,
maxWidth,
}: ExemplarsPluginProps) => {
const plotInstance = useRef<uPlot>();
const [lockedExemplarFieldIndex, setLockedExemplarFieldIndex] = useState<DataFrameFieldIndex | undefined>();
const [lockedExemplarRowIndex, setLockedExemplarRowIndex] = useState<number | undefined>();
useLayoutEffect(() => {
config.addHook('init', (u) => {
@@ -32,7 +33,7 @@ export const ExemplarsPlugin = ({ exemplars, timeZone, config, visibleSeries, ma
});
}, [config]);
const mapExemplarToXYCoords = useCallback((dataFrame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
const mapExemplarToXYCoords = useCallback((dataFrame: DataFrame, rowIndex: number) => {
const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);
@@ -47,7 +48,7 @@ export const ExemplarsPlugin = ({ exemplars, timeZone, config, visibleSeries, ma
const yMin = plotInstance.current.scales[yScale].min;
const yMax = plotInstance.current.scales[yScale].max;
let y = value.values[dataFrameFieldIndex.fieldIndex];
let y = value.values[rowIndex];
// To not to show exemplars outside of the graph we set the y value to min if it is smaller and max if it is bigger than the size of the graph
if (yMin != null && y < yMin) {
y = yMin;
@@ -58,18 +59,17 @@ export const ExemplarsPlugin = ({ exemplars, timeZone, config, visibleSeries, ma
}
return {
x: plotInstance.current.valToPos(time.values[dataFrameFieldIndex.fieldIndex], 'x'),
x: plotInstance.current.valToPos(time.values[rowIndex], 'x'),
y: plotInstance.current.valToPos(y, yScale),
};
}, []);
const renderMarker = useCallback(
(dataFrame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
const showMarker =
visibleSeries !== undefined ? showExemplarMarker(visibleSeries, dataFrame, dataFrameFieldIndex) : true;
(dataFrame: DataFrame, rowIndex: number): ReactNode => {
const showMarker = visibleSeries !== undefined ? showExemplarMarker(visibleSeries, dataFrame, rowIndex) : true;
const markerColor =
visibleSeries !== undefined ? getExemplarColor(dataFrame, dataFrameFieldIndex, visibleSeries) : undefined;
visibleSeries !== undefined ? getExemplarColor(dataFrame, rowIndex, visibleSeries) : undefined;
if (!showMarker) {
return <></>;
@@ -77,18 +77,20 @@ export const ExemplarsPlugin = ({ exemplars, timeZone, config, visibleSeries, ma
return (
<ExemplarMarker
setClickedExemplarFieldIndex={setLockedExemplarFieldIndex}
clickedExemplarFieldIndex={lockedExemplarFieldIndex}
setClickedRowIndex={setLockedExemplarRowIndex}
clickedRowIndex={lockedExemplarRowIndex}
timeZone={timeZone}
dataFrame={dataFrame}
dataFrameFieldIndex={dataFrameFieldIndex}
frameIndex={0}
rowIndex={rowIndex}
config={config}
exemplarColor={markerColor}
maxHeight={maxHeight}
maxWidth={maxWidth}
/>
);
},
[config, timeZone, visibleSeries, setLockedExemplarFieldIndex, lockedExemplarFieldIndex, maxHeight]
[visibleSeries, lockedExemplarRowIndex, timeZone, config, maxHeight, maxWidth]
);
return (
@@ -138,11 +140,7 @@ interface LabelWithExemplarUIData {
/**
* Get color of active series in legend
*/
const getExemplarColor = (
dataFrame: DataFrame,
dataFrameFieldIndex: DataFrameFieldIndex,
visibleLabels: VisibleExemplarLabels
) => {
const getExemplarColor = (dataFrame: DataFrame, rowIndex: number, visibleLabels: VisibleExemplarLabels) => {
let exemplarColor;
visibleLabels.labels.some((visibleLabel) => {
const labelKeys = Object.keys(visibleLabel.labels);
@@ -151,7 +149,7 @@ const getExemplarColor = (
});
if (fields.length) {
const hasMatch = fields.every((field, index, fields) => {
const value = field.values[dataFrameFieldIndex.fieldIndex];
const value = field.values[rowIndex];
return visibleLabel.labels[field.name] === value;
});
@@ -168,11 +166,7 @@ const getExemplarColor = (
/**
* Determine if the current exemplar marker is filtered by what series are selected in the legend UI
*/
const showExemplarMarker = (
visibleSeries: VisibleExemplarLabels,
dataFrame: DataFrame,
dataFrameFieldIndex: DataFrameFieldIndex
) => {
const showExemplarMarker = (visibleSeries: VisibleExemplarLabels, dataFrame: DataFrame, rowIndex: number) => {
let showMarker = false;
// If all series are visible, don't filter any exemplars
if (visibleSeries.labels.length === visibleSeries.totalSeriesCount) {
@@ -196,7 +190,7 @@ const showExemplarMarker = (
showMarker = visibleSeries.labels.some((series) => {
return Object.keys(series.labels).every((label) => {
const value = series.labels[label];
return fields.find((field) => field.values[dataFrameFieldIndex.fieldIndex] === value);
return fields.find((field) => field.values[rowIndex] === value);
});
});
}
+1
View File
@@ -6806,6 +6806,7 @@
}
}
},
"exemplar-tooltip-header": "Exemplar",
"explore": {
"accordian-logs": {
"events": "Events",