From 69e726655da6d45ff0d58d6b2615fe2ff9b06b10 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Mon, 7 Sep 2026 12:43:32 +0200 Subject: [PATCH 1/5] feat(prometheus): add exemplars spec section and queryExemplars client method Add an optional `exemplars: { enable: boolean }` section to the PrometheusDatasource spec, exposed in the datasource editor as a toggle and validated by the CUE schema. Add a `queryExemplars` method to the Prometheus client hitting GET /api/v1/query_exemplars with the same query parameters and authentication handling as the other API calls. The endpoint is also added to the default proxy allowedEndpoints list. Related to perses/perses#3445 Signed-off-by: Juliano Costa --- prometheus/schemas/datasource/prometheus.cue | 3 ++ prometheus/src/model/api-types.ts | 20 +++++++++++ prometheus/src/model/prometheus-client.ts | 20 +++++++++++ .../plugins/PrometheusDatasourceEditor.tsx | 24 ++++++++++++- .../src/plugins/prometheus-datasource.test.ts | 34 +++++++++++++++++++ .../src/plugins/prometheus-datasource.tsx | 2 ++ prometheus/src/plugins/types.ts | 10 ++++++ 7 files changed, 112 insertions(+), 1 deletion(-) diff --git a/prometheus/schemas/datasource/prometheus.cue b/prometheus/schemas/datasource/prometheus.cue index 6cb6e7047..d772310bb 100644 --- a/prometheus/schemas/datasource/prometheus.cue +++ b/prometheus/schemas/datasource/prometheus.cue @@ -23,6 +23,9 @@ spec: { datasource.#HTTPDatasourceSpec scrapeInterval?: =~#durationRegex queryParams?: {[string]: string} + exemplars?: { + enable: bool + } } #kind: "PrometheusDatasource" diff --git a/prometheus/src/model/api-types.ts b/prometheus/src/model/api-types.ts index eb0e30778..0e52a3354 100644 --- a/prometheus/src/model/api-types.ts +++ b/prometheus/src/model/api-types.ts @@ -101,6 +101,26 @@ export interface RangeQueryRequestParameters { export type RangeQueryResponse = ApiResponse; +// Ref https://prometheus.io/docs/prometheus/latest/querying/api/#querying-exemplars +export interface QueryExemplarsRequestParameters { + query: string; + start: UnixTimestampSeconds; + end: UnixTimestampSeconds; +} + +export interface ExemplarData { + labels: Metric; + value: string; + timestamp: UnixTimestampSeconds; +} + +export interface ExemplarSeries { + seriesLabels: Metric; + exemplars: ExemplarData[]; +} + +export type QueryExemplarsResponse = ApiResponse; + export interface SeriesRequestParameters { 'match[]': string[]; start?: UnixTimestampSeconds; diff --git a/prometheus/src/model/prometheus-client.ts b/prometheus/src/model/prometheus-client.ts index 39b76740d..4cb3267b3 100644 --- a/prometheus/src/model/prometheus-client.ts +++ b/prometheus/src/model/prometheus-client.ts @@ -27,6 +27,8 @@ import type { MetricMetadataResponse, ParseQueryRequestParameters, ParseQueryResponse, + QueryExemplarsRequestParameters, + QueryExemplarsResponse, RangeQueryRequestParameters, RangeQueryResponse, SeriesRequestParameters, @@ -49,6 +51,10 @@ export interface PrometheusClient extends DatasourceClient { options: PrometheusClientOptions; instantQuery(params: InstantQueryRequestParameters, options?: ClientRequestOptions): Promise; rangeQuery(params: RangeQueryRequestParameters, options?: ClientRequestOptions): Promise; + queryExemplars( + params: QueryExemplarsRequestParameters, + options?: ClientRequestOptions, + ): Promise; labelNames(params: LabelNamesRequestParameters, options?: ClientRequestOptions): Promise; labelValues(params: LabelValuesRequestParameters, options?: ClientRequestOptions): Promise; metricMetadata( @@ -134,6 +140,20 @@ export function rangeQuery( return fetchWithPost('/api/v1/query_range', params, queryOptions); } +/** + * Calls the `/api/v1/query_exemplars` endpoint to get exemplar data for a query. + */ +export function queryExemplars( + params: QueryExemplarsRequestParameters, + queryOptions: QueryOptions, +): Promise { + return fetchWithGet( + '/api/v1/query_exemplars', + params, + queryOptions, + ); +} + /** * Calls the `/api/v1/labels` endpoint to get a list of label names. */ diff --git a/prometheus/src/plugins/PrometheusDatasourceEditor.tsx b/prometheus/src/plugins/PrometheusDatasourceEditor.tsx index 8cbe546b9..78838963c 100644 --- a/prometheus/src/plugins/PrometheusDatasourceEditor.tsx +++ b/prometheus/src/plugins/PrometheusDatasourceEditor.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box, IconButton, TextField, Typography } from '@mui/material'; +import { Box, FormControlLabel, IconButton, Switch, TextField, Typography } from '@mui/material'; import type { QueryParamValues } from '@perses-dev/components'; import type { DatasourceEditorProps } from '@perses-dev/plugin-system'; import { HTTPSettingsEditor } from '@perses-dev/plugin-system'; @@ -133,6 +133,10 @@ export function PrometheusDatasourceEditor(props: PrometheusDatasourceEditorProp endpointPattern: '/api/v1/query_range', method: 'POST', }, + { + endpointPattern: '/api/v1/query_exemplars', + method: 'GET', + }, { endpointPattern: '/api/v1/label/([a-zA-Z0-9_-]+)/values', method: 'GET', @@ -173,6 +177,24 @@ export function PrometheusDatasourceEditor(props: PrometheusDatasourceEditorProp initialSpecProxy={initialSpecProxy} testConnection={testConnection} /> + + Exemplars + + + onChange({ + ...value, + exemplars: e.target.checked ? { enable: true } : undefined, + }) + } + /> + } + label="Enable exemplars" + /> Query Parameters diff --git a/prometheus/src/plugins/prometheus-datasource.test.ts b/prometheus/src/plugins/prometheus-datasource.test.ts index 58b84af50..2b322166d 100644 --- a/prometheus/src/plugins/prometheus-datasource.test.ts +++ b/prometheus/src/plugins/prometheus-datasource.test.ts @@ -66,4 +66,38 @@ describe('PrometheusDatasource query parameters', () => { mockFetch.mockClear(); }); + + it('should call the query_exemplars endpoint with query parameters', async () => { + const spec: PrometheusDatasourceSpec = { + directUrl: 'http://localhost:9090', + queryParams: { + dedup: 'false', + }, + exemplars: { + enable: true, + }, + }; + + const client = PrometheusDatasource.createClient(spec, {}); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve({ data: [] }), + }); + global.fetch = mockFetch; + + const response = await client.queryExemplars({ + query: 'up', + start: 1700000000, + end: 1700003600, + }); + + expect(response.data).toEqual([]); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:9090/api/v1/query_exemplars?query=up&start=1700000000&end=1700003600&dedup=false', + expect.objectContaining({ method: 'GET' }), + ); + }); }); diff --git a/prometheus/src/plugins/prometheus-datasource.tsx b/prometheus/src/plugins/prometheus-datasource.tsx index c7cc9cd43..a2f75943d 100644 --- a/prometheus/src/plugins/prometheus-datasource.tsx +++ b/prometheus/src/plugins/prometheus-datasource.tsx @@ -25,6 +25,7 @@ import { mergeQueryParams, metricMetadata, parseQuery, + queryExemplars, rangeQuery, series, } from '../model'; @@ -69,6 +70,7 @@ const createClient: DatasourcePlugin healthCheck: healthCheck({ datasourceUrl, headers: specHeaders, queryParams }), instantQuery: wrapClientMethod(instantQuery, datasourceUrl, specHeaders, queryParams), rangeQuery: wrapClientMethod(rangeQuery, datasourceUrl, specHeaders, queryParams), + queryExemplars: wrapClientMethod(queryExemplars, datasourceUrl, specHeaders, queryParams), labelNames: wrapClientMethod(labelNames, datasourceUrl, specHeaders, queryParams), labelValues: wrapClientMethod(labelValues, datasourceUrl, specHeaders, queryParams), metricMetadata: wrapClientMethod(metricMetadata, datasourceUrl, specHeaders, queryParams), diff --git a/prometheus/src/plugins/types.ts b/prometheus/src/plugins/types.ts index 90004731f..ed3d4cd38 100644 --- a/prometheus/src/plugins/types.ts +++ b/prometheus/src/plugins/types.ts @@ -19,11 +19,21 @@ import type { PrometheusDatasourceSelector } from '../model'; export const DEFAULT_SCRAPE_INTERVAL: DurationString = '1m'; +/** + * Optional section enabling the exemplars feature for this datasource. + * When enabled, the PrometheusTimeSeriesQuery plugin also queries the + * `/api/v1/query_exemplars` endpoint and returns exemplar data. + */ +export interface PrometheusExemplarsSpec { + enable: boolean; +} + export interface PrometheusDatasourceSpec { directUrl?: string; proxy?: HTTPProxy; scrapeInterval?: DurationString; // default to 1m queryParams?: QueryParamValues; + exemplars?: PrometheusExemplarsSpec; } export interface PrometheusVariableOptionsBase { From 543b09ab86987fba73f66e7e5f48d8ae6e0a7a49 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Mon, 7 Sep 2026 12:46:58 +0200 Subject: [PATCH 2/5] feat(prometheus): fetch exemplars in PrometheusTimeSeriesQuery When the selected datasource has exemplars enabled in its spec and the query runs in range mode, fire a query_exemplars request alongside the range query (same PromQL and time range, in parallel) and attach the converted exemplar data to the returned TimeSeriesData. Exemplar timestamps are converted from seconds to ms and values are parsed as numbers. A failing exemplar request never breaks the panel: the error is logged and the query result is returned without exemplars. Related to perses/perses#3445 Signed-off-by: Juliano Costa --- .../get-time-series-data.ts | 57 ++++++++++-- .../plugin.test.ts | 87 ++++++++++++++++++- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts b/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts index 107679142..f04c50c13 100644 --- a/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts +++ b/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts @@ -13,13 +13,28 @@ import type { TimeSeriesQueryPlugin } from '@perses-dev/plugin-system'; import { datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system'; -import type { DatasourceSpec, DurationString, Notice, TimeSeries, TimeSeriesData } from '@perses-dev/spec'; +import type { + DatasourceSpec, + DurationString, + Notice, + TimeSeries, + TimeSeriesData, + TimeSeriesExemplars, +} from '@perses-dev/spec'; import { parseDurationString } from '@perses-dev/spec'; import { fromUnixTime, milliseconds } from 'date-fns'; -import type { PrometheusClient, MatrixData, VectorData, ScalarData, InstantQueryResultType } from '../../model'; +import type { + PrometheusClient, + MatrixData, + VectorData, + ScalarData, + InstantQueryResultType, + ExemplarSeries, +} from '../../model'; import { parseValueTuple, + parseSampleValue, getDurationStringSeconds, getPrometheusTimeRange, getRangeStep, @@ -110,15 +125,27 @@ export const getTimeSeriesData: TimeSeriesQueryPlugin> | undefined; if (isInstant) { response = await client.instantQuery({ query, time: end }, { ...interpolatedOptions, signal: abortSignal }); } else { + const exemplarPromise = exemplarsEnabled + ? client.queryExemplars({ query, start, end }, { ...interpolatedOptions, signal: abortSignal }) + : undefined; + response = await client.rangeQuery({ query, start, end, step }, { ...interpolatedOptions, signal: abortSignal }); + + if (exemplarPromise) { + try { + exemplarResponse = await exemplarPromise; + } catch (err) { + console.warn('Failed to fetch exemplars', err); + } + } } // TODO: What about error responses from Prom that have a response body? @@ -144,6 +171,7 @@ export const getTimeSeriesData: TimeSeriesQueryPlugin ({ + seriesLabels: res.seriesLabels, + exemplars: res.exemplars.map((exemplar) => ({ + labels: exemplar.labels, + value: parseSampleValue(exemplar.value), + timestamp: exemplar.timestamp * 1000, + })), + })); +} + function buildVectorData(query: string, data: VectorData, seriesNameFormat: string | undefined): TimeSeries[] { return data.result.map((res) => { const { metric, value, histogram } = res; diff --git a/prometheus/src/plugins/prometheus-time-series-query/plugin.test.ts b/prometheus/src/plugins/prometheus-time-series-query/plugin.test.ts index 2b037c676..d51d163ed 100644 --- a/prometheus/src/plugins/prometheus-time-series-query/plugin.test.ts +++ b/prometheus/src/plugins/prometheus-time-series-query/plugin.test.ts @@ -19,7 +19,7 @@ import type { TimeSeriesQueryContext } from '@perses-dev/plugin-system'; import type { DatasourceSpec } from '@perses-dev/spec'; import type { Mock } from 'vitest'; -import type { RangeQueryResponse, InstantQueryResponse } from '../../model'; +import type { RangeQueryResponse, InstantQueryResponse, QueryExemplarsResponse } from '../../model'; import { PrometheusDatasource } from '../prometheus-datasource'; import type { PrometheusDatasourceSpec } from '../types'; import { PrometheusTimeSeriesQuery } from './'; @@ -83,6 +83,30 @@ const getDatasource: Mock = vi.fn((): DatasourceSpec = }; }); +// Mock exemplars query +promStubClient.queryExemplars = vi.fn(async (): Promise => { + const stubResponse: QueryExemplarsResponse = { + status: 'success', + data: [ + { + seriesLabels: { + __name__: 'up', + }, + exemplars: [ + { + labels: { + traceID: 'abc123', + }, + value: '10', + timestamp: 1686141338.877, + }, + ], + }, + ], + }; + return stubResponse; +}); + const createStubContext = (): TimeSeriesQueryContext => { const stubTimeSeriesContext: TimeSeriesQueryContext = { datasourceStore: { @@ -171,6 +195,67 @@ describe('PrometheusTimeSeriesQuery', () => { expect(promStubClient.instantQuery).not.toHaveBeenCalled(); }); + it('should not query exemplars when the datasource does not enable them', async () => { + const ctx = createStubContext(); + (promStubClient.rangeQuery as Mock).mockClear(); + (promStubClient.queryExemplars as Mock).mockClear(); + + const results = await PrometheusTimeSeriesQuery.getTimeSeriesData({ query: 'up' }, ctx); + + expect(promStubClient.queryExemplars).not.toHaveBeenCalled(); + expect(results.exemplars).toBeUndefined(); + }); + + it('should query exemplars and convert them when the datasource enables them', async () => { + const ctx = createStubContext(); + getDatasource.mockImplementation((): DatasourceSpec => { + return { + default: false, + plugin: { + kind: 'PrometheusDatasource', + spec: { + ...datasource, + exemplars: { enable: true }, + }, + }, + }; + }); + (promStubClient.queryExemplars as Mock).mockClear(); + + const results = await PrometheusTimeSeriesQuery.getTimeSeriesData({ query: 'up' }, ctx); + + expect(promStubClient.queryExemplars).toHaveBeenCalledTimes(1); + expect(results.exemplars).toEqual([ + { + seriesLabels: { __name__: 'up' }, + exemplars: [{ labels: { traceID: 'abc123' }, value: 10, timestamp: 1686141338877 }], + }, + ]); + }); + + it('should keep the query working when the exemplar query fails', async () => { + const ctx = createStubContext(); + getDatasource.mockImplementation((): DatasourceSpec => { + return { + default: false, + plugin: { + kind: 'PrometheusDatasource', + spec: { + ...datasource, + exemplars: { enable: true }, + }, + }, + }; + }); + (promStubClient.queryExemplars as Mock).mockClear(); + (promStubClient.queryExemplars as Mock).mockRejectedValueOnce(new Error('exemplar endpoint unavailable')); + + const results = await PrometheusTimeSeriesQuery.getTimeSeriesData({ query: 'up' }, ctx); + + expect(results.series.length).toBeGreaterThan(0); + expect(results.exemplars).toBeUndefined(); + }); + it('should use instantQuery when spec.instant is unset and context mode is instant', async () => { const ctx = createStubContext(); ctx.mode = 'instant'; From 10769bd85d4998732b8661dc7ae1b29a94725e1a Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Tue, 8 Sep 2026 13:45:20 +0200 Subject: [PATCH 3/5] feat(timeserieschart): render exemplars as pin-able diamond markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exemplars render as diamond scatter series on top of their matching series, inheriting the series color and y axis; only series visible in the legend get markers. Hovering a diamond shows the ExemplarMetadataTooltip from @perses-dev/components (series labels, exemplar labels, value, timestamp) anchored to the chart, and clicking the diamond pins it — clicking it again or elsewhere on the chart unpins it — mirroring the annotation tooltip interaction instead of opening a modal dialog. Signed-off-by: Juliano Costa --- .../get-time-series-data.ts | 6 - prometheus/src/plugins/types.ts | 5 - timeserieschart/src/TimeSeriesChartBase.tsx | 126 +++++++++++++++++- .../src/TimeSeriesChartPanel.test.tsx | 87 +++++++++++- timeserieschart/src/TimeSeriesChartPanel.tsx | 56 +++++++- .../src/test/mock-query-results.ts | 39 +++++- .../src/utils/data-transform.test.ts | 43 +++++- timeserieschart/src/utils/data-transform.ts | 45 ++++++- 8 files changed, 378 insertions(+), 29 deletions(-) diff --git a/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts b/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts index f04c50c13..4f122606d 100644 --- a/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts +++ b/prometheus/src/plugins/prometheus-time-series-query/get-time-series-data.ts @@ -181,12 +181,6 @@ export const getTimeSeriesData: TimeSeriesQueryPlugin(fun height, data, seriesMapping, + exemplars, annotations, timeScale: timeScaleProp, yAxis, @@ -139,6 +160,9 @@ export const TimeSeriesChartBase = forwardRef(fun const [startX, setStartX] = useState(0); const [hoveredAnnotation, setHoveredAnnotation] = useState(null); const [pinnedAnnotation, setPinnedAnnotation] = useState(null); + const [hoveredExemplar, setHoveredExemplar] = useState(null); + const [pinnedExemplar, setPinnedExemplar] = useState(null); + const [pinnedExemplarPos, setPinnedExemplarPos] = useState(null); const [pinnedAnnotationPos, setPinnedAnnotationPos] = useState(null); const { timeZone, formatWithUserTimeZone } = useTimeZone(); @@ -212,6 +236,18 @@ export const TimeSeriesChartBase = forwardRef(fun }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseover: (params: any): void => { + if ( + params.componentType === 'series' && + params.seriesType === 'scatter' && + typeof params.seriesId === 'string' && + params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) + ) { + if (params.data?.exemplar) { + setHoveredExemplar({ exemplar: params.data.exemplar, seriesLabels: params.data.seriesLabels }); + return; + } + } + setHoveredExemplar(null); // Only markPoint (triangles under the X-axis) opens the annotation tooltip. // Hovering markLine or anything else keeps the regular TimeSeries tooltip visible // and clears any stale hovered annotation (mouseout is sometimes missed by ECharts). @@ -226,6 +262,15 @@ export const TimeSeriesChartBase = forwardRef(fun }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseout: (params: any): void => { + if ( + params.componentType === 'series' && + params.seriesType === 'scatter' && + typeof params.seriesId === 'string' && + params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) + ) { + setHoveredExemplar(null); + return; + } if ( annotations && params.componentType === 'markPoint' && @@ -243,6 +288,7 @@ export const TimeSeriesChartBase = forwardRef(fun // Cursor left the chart canvas — guarantee the annotation tooltip is dismissed. setHoveredAnnotation(null); } + setHoveredExemplar(null); }, }; }, [annotations, onDataZoom]); @@ -250,6 +296,8 @@ export const TimeSeriesChartBase = forwardRef(fun // Generate annotation series for ECharts markArea (range), markLine (point), and markPoint (markers under X-axis) const annotationSeries = useMemo(() => buildAnnotationSeries(annotations), [annotations]); + const exemplarSeries = useMemo(() => exemplars?.map(getExemplarSeries) ?? [], [exemplars]); + const { noDataOption } = chartsTheme; const option: EChartsCoreOption = useMemo(() => { @@ -270,8 +318,8 @@ export const TimeSeriesChartBase = forwardRef(fun const updatedSeriesMapping = enablePinning && pinnedCrosshair !== null - ? [...seriesMapping, pinnedCrosshair, ...annotationSeries] - : [...seriesMapping, ...annotationSeries]; + ? [...seriesMapping, pinnedCrosshair, ...annotationSeries, ...exemplarSeries] + : [...seriesMapping, ...annotationSeries, ...exemplarSeries]; const option: EChartsCoreOption = { dataset: dataset, @@ -327,6 +375,7 @@ export const TimeSeriesChartBase = forwardRef(fun data, seriesMapping, annotationSeries, + exemplarSeries, timeScale, yAxis, format, @@ -365,6 +414,34 @@ export const TimeSeriesChartBase = forwardRef(fun // e.preventDefault(); // Prevent the default behaviour when right clicked // }} onClick={(e) => { + // If clicking while hovering an exemplar marker, toggle the exemplar tooltip pin + // instead of pinning the TimeChartTooltip, so pinned TimeChartTooltip is preserved. + if (hoveredExemplar !== null && e.target instanceof HTMLCanvasElement) { + const pinnedPos: CursorCoordinates = { + page: { x: e.pageX, y: e.pageY }, + client: { x: e.clientX, y: e.clientY }, + plotCanvas: { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }, + target: e.target, + }; + setPinnedExemplar((current) => { + if (current !== null && current.exemplar === hoveredExemplar.exemplar) { + setPinnedExemplarPos(null); + return null; + } + setPinnedExemplarPos(pinnedPos); + return hoveredExemplar; + }); + return; + } + + // Clicking elsewhere on the chart canvas unpins a pinned exemplar tooltip. + // Return so the unpin click does not also pin the TimeChartTooltip. + if (pinnedExemplar !== null && e.target instanceof HTMLCanvasElement) { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + return; + } + // If clicking while hovering an annotation, toggle the annotation tooltip pin // instead of pinning the TimeChartTooltip, so pinned TimeChartTooltip is preserved. if (hoveredAnnotation !== null && e.target instanceof HTMLCanvasElement) { @@ -467,6 +544,27 @@ export const TimeSeriesChartBase = forwardRef(fun return; } const { clientX } = e; + // ECharts does not reliably emit mouseout when the cursor moves off an exemplar marker + // onto the plain plot area, which would keep the exemplar tooltip stuck on screen and + // hide the regular TimeChartTooltip. Clear the hovered exemplar as soon as the cursor + // is no longer within its marker, so the next hover can take over. + setHoveredExemplar((current) => { + if (current === null || chartRef.current === undefined) return current; + let markerPixel: number[] | undefined; + try { + markerPixel = chartRef.current.convertToPixel('grid', [ + current.exemplar.timestamp, + current.exemplar.value, + ]); + } catch { + // Coordinates cannot be resolved (e.g. grid not ready yet), keep the current hover. + return current; + } + const [markerPixelX, markerPixelY] = markerPixel ?? []; + if (markerPixelX === undefined || markerPixelY === undefined) return current; + const distance = Math.hypot(e.nativeEvent.offsetX - markerPixelX, e.nativeEvent.offsetY - markerPixelY); + return distance <= EXEMPLAR_HOVER_RADIUS ? current : null; + }); if (isDragging) { const deltaX = clientX - startX; if (deltaX > 0) { @@ -484,8 +582,9 @@ export const TimeSeriesChartBase = forwardRef(fun if (tooltipPinnedCoords === null) { setShowTooltip(false); } - // Defensive: clear hovered annotation in case ECharts missed a mouseout event. + // Defensive: clear hovered annotation and exemplar in case ECharts missed a mouseout event. setHoveredAnnotation(null); + setHoveredExemplar(null); if (chartRef.current !== undefined) { clearHighlightedSeries(chartRef.current); } @@ -511,7 +610,7 @@ export const TimeSeriesChartBase = forwardRef(fun {/* Allows overrides prop to hide custom tooltip and use the ECharts option.tooltip instead. Keep the time chart tooltip visible when pinned even if user hovers an annotation. */} {showTooltip === true && - (tooltipPinnedCoords !== null || hoveredAnnotation === null) && + (tooltipPinnedCoords !== null || (hoveredAnnotation === null && hoveredExemplar === null)) && (option.tooltip as TooltipComponentOption)?.showContent === false && tooltipConfig.hidden !== true && ( (fun }} /> )} + {/* Pinned exemplar takes priority over hovered. */} + {(pinnedExemplar ?? hoveredExemplar) !== null && ( + { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + }} + /> + )} {/* Pinned annotation takes priority over hovered. */} {(pinnedAnnotation ?? hoveredAnnotation) && ( ({ lastChartOption: { current: undefined as unknown } })); +vi.mock('@perses-dev/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + EChart: (props: Record): ReactElement => { + lastChartOption.current = props.option; + return
; + }, + }; +}); import type { TimeSeriesChartProps } from './TimeSeriesChartPanel'; import { TimeSeriesChartPanel } from './TimeSeriesChartPanel'; @@ -76,7 +92,7 @@ function getLegendByName(name?: string): HTMLElement { describe('TimeSeriesChartPanel', () => { // Helper to render the panel with some context set - const renderPanel = (): void => { + const renderPanel = (data = MOCK_TIME_SERIES_DATA_MULTIVALUE): void => { const mockTimeRangeContext = { refreshIntervalInMs: 0, setRefreshInterval: (): Record => ({}), @@ -92,7 +108,7 @@ describe('TimeSeriesChartPanel', () => { @@ -100,6 +116,69 @@ describe('TimeSeriesChartPanel', () => { ); }; + it('should render the legend with unformatted series labels', async () => { + renderPanel(); + }); + + describe('exemplars', () => { + const getExemplarSeries = (): unknown[] => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const option = lastChartOption.current as any; + return (option?.series ?? []).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s: any) => typeof s?.id === 'string' && s.id.startsWith('exemplar-'), + ); + }; + + it('should render exemplars as diamond scatter series with embedded metadata', async () => { + renderPanel({ ...MOCK_TIME_SERIES_DATA_MULTIVALUE, exemplars: MOCK_TIME_SERIES_EXEMPLARS }); + const exemplarSeries = await waitFor(() => { + const series = getExemplarSeries(); + expect(series).toHaveLength(2); + return series; + }); + + expect(exemplarSeries).toHaveLength(2); + for (const series of exemplarSeries) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = series as any; + expect(s.type).toEqual('scatter'); + expect(s.symbol).toEqual('diamond'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vda1Series = exemplarSeries.find((s) => (s as any)?.id?.includes('vda1')) as any; + const firstItem = vda1Series?.data?.[0]; + const expectedExemplars = MOCK_TIME_SERIES_EXEMPLARS[0]?.exemplars ?? []; + expect(firstItem?.exemplar).toEqual(expectedExemplars[0]); + expect(firstItem?.seriesLabels).toEqual(MOCK_TIME_SERIES_EXEMPLARS[0]?.seriesLabels); + }); + + it('should not render exemplar series when the query data has no exemplars', async () => { + renderPanel(); + await screen.findByText( + 'device="/dev/vda1", env="demo", fstype="ext4", instance="demo.do.prometheus.io:9100", job="node", mountpoint="/"', + ); + expect(getExemplarSeries()).toHaveLength(0); + }); + + it('should only render exemplars of series selected in the legend', async () => { + renderPanel({ ...MOCK_TIME_SERIES_DATA_MULTIVALUE, exemplars: MOCK_TIME_SERIES_EXEMPLARS }); + await waitFor(() => { + expect(getExemplarSeries()).toHaveLength(2); + }); + + userEvent.click(getLegendByName(MOCK_TIME_SERIES_DATA_MULTIVALUE.series[0]?.name)); + + await waitFor(() => { + const exemplarSeries = getExemplarSeries(); + expect(exemplarSeries).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((exemplarSeries[0] as any)?.id).toContain('vda1'); + }); + }); + }); + it('should render the legend with unformatted series labels', async () => { renderPanel(); expect( diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index e0145d9c4..f4430c70c 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -43,7 +43,7 @@ import { legendValues, getCalculations, } from '@perses-dev/plugin-system'; -import type { TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; +import type { Labels, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; import type { GridComponentOption } from 'echarts'; import merge from 'lodash/merge'; import type { ReactElement } from 'react'; @@ -54,6 +54,7 @@ import { DEFAULT_FORMAT, DEFAULT_VISUAL, THRESHOLD_PLOT_INTERVAL } from './time- import { TimeSeriesChartBase } from './TimeSeriesChartBase'; import type { TimeSeriesAnnotation } from './utils/annotation'; import { convertAnnotationToTimeSeriesAnnotation } from './utils/annotation'; +import type { ExemplarChartData } from './utils/data-transform'; import { getTimeSeries, getCommonTimeScaleForQueries, @@ -65,6 +66,18 @@ import { getSeriesColor } from './utils/palette-gen'; export type TimeSeriesChartProps = PanelProps; +/** + * Stable string key for a labels record, so exemplars can be matched to their + * series regardless of the labels order. + */ +function labelsKey(labels: Labels): string { + return JSON.stringify( + Object.keys(labels) + .toSorted() + .map((labelName) => [labelName, labels[labelName]]), + ); +} + // Using an "ALL" value to handle the case on first loading the chart where we // want to select all, but do not want all of the legend items to be visually highlighted. // This helps us differentiate those cases more clearly instead of inferring it @@ -168,14 +181,17 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement timeChartData, timeSeriesMapping, legendItems, + chartExemplars, seriesFormatMap: computedSeriesFormatMap, maxValuesByFormat, } = useMemo(() => { const timeScale = getCommonTimeScaleForQueries(queryResults); if (timeScale === undefined) { return { - timeChartData: [], - timeSeriesMapping: [], + timeChartData: [] as TimeSeries[], + timeSeriesMapping: [] as TimeChartSeriesMapping, + chartExemplars: [] as ExemplarChartData[], + legendItems: [] as LegendItem[], seriesFormatMap: new Map(), maxValuesByFormat: new Map(), }; @@ -194,6 +210,10 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement // Index is counted across multiple queries which ensures the categorical color palette does not reset for every query let seriesIndex = 0; + const seriesByLabels = new Map(); + + const chartExemplars: ExemplarChartData[] = []; + // Mapping of each set of query results to be ECharts option compatible // TODO: Look into performance optimizations and moving parts of mapping to the lower level chart for (let queryIndex = 0; queryIndex < queryResults.length; queryIndex++) { @@ -214,7 +234,12 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement for (let i = 0; i < result.data.series.length; i++) { const timeSeries: TimeSeries | undefined = result.data.series[i]; if (timeSeries === undefined) { - return { timeChartData: [], timeSeriesMapping: [], legendItems: [] }; + return { + timeChartData: [] as TimeSeries[], + timeSeriesMapping: [] as TimeChartSeriesMapping, + chartExemplars: [] as ExemplarChartData[], + legendItems: [] as LegendItem[], + }; } // Format is determined by seriesNameFormat in query spec @@ -302,6 +327,17 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement name: formattedSeriesName, values: renderedValues, }); + + if (timeSeries.labels) { + seriesByLabels.set(labelsKey(timeSeries.labels), { + seriesId, + seriesName: formattedSeriesName, + color: seriesColor, + seriesLabels: timeSeries.labels, + yAxisIndex, + exemplars: [], + }); + } } if (legend && legendItems) { @@ -317,6 +353,16 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement seriesIndex++; } } + + const queryExemplars = result?.data.exemplars; + if (queryExemplars) { + for (const seriesExemplars of queryExemplars) { + if (seriesExemplars.exemplars.length === 0) continue; + const matched = seriesByLabels.get(labelsKey(seriesExemplars.seriesLabels)); + if (matched === undefined) continue; + chartExemplars.push({ ...matched, exemplars: seriesExemplars.exemplars }); + } + } } // map thresholds only if there is at least one time series to avoid displaying thresholds without any data @@ -362,6 +408,7 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement timeChartData, timeSeriesMapping, legendItems, + chartExemplars, seriesFormatMap, maxValuesByFormat, }; @@ -517,6 +564,7 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement height={height} data={timeChartData} seriesMapping={timeSeriesMapping} + exemplars={chartExemplars} annotations={annotations} timeScale={timeScale} yAxis={multipleYAxes ?? echartsYAxis} diff --git a/timeserieschart/src/test/mock-query-results.ts b/timeserieschart/src/test/mock-query-results.ts index a946dfbc5..0b69bc18d 100644 --- a/timeserieschart/src/test/mock-query-results.ts +++ b/timeserieschart/src/test/mock-query-results.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { TimeSeriesData } from '@perses-dev/spec'; +import type { TimeSeriesData, TimeSeriesExemplars } from '@perses-dev/spec'; export const MOCK_TIME_SERIES_QUERY_RESULT_MULTIVALUE = [ { @@ -175,6 +175,43 @@ export const MOCK_TIME_SERIES_DATA_MULTIVALUE: TimeSeriesData = { ], }; +export const MOCK_TIME_SERIES_EXEMPLARS: TimeSeriesExemplars[] = [ + { + seriesLabels: { + device: '/dev/vda1', + env: 'demo', + fstype: 'ext4', + instance: 'demo.do.prometheus.io:9100', + job: 'node', + mountpoint: '/', + }, + exemplars: [ + { + labels: { trace_id: 'trace-vda1' }, + value: 0.27700745551584494, + timestamp: 1666479357903, + }, + ], + }, + { + seriesLabels: { + device: '/dev/vda15', + env: 'demo', + fstype: 'vfat', + instance: 'demo.do.prometheus.io:9100', + job: 'node', + mountpoint: '/boot/efi', + }, + exemplars: [ + { + labels: { trace_id: 'trace-vda15' }, + value: 0.08486496097624485, + timestamp: 1666479382282, + }, + ], + }, +]; + export const MOCK_TIME_SERIES_DATA_SINGLEVALUE: TimeSeriesData = { timeRange: { start: new Date(1666625535000), diff --git a/timeserieschart/src/utils/data-transform.test.ts b/timeserieschart/src/utils/data-transform.test.ts index a0e0f79c4..ee6f000c3 100644 --- a/timeserieschart/src/utils/data-transform.test.ts +++ b/timeserieschart/src/utils/data-transform.test.ts @@ -15,7 +15,16 @@ import type { LegacyTimeSeries } from '@perses-dev/components'; import type { TimeScale } from '@perses-dev/spec'; import type { TimeSeriesChartVisualOptions, TimeSeriesChartYAxisOptions } from '../time-series-chart-model'; -import { convertPercentThreshold, convertPanelYAxis, getTimeSeries, roundDown } from './data-transform'; +import type { ExemplarChartData } from './data-transform'; +import { + EXEMPLAR_SERIES_ID_PREFIX, + EXEMPLAR_SYMBOL_SIZE, + convertPercentThreshold, + convertPanelYAxis, + getExemplarSeries, + getTimeSeries, + roundDown, +} from './data-transform'; const MAX_VALUE = 120; const MOCK_ECHART_TIME_SERIES_DATA: LegacyTimeSeries[] = [ @@ -27,6 +36,38 @@ const MOCK_ECHART_TIME_SERIES_DATA: LegacyTimeSeries[] = [ }, ]; +describe('getExemplarSeries', () => { + const exemplarData: ExemplarChartData = { + seriesId: 'chart1http_requests_total0', + seriesName: 'http_requests_total', + color: '#5f6caf', + seriesLabels: { __name__: 'http_requests_total', job: 'demo' }, + yAxisIndex: 0, + exemplars: [ + { labels: { trace_id: 'abc-123' }, value: 42, timestamp: 1700000000000 }, + { labels: { trace_id: 'def-456' }, value: 7, timestamp: 1700000150000 }, + ], + }; + + it('should render a diamond scatter series prefixed with the exemplar series id', () => { + const series = getExemplarSeries(exemplarData); + expect(series.type).toEqual('scatter'); + expect(series.id).toEqual(`${EXEMPLAR_SERIES_ID_PREFIX}${exemplarData.seriesId}`); + expect(series.symbol).toEqual('diamond'); + expect(series.symbolSize).toEqual(EXEMPLAR_SYMBOL_SIZE); + expect(series.color).toEqual('#5f6caf'); + }); + + it('should embed exemplar metadata in each data item so the dialog can be populated on click', () => { + const series = getExemplarSeries(exemplarData); + const data = series.data as Array<{ value: [number, number]; exemplar: unknown; seriesLabels: unknown }>; + expect(data).toHaveLength(2); + expect(data[0]?.value).toEqual([1700000000000, 42]); + expect(data[0]?.exemplar).toEqual(exemplarData.exemplars[0]); + expect(data[0]?.seriesLabels).toEqual(exemplarData.seriesLabels); + }); +}); + describe('convertPercentThreshold', () => { it('should return 25 if percent threshold is 25 and max is 100', () => { const value = convertPercentThreshold(25, MOCK_ECHART_TIME_SERIES_DATA, 100); diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index a7244f29a..39d8005f2 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -20,9 +20,9 @@ import type { } from '@perses-dev/components'; import { OPTIMIZED_MODE_SERIES_LIMIT, getCommonTimeScale } from '@perses-dev/components'; import type { useTimeSeriesQueries, PanelData } from '@perses-dev/plugin-system'; -import type { TimeScale, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; +import type { Exemplar, Labels, TimeScale, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; import type { YAXisComponentOption } from 'echarts'; -import type { LineSeriesOption, BarSeriesOption } from 'echarts/charts'; +import type { LineSeriesOption, BarSeriesOption, ScatterSeriesOption } from 'echarts/charts'; import type { TimeSeriesChartVisualOptions, @@ -51,6 +51,23 @@ export const HIDE_DATAPOINTS_LIMIT = 70; export const BLUR_FADEOUT_OPACITY = 0.5; +export const EXEMPLAR_SERIES_ID_PREFIX = 'exemplar-'; + +export const EXEMPLAR_SYMBOL_SIZE = 14; + +/** + * The exemplars of one series, converted to a chart-friendly shape with the + * rendering attributes (color, y axis) of the matching time series. + */ +export interface ExemplarChartData { + seriesId: string; + seriesName: string; + color: string; + seriesLabels?: Labels; + yAxisIndex?: number; + exemplars: Exemplar[]; +} + /** * Given a list of running queries, calculates a common time scale for use on * the x axis (i.e. start/end dates and a step that is divisible into all of @@ -144,6 +161,30 @@ export function getTimeSeries( return series; } +/** + * Gets an ECharts scatter series rendering exemplar markers for a single series. + * Each data item embeds its exemplar (and series labels) so the metadata dialog + * can be populated when a marker is clicked. + */ +export function getExemplarSeries(data: ExemplarChartData): ScatterSeriesOption { + return { + type: 'scatter', + id: `${EXEMPLAR_SERIES_ID_PREFIX}${data.seriesId}`, + name: data.seriesName, + color: data.color, + yAxisIndex: data.yAxisIndex, + symbol: 'diamond', + symbolSize: EXEMPLAR_SYMBOL_SIZE, + z: 10, + cursor: 'pointer', + data: data.exemplars.map((exemplar) => ({ + value: [exemplar.timestamp, exemplar.value], + exemplar, + seriesLabels: data.seriesLabels, + })), + }; +} + /** * Gets threshold-specific line series styles * markLine cannot be used since it does not update yAxis max / min From 995165aa859b5df02ccfed967b08322b4387c7e4 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Thu, 10 Sep 2026 12:30:33 +0200 Subject: [PATCH 4/5] fix(timeserieschart): do not stack hover tooltip over a pinned tooltip While a tooltip is pinned (line chart or exemplar), the other tooltip type no longer renders in mouse-follow mode on top of it. Clicking a different element type unpins the previous tooltip and pins the new one, so only one tooltip stays pinned at a time; Ctrl/Cmd-click opts into pinning multiple tooltips, consistent with the existing cross-chart behavior. Signed-off-by: Juliano Costa --- timeserieschart/src/TimeSeriesChartBase.tsx | 57 ++++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 9f706b5d0..857057e9e 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -402,6 +402,16 @@ export const TimeSeriesChartBase = forwardRef(fun } } } + // A pinned exemplar tooltip is also unpinned when a tooltip is pinned in another chart, + // unless it is the one just pinned by this chart at these exact coordinates. + if ( + pinnedExemplarPos !== null && + lastTooltipPinnedCoords !== null && + !isEqual(lastTooltipPinnedCoords, pinnedExemplarPos) + ) { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + } // tooltipPinnedCoords CANNOT be in dep array or tooltip pinning breaks in the current chart's onClick // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastTooltipPinnedCoords, seriesMapping]); @@ -414,8 +424,16 @@ export const TimeSeriesChartBase = forwardRef(fun // e.preventDefault(); // Prevent the default behaviour when right clicked // }} onClick={(e) => { + // Allows user to opt-in to multi tooltip pinning when Ctrl or Cmd key held down + const isControlKeyPressed = e.ctrlKey || e.metaKey; + if (isControlKeyPressed) { + e.preventDefault(); + } + // If clicking while hovering an exemplar marker, toggle the exemplar tooltip pin - // instead of pinning the TimeChartTooltip, so pinned TimeChartTooltip is preserved. + // instead of pinning the TimeChartTooltip. Pinning an exemplar tooltip unpins the + // pinned TimeChartTooltip, so only one tooltip stays pinned at a time unless + // Ctrl or Cmd is held down. if (hoveredExemplar !== null && e.target instanceof HTMLCanvasElement) { const pinnedPos: CursorCoordinates = { page: { x: e.pageX, y: e.pageY }, @@ -423,6 +441,7 @@ export const TimeSeriesChartBase = forwardRef(fun plotCanvas: { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }, target: e.target, }; + const isUnpinClick = pinnedExemplar !== null && pinnedExemplar.exemplar === hoveredExemplar.exemplar; setPinnedExemplar((current) => { if (current !== null && current.exemplar === hoveredExemplar.exemplar) { setPinnedExemplarPos(null); @@ -431,15 +450,21 @@ export const TimeSeriesChartBase = forwardRef(fun setPinnedExemplarPos(pinnedPos); return hoveredExemplar; }); + if (!isUnpinClick && !isControlKeyPressed) { + // Unpin the pinned TimeChartTooltip and let adjacent charts know a tooltip is + // pinned at these coordinates, so only one tooltip is pinned at a time. + setTooltipPinnedCoords(null); + setPinnedCrosshair(null); + setLastTooltipPinnedCoords(pinnedPos); + } return; } - // Clicking elsewhere on the chart canvas unpins a pinned exemplar tooltip. - // Return so the unpin click does not also pin the TimeChartTooltip. - if (pinnedExemplar !== null && e.target instanceof HTMLCanvasElement) { + // Unpin a pinned exemplar tooltip when clicking elsewhere on the chart canvas, + // so the same click can pin the TimeChartTooltip instead. Ctrl or Cmd keeps both. + if (pinnedExemplar !== null && !isControlKeyPressed && e.target instanceof HTMLCanvasElement) { setPinnedExemplar(null); setPinnedExemplarPos(null); - return; } // If clicking while hovering an annotation, toggle the annotation tooltip pin @@ -462,12 +487,6 @@ export const TimeSeriesChartBase = forwardRef(fun return; } - // Allows user to opt-in to multi tooltip pinning when Ctrl or Cmd key held down - const isControlKeyPressed = e.ctrlKey || e.metaKey; - if (isControlKeyPressed) { - e.preventDefault(); - } - // Determine where on chart canvas to plot pinned crosshair as markLine. const pointInGrid = getPointInGrid(e.nativeEvent.offsetX, e.nativeEvent.offsetY, chartRef.current); if (pointInGrid === null) { @@ -552,10 +571,7 @@ export const TimeSeriesChartBase = forwardRef(fun if (current === null || chartRef.current === undefined) return current; let markerPixel: number[] | undefined; try { - markerPixel = chartRef.current.convertToPixel('grid', [ - current.exemplar.timestamp, - current.exemplar.value, - ]); + markerPixel = chartRef.current.convertToPixel('grid', [current.exemplar.timestamp, current.exemplar.value]); } catch { // Coordinates cannot be resolved (e.g. grid not ready yet), keep the current hover. return current; @@ -608,9 +624,11 @@ export const TimeSeriesChartBase = forwardRef(fun }} > {/* Allows overrides prop to hide custom tooltip and use the ECharts option.tooltip instead. - Keep the time chart tooltip visible when pinned even if user hovers an annotation. */} + Keep the time chart tooltip visible when pinned even if user hovers an annotation or exemplar, + but do not show the mouse-following tooltip on top of a pinned exemplar tooltip. */} {showTooltip === true && - (tooltipPinnedCoords !== null || (hoveredAnnotation === null && hoveredExemplar === null)) && + (tooltipPinnedCoords !== null || + (pinnedExemplar === null && hoveredAnnotation === null && hoveredExemplar === null)) && (option.tooltip as TooltipComponentOption)?.showContent === false && tooltipConfig.hidden !== true && ( (fun }} /> )} - {/* Pinned exemplar takes priority over hovered. */} - {(pinnedExemplar ?? hoveredExemplar) !== null && ( + {/* Pinned exemplar takes priority over hovered. While a TimeChartTooltip is pinned, the + mouse-following exemplar tooltip is not rendered so it does not appear on top of it. */} + {(pinnedExemplar !== null || (hoveredExemplar !== null && tooltipPinnedCoords === null)) && ( Date: Mon, 14 Sep 2026 15:55:56 -0300 Subject: [PATCH 5/5] Address Copilot review comments on exemplars PR Signed-off-by: Juliano Costa --- .../src/plugins/prometheus-datasource.test.ts | 9 +++++-- timeserieschart/src/TimeSeriesChartBase.tsx | 26 ++++++++++++------- .../src/TimeSeriesChartPanel.test.tsx | 6 ++--- timeserieschart/src/TimeSeriesChartPanel.tsx | 3 +++ .../src/utils/data-transform.test.ts | 10 +++++++ timeserieschart/src/utils/data-transform.ts | 11 +++++++- 6 files changed, 48 insertions(+), 17 deletions(-) diff --git a/prometheus/src/plugins/prometheus-datasource.test.ts b/prometheus/src/plugins/prometheus-datasource.test.ts index 2b322166d..15cf55c3f 100644 --- a/prometheus/src/plugins/prometheus-datasource.test.ts +++ b/prometheus/src/plugins/prometheus-datasource.test.ts @@ -15,6 +15,11 @@ import { PrometheusDatasource } from './prometheus-datasource'; import type { PrometheusDatasourceSpec } from './types'; describe('PrometheusDatasource query parameters', () => { + // Restore the real fetch after each test so the stub never leaks into other tests. + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('should not alter the base URL', () => { const spec: PrometheusDatasourceSpec = { proxy: { @@ -52,7 +57,7 @@ describe('PrometheusDatasource query parameters', () => { status: 200, json: () => Promise.resolve({ data: [] }), }); - global.fetch = mockFetch; + vi.stubGlobal('fetch', mockFetch); // Test healthCheck includes query parameters if (client.healthCheck) { @@ -86,7 +91,7 @@ describe('PrometheusDatasource query parameters', () => { statusText: 'OK', json: () => Promise.resolve({ data: [] }), }); - global.fetch = mockFetch; + vi.stubGlobal('fetch', mockFetch); const response = await client.queryExemplars({ query: 'up', diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 857057e9e..8b1cb7c93 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -96,6 +96,11 @@ registerECharts([ interface HoveredExemplar { exemplar: Exemplar; seriesLabels?: Labels; + /** + * The Y value actually plotted for the marker. Differs from `exemplar.value` when the + * matching series is rendered with the negativeY transform (value negated for display). + */ + plottedValue: number; } /** @@ -243,7 +248,11 @@ export const TimeSeriesChartBase = forwardRef(fun params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) ) { if (params.data?.exemplar) { - setHoveredExemplar({ exemplar: params.data.exemplar, seriesLabels: params.data.seriesLabels }); + setHoveredExemplar({ + exemplar: params.data.exemplar, + seriesLabels: params.data.seriesLabels, + plottedValue: params.data?.value?.[1] ?? params.data.exemplar.value, + }); return; } } @@ -442,14 +451,10 @@ export const TimeSeriesChartBase = forwardRef(fun target: e.target, }; const isUnpinClick = pinnedExemplar !== null && pinnedExemplar.exemplar === hoveredExemplar.exemplar; - setPinnedExemplar((current) => { - if (current !== null && current.exemplar === hoveredExemplar.exemplar) { - setPinnedExemplarPos(null); - return null; - } - setPinnedExemplarPos(pinnedPos); - return hoveredExemplar; - }); + // Compute the next state first and call the setters separately: updater functions + // may run more than once (e.g. StrictMode), so they must stay side-effect free. + setPinnedExemplar(isUnpinClick ? null : hoveredExemplar); + setPinnedExemplarPos(isUnpinClick ? null : pinnedPos); if (!isUnpinClick && !isControlKeyPressed) { // Unpin the pinned TimeChartTooltip and let adjacent charts know a tooltip is // pinned at these coordinates, so only one tooltip is pinned at a time. @@ -571,7 +576,8 @@ export const TimeSeriesChartBase = forwardRef(fun if (current === null || chartRef.current === undefined) return current; let markerPixel: number[] | undefined; try { - markerPixel = chartRef.current.convertToPixel('grid', [current.exemplar.timestamp, current.exemplar.value]); + // Use the plotted value so negated markers (negativeY) are tracked correctly. + markerPixel = chartRef.current.convertToPixel('grid', [current.exemplar.timestamp, current.plottedValue]); } catch { // Coordinates cannot be resolved (e.g. grid not ready yet), keep the current hover. return current; diff --git a/timeserieschart/src/TimeSeriesChartPanel.test.tsx b/timeserieschart/src/TimeSeriesChartPanel.test.tsx index 72a2fcf84..1cb54f08f 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.test.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.test.tsx @@ -116,10 +116,6 @@ describe('TimeSeriesChartPanel', () => { ); }; - it('should render the legend with unformatted series labels', async () => { - renderPanel(); - }); - describe('exemplars', () => { const getExemplarSeries = (): unknown[] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -168,6 +164,8 @@ describe('TimeSeriesChartPanel', () => { expect(getExemplarSeries()).toHaveLength(2); }); + // NOTE: the project pins @testing-library/user-event v13, whose direct + // `userEvent.click` API is synchronous (the v14 `setup()` API is not available). userEvent.click(getLegendByName(MOCK_TIME_SERIES_DATA_MULTIVALUE.series[0]?.name)); await waitFor(() => { diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index f4430c70c..9ddd1fff2 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -335,6 +335,9 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement color: seriesColor, seriesLabels: timeSeries.labels, yAxisIndex, + // Exemplar markers must render on the same side of the X axis as their + // series, so they inherit the negativeY visual transform of the query. + negativeY: querySettings?.negativeY, exemplars: [], }); } diff --git a/timeserieschart/src/utils/data-transform.test.ts b/timeserieschart/src/utils/data-transform.test.ts index ee6f000c3..ef147002e 100644 --- a/timeserieschart/src/utils/data-transform.test.ts +++ b/timeserieschart/src/utils/data-transform.test.ts @@ -66,6 +66,16 @@ describe('getExemplarSeries', () => { expect(data[0]?.exemplar).toEqual(exemplarData.exemplars[0]); expect(data[0]?.seriesLabels).toEqual(exemplarData.seriesLabels); }); + + it('should negate plotted Y values while keeping original exemplar values when negativeY is enabled', () => { + const series = getExemplarSeries({ ...exemplarData, negativeY: true }); + const data = series.data as Array<{ value: [number, number]; exemplar: unknown }>; + expect(data[0]?.value).toEqual([1700000000000, -42]); + expect(data[1]?.value).toEqual([1700000150000, -7]); + // The embedded exemplar keeps the original (positive) value for the metadata tooltip. + expect(data[0]?.exemplar).toEqual(exemplarData.exemplars[0]); + expect(data[1]?.exemplar).toEqual(exemplarData.exemplars[1]); + }); }); describe('convertPercentThreshold', () => { diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index 39d8005f2..fece15276 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -65,6 +65,13 @@ export interface ExemplarChartData { color: string; seriesLabels?: Labels; yAxisIndex?: number; + /** + * When the matching series is rendered with `querySettings.negativeY`, its values are + * visually negated so it renders below the X axis. The same transform is applied to + * the exemplar markers' plotted Y values so they stay next to their series. The + * original (positive) values remain on each `exemplar` for metadata display. + */ + negativeY?: boolean; exemplars: Exemplar[]; } @@ -178,7 +185,9 @@ export function getExemplarSeries(data: ExemplarChartData): ScatterSeriesOption z: 10, cursor: 'pointer', data: data.exemplars.map((exemplar) => ({ - value: [exemplar.timestamp, exemplar.value], + // The plotted Y value is negated when negativeY is enabled for the matching series, + // while `exemplar.value` keeps the original value for the metadata tooltip. + value: [exemplar.timestamp, data.negativeY ? -exemplar.value : exemplar.value], exemplar, seriesLabels: data.seriesLabels, })),