Compare commits

...

5 Commits

Author SHA1 Message Date
Adela Almasan f77f87bde5 lint 2026-01-08 10:51:19 -06:00
Adela Almasan 18e873fb59 test for getSeriesAndRest 2026-01-07 15:35:18 -06:00
Adela Almasan d99c5c2185 add hollow style for leading/trailing color placement 2026-01-07 12:00:08 -06:00
Adela Almasan 195bc409ef Merge branch 'main' into leeoniya/statetimeline-tooltip-hideFrom-fix 2026-01-05 10:42:57 -06:00
Leon Sorokin 6a855ef6e4 StateTimeline: Fix series hidden from tooltip when hideFrom.viz: true 2025-10-30 15:33:50 -05:00
9 changed files with 171 additions and 41 deletions
@@ -212,6 +212,7 @@ export const VizTooltipRow = ({
colorIndicator={colorIndicator} colorIndicator={colorIndicator}
position={ColorIndicatorPosition.Trailing} position={ColorIndicatorPosition.Trailing}
lineStyle={lineStyle} lineStyle={lineStyle}
isHollow={isHiddenFromViz}
/> />
)} )}
</div> </div>
@@ -148,18 +148,26 @@ export const getContentItems = (
_restFields?.forEach((field) => { _restFields?.forEach((field) => {
if (!field.config.custom?.hideFrom?.tooltip) { if (!field.config.custom?.hideFrom?.tooltip) {
const { colorIndicator, colorPlacement } = getIndicatorAndPlacement(field); const valueIdx = dataIdxs[seriesIdx ?? 0];
const display = field.display!(field.values[dataIdxs[0]!]);
rows.push({ if (valueIdx != null) {
label: field.state?.displayName ?? field.name, const value = field.values[valueIdx];
value: formattedValueToString(display),
color: FALLBACK_COLOR, if (value != null) {
colorIndicator, const display = field.display!(value);
colorPlacement, const { colorIndicator, colorPlacement } = getIndicatorAndPlacement(field);
lineStyle: field.config.custom?.lineStyle,
isHiddenFromViz: true, rows.push({
}); label: field.state?.displayName ?? field.name,
value: formattedValueToString(display),
color: FALLBACK_COLOR,
colorIndicator,
colorPlacement,
lineStyle: field.config.custom?.lineStyle,
isHiddenFromViz: true,
});
}
}
} }
}); });
@@ -53,14 +53,6 @@ export interface GraphNGProps extends Themeable2 {
dataLinkPostProcessor?: DataLinkPostProcessor; dataLinkPostProcessor?: DataLinkPostProcessor;
cursorSync?: DashboardCursorSync; cursorSync?: DashboardCursorSync;
// Remove fields that are hidden from the visualization before rendering
// The fields will still be available for other things like data links
// this is a temporary hack that only works when:
// 1. renderLegend (above) does not render <PlotLegend>
// 2. does not have legend series toggle
// 3. passes through all fields required for link/action gen (including those with hideFrom.viz)
omitHideFromViz?: boolean;
/** /**
* needed for propsToDiff to re-init the plot & config * needed for propsToDiff to re-init the plot & config
* this is a generic approach to plot re-init, without having to specify which panel-level options * this is a generic approach to plot re-init, without having to specify which panel-level options
@@ -187,15 +179,6 @@ export class GraphNG extends Component<GraphNGProps, GraphNGState> {
}; };
} }
if (props.omitHideFromViz) {
const nonHiddenFields = alignedFrameFinal.fields.filter((field) => field.config.custom?.hideFrom?.viz !== true);
alignedFrameFinal = {
...alignedFrameFinal,
fields: nonHiddenFields,
length: nonHiddenFields.length,
};
}
let config = this.state?.config; let config = this.state?.config;
if (withConfig) { if (withConfig) {
@@ -7,7 +7,7 @@ import { UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafan
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG'; import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
import { getXAxisConfig } from '../TimeSeries/utils'; import { getXAxisConfig } from '../TimeSeries/utils';
import { preparePlotConfigBuilder, TimelineMode } from './utils'; import { getSeriesAndRest, preparePlotConfigBuilder, TimelineMode } from './utils';
/** /**
* @alpha * @alpha
@@ -56,8 +56,10 @@ export const TimelineChart = (props: TimelineProps) => {
const prepConfig = useCallback( const prepConfig = useCallback(
(alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { seriesFrame } = getSeriesAndRest(alignedFrame);
return preparePlotConfigBuilder({ return preparePlotConfigBuilder({
frame: alignedFrame, frame: seriesFrame,
getTimeRange, getTimeRange,
allFrames: frames, allFrames: frames,
...props, ...props,
@@ -66,7 +68,7 @@ export const TimelineChart = (props: TimelineProps) => {
timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], timeZones: Array.isArray(timeZone) ? timeZone : [timeZone],
// When there is only one row, use the full space // When there is only one row, use the full space
rowHeight: alignedFrame.fields.length > 2 ? rowHeight : 1, rowHeight: seriesFrame.fields.length > 2 ? rowHeight : 1,
getValueColor: getValueColor, getValueColor: getValueColor,
hoverMulti: tooltip?.mode === TooltipDisplayMode.Multi, hoverMulti: tooltip?.mode === TooltipDisplayMode.Multi,
@@ -105,7 +107,6 @@ export const TimelineChart = (props: TimelineProps) => {
prepConfig={prepConfig} prepConfig={prepConfig}
propsToDiff={propsToDiff} propsToDiff={propsToDiff}
renderLegend={renderLegend} renderLegend={renderLegend}
omitHideFromViz={true}
/> />
); );
}; };
@@ -18,6 +18,7 @@ import { preparePlotFrame } from '../GraphNG/utils';
import { import {
findNextStateIndex, findNextStateIndex,
fmtDuration, fmtDuration,
getSeriesAndRest,
getThresholdItems, getThresholdItems,
hasSpecialMappedValue, hasSpecialMappedValue,
makeFramePerSeries, makeFramePerSeries,
@@ -563,3 +564,95 @@ describe('hasSpecialMappedValue', () => {
expect(hasSpecialMappedValue(field, valueMatch)).toEqual(expected); expect(hasSpecialMappedValue(field, valueMatch)).toEqual(expected);
}); });
}); });
describe('getSeriesAndRest', () => {
it('should return all fields as series when none are hidden', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{ name: 'value1', type: FieldType.number, values: [10, 20, 30] },
{ name: 'value2', type: FieldType.string, values: ['a', 'b', 'c'] },
],
});
const result = getSeriesAndRest(frame);
expect(result.seriesFrame.fields).toHaveLength(3);
expect(result.restFields).toHaveLength(0);
expect(result.seriesFrame.fields.map((f) => f.name)).toEqual(['time', 'value1', 'value2']);
});
it('should separate hidden fields from visible fields', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
{
name: 'visible1',
type: FieldType.number,
values: [10, 20, 30],
config: { custom: { hideFrom: { viz: false, legend: true } } },
},
{
name: 'hidden1',
type: FieldType.string,
values: ['a', 'b', 'c'],
config: { custom: { hideFrom: { viz: true } } },
},
{
name: 'visible2',
type: FieldType.number,
values: [100, 200, 300],
},
{
name: 'hidden2',
type: FieldType.string,
values: ['x', 'y', 'z'],
config: { custom: { hideFrom: { viz: true, tooltip: false } } },
},
],
});
const result = getSeriesAndRest(frame);
expect(result.seriesFrame.fields).toHaveLength(3);
expect(result.restFields).toHaveLength(2);
expect(result.seriesFrame.fields.map((f) => f.name)).toEqual(['time', 'visible1', 'visible2']);
expect(result.restFields.map((f) => f.name)).toEqual(['hidden1', 'hidden2']);
});
it('should handle all fields being hidden', () => {
const frame = toDataFrame({
fields: [
{
name: 'time',
type: FieldType.time,
values: [1, 2, 3],
config: { custom: { hideFrom: { viz: true } } },
},
{
name: 'value1',
type: FieldType.number,
values: [10, 20, 30],
config: { custom: { hideFrom: { viz: true } } },
},
],
});
const result = getSeriesAndRest(frame);
expect(result.seriesFrame.fields).toHaveLength(0);
expect(result.restFields).toHaveLength(2);
expect(result.restFields.map((f) => f.name)).toEqual(['time', 'value1']);
});
it('should handle empty frame', () => {
const frame = toDataFrame({
fields: [],
});
const result = getSeriesAndRest(frame);
expect(result.seriesFrame.fields).toHaveLength(0);
expect(result.restFields).toHaveLength(0);
});
});
@@ -756,3 +756,26 @@ export function fmtDuration(milliSeconds: number): string {
: '0' : '0'
).trim(); ).trim();
} }
export function getSeriesAndRest(alignedFrame: DataFrame) {
const seriesFields: Field[] = [];
const restFields: Field[] = [];
alignedFrame.fields.forEach((field) => {
if (field.config.custom?.hideFrom?.viz) {
restFields.push(field);
} else {
seriesFields.push(field);
}
});
const seriesFrame: DataFrame = {
...alignedFrame,
fields: seriesFields,
};
return {
seriesFrame: seriesFrame,
restFields: restFields,
};
}
@@ -14,6 +14,7 @@ import {
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
import { import {
getSeriesAndRest,
prepareTimelineFields, prepareTimelineFields,
prepareTimelineLegendItems, prepareTimelineLegendItems,
TimelineMode, TimelineMode,
@@ -98,10 +99,13 @@ export const StateTimelinePanel = ({
annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined} annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined}
> >
{(builder, alignedFrame) => { {(builder, alignedFrame) => {
// TODO: refactor frame prep not to do this here, should be memod at panel level once GraphNG is dissolved
const { seriesFrame, restFields } = getSeriesAndRest(alignedFrame);
return ( return (
<> <>
{cursorSync !== DashboardCursorSync.Off && ( {cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} /> <EventBusPlugin config={builder} eventBus={eventBus} frame={seriesFrame} />
)} )}
<XAxisInteractionAreaPlugin config={builder} queryZoom={onChangeTimeRange} /> <XAxisInteractionAreaPlugin config={builder} queryZoom={onChangeTimeRange} />
{options.tooltip.mode !== TooltipDisplayMode.None && ( {options.tooltip.mode !== TooltipDisplayMode.None && (
@@ -114,7 +118,7 @@ export const StateTimelinePanel = ({
syncMode={cursorSync} syncMode={cursorSync}
syncScope={eventsScope} syncScope={eventsScope}
getDataLinks={(seriesIdx, dataIdx) => getDataLinks={(seriesIdx, dataIdx) =>
alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] seriesFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
} }
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
if (enableAnnotationCreation && timeRange2 != null) { if (enableAnnotationCreation && timeRange2 != null) {
@@ -132,7 +136,7 @@ export const StateTimelinePanel = ({
return ( return (
<StateTimelineTooltip <StateTimelineTooltip
series={alignedFrame} series={seriesFrame}
dataIdxs={dataIdxs} dataIdxs={dataIdxs}
seriesIdx={seriesIdx} seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode} mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
@@ -145,13 +149,14 @@ export const StateTimelinePanel = ({
replaceVariables={replaceVariables} replaceVariables={replaceVariables}
dataLinks={dataLinks} dataLinks={dataLinks}
canExecuteActions={userCanExecuteActions} canExecuteActions={userCanExecuteActions}
_rest={restFields}
/> />
); );
}} }}
maxWidth={options.tooltip.maxWidth} maxWidth={options.tooltip.maxWidth}
/> />
)} )}
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && ( {seriesFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
<AnnotationsPlugin2 <AnnotationsPlugin2
replaceVariables={replaceVariables} replaceVariables={replaceVariables}
multiLane={options.annotations?.multiLane} multiLane={options.annotations?.multiLane}
@@ -35,6 +35,7 @@ export const StateTimelineTooltip = ({
maxHeight, maxHeight,
replaceVariables, replaceVariables,
dataLinks, dataLinks,
_rest,
}: StateTimelineTooltipProps) => { }: StateTimelineTooltipProps) => {
const pluginContext = usePluginContext(); const pluginContext = usePluginContext();
const xField = series.fields[0]; const xField = series.fields[0];
@@ -45,7 +46,17 @@ export const StateTimelineTooltip = ({
mode = isPinned ? TooltipDisplayMode.Single : mode; mode = isPinned ? TooltipDisplayMode.Single : mode;
const contentItems = getContentItems(series.fields, xField, dataIdxs, seriesIdx, mode, sortOrder); const contentItems = getContentItems(
series.fields,
xField,
dataIdxs,
seriesIdx,
mode,
sortOrder,
undefined,
undefined,
_rest
);
let endTime = null; let endTime = null;
// append duration in single mode // append duration in single mode
@@ -15,6 +15,7 @@ import {
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
import { import {
getSeriesAndRest,
prepareTimelineFields, prepareTimelineFields,
prepareTimelineLegendItems, prepareTimelineLegendItems,
TimelineMode, TimelineMode,
@@ -113,10 +114,13 @@ export const StatusHistoryPanel = ({
annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined} annotationLanes={options.annotations?.multiLane ? getXAnnotationFrames(data.annotations).length : undefined}
> >
{(builder, alignedFrame) => { {(builder, alignedFrame) => {
// TODO: refactor frame prep not to do this here, should be memod at panel level once GraphNG is dissolved
const { seriesFrame, restFields } = getSeriesAndRest(alignedFrame);
return ( return (
<> <>
{cursorSync !== DashboardCursorSync.Off && ( {cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} /> <EventBusPlugin config={builder} eventBus={eventBus} frame={seriesFrame} />
)} )}
<XAxisInteractionAreaPlugin config={builder} queryZoom={onChangeTimeRange} /> <XAxisInteractionAreaPlugin config={builder} queryZoom={onChangeTimeRange} />
{options.tooltip.mode !== TooltipDisplayMode.None && ( {options.tooltip.mode !== TooltipDisplayMode.None && (
@@ -129,7 +133,7 @@ export const StatusHistoryPanel = ({
syncMode={cursorSync} syncMode={cursorSync}
syncScope={eventsScope} syncScope={eventsScope}
getDataLinks={(seriesIdx, dataIdx) => getDataLinks={(seriesIdx, dataIdx) =>
alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] seriesFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? []
} }
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
if (enableAnnotationCreation && timeRange2 != null) { if (enableAnnotationCreation && timeRange2 != null) {
@@ -147,7 +151,7 @@ export const StatusHistoryPanel = ({
return ( return (
<StateTimelineTooltip <StateTimelineTooltip
series={alignedFrame} series={seriesFrame}
dataIdxs={dataIdxs} dataIdxs={dataIdxs}
seriesIdx={seriesIdx} seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode} mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
@@ -160,13 +164,14 @@ export const StatusHistoryPanel = ({
replaceVariables={replaceVariables} replaceVariables={replaceVariables}
dataLinks={dataLinks} dataLinks={dataLinks}
canExecuteActions={userCanExecuteActions} canExecuteActions={userCanExecuteActions}
_rest={restFields}
/> />
); );
}} }}
maxWidth={options.tooltip.maxWidth} maxWidth={options.tooltip.maxWidth}
/> />
)} )}
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && ( {seriesFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
<AnnotationsPlugin2 <AnnotationsPlugin2
replaceVariables={replaceVariables} replaceVariables={replaceVariables}
multiLane={options.annotations?.multiLane} multiLane={options.annotations?.multiLane}