From 0fd1acde069ca2032fb363fd7e76e4f10d4490c6 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 20 Apr 2026 17:06:04 +0200 Subject: [PATCH 01/15] WIP: Prometheus Annotations plugin Signed-off-by: Guillaume LADORME --- .../PrometheusPromQLAnnotation.tsx | 16 ++ ...PrometheusPromQLAnnotationOptionEditor.tsx | 88 ++++++ .../annotations/get-annotation-data.ts | 89 +++++++ .../src/components/annotations/index.ts | 14 + prometheus/src/index.ts | 1 + prometheus/src/plugins/types.ts | 5 + timeserieschart/package.json | 3 +- timeserieschart/src/TimeSeriesChartBase.tsx | 250 +++++++++++++++++- timeserieschart/src/TimeSeriesChartPanel.tsx | 4 + 9 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 prometheus/src/components/annotations/PrometheusPromQLAnnotation.tsx create mode 100644 prometheus/src/components/annotations/PrometheusPromQLAnnotationOptionEditor.tsx create mode 100644 prometheus/src/components/annotations/get-annotation-data.ts create mode 100644 prometheus/src/components/annotations/index.ts diff --git a/prometheus/src/components/annotations/PrometheusPromQLAnnotation.tsx b/prometheus/src/components/annotations/PrometheusPromQLAnnotation.tsx new file mode 100644 index 000000000..991f2412a --- /dev/null +++ b/prometheus/src/components/annotations/PrometheusPromQLAnnotation.tsx @@ -0,0 +1,16 @@ +import { AnnotationPlugin, AnnotationQueryQueryPluginDependencies, parseVariables } from '@perses-dev/plugin-system'; +import { PrometheusPromQLAnnotationOptions } from '../../plugins'; +import { PrometheusPromQLAnnotationOptionEditor } from './PrometheusPromQLAnnotationOptionEditor'; +import { getAnnotationData } from './get-annotation-data'; + +export const PrometheusPromQLAnnotation: AnnotationPlugin = { + getAnnotationData: getAnnotationData, + dependsOn: (spec: PrometheusPromQLAnnotationOptions): AnnotationQueryQueryPluginDependencies => { + const queryVariables = parseVariables(spec.expr); + return { + variables: [...queryVariables], + }; + }, + OptionsEditorComponent: PrometheusPromQLAnnotationOptionEditor, + createInitialOptions: () => ({ expr: '' }), +}; diff --git a/prometheus/src/components/annotations/PrometheusPromQLAnnotationOptionEditor.tsx b/prometheus/src/components/annotations/PrometheusPromQLAnnotationOptionEditor.tsx new file mode 100644 index 000000000..42084fd8b --- /dev/null +++ b/prometheus/src/components/annotations/PrometheusPromQLAnnotationOptionEditor.tsx @@ -0,0 +1,88 @@ +import { ReactElement } from 'react'; +import { useId } from '@perses-dev/components'; +import { produce } from 'immer'; +import { FormControl, Stack } from '@mui/material'; +import { + DatasourceSelect, + DatasourceSelectProps, + useDatasourceClient, + useDatasourceSelectValueToSelector, +} from '@perses-dev/plugin-system'; +import { PrometheusTimeSeriesQueryEditorProps, useQueryState } from '../../plugins'; +import { + DEFAULT_PROM, + isDefaultPromSelector, + isPrometheusDatasourceSelector, + PROM_DATASOURCE_KIND, + PrometheusClient, + PrometheusDatasourceSelector, +} from '../../model'; + +import { PromQLEditor } from '../PromQLEditor'; + +export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusTimeSeriesQueryEditorProps): ReactElement { + const { + onChange, + value, + value: { query, datasource }, + isReadonly, + } = props; + + const datasourceSelectValue = datasource ?? DEFAULT_PROM; + + const datasourceSelectLabelID = useId('prom-datasource-label'); // for panels with multiple queries, this component is rendered multiple times on the same page + + const selectedDatasource = useDatasourceSelectValueToSelector( + datasourceSelectValue, + PROM_DATASOURCE_KIND + ) as PrometheusDatasourceSelector; + + const { data: client } = useDatasourceClient(selectedDatasource); + const promURL = client?.options.datasourceUrl; + + const { handleQueryChange, handleQueryBlur } = useQueryState(props); + + const handleDatasourceChange: DatasourceSelectProps['onChange'] = (next) => { + if (isPrometheusDatasourceSelector(next)) { + /* Good to know: The usage of onchange here causes an immediate spec update which eventually updates the panel + This was probably intentional to allow for quick switching between datasources. + Could have been triggered only with Run Query button as well. + */ + onChange( + produce(value, (draft) => { + // If they're using the default, just omit the datasource prop (i.e. set to undefined) + const nextDatasource = isDefaultPromSelector(next) ? undefined : next; + draft.datasource = nextDatasource; + }) + ); + return; + } + + throw new Error('Got unexpected non-Prometheus datasource selector'); + }; + + return ( + + + + + + + ); +} diff --git a/prometheus/src/components/annotations/get-annotation-data.ts b/prometheus/src/components/annotations/get-annotation-data.ts new file mode 100644 index 000000000..1c607145c --- /dev/null +++ b/prometheus/src/components/annotations/get-annotation-data.ts @@ -0,0 +1,89 @@ +import { AnnotationData } from '@perses-dev/spec'; +import { AnnotationContext, datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system'; +import { DatasourceSpec, parseDurationString } from '@perses-dev/core'; +import { milliseconds } from 'date-fns'; +import { DEFAULT_SCRAPE_INTERVAL, PrometheusDatasourceSpec, PrometheusPromQLAnnotationOptions } from '../../plugins'; +import { + DEFAULT_PROM, + getPrometheusTimeRange, + getRangeStep, + PROM_DATASOURCE_KIND, + PrometheusClient, +} from '../../model'; + +export const getAnnotationData = async ( + spec: PrometheusPromQLAnnotationOptions, + context: AnnotationContext, + abortSignal?: AbortSignal +): Promise => { + if (!spec.expr) { + return []; + } + + const listDatasourceSelectItems = await context.datasourceStore.listDatasourceSelectItems(PROM_DATASOURCE_KIND); + + const datasourceSelector = + datasourceSelectValueToSelector( + spec.datasource ?? DEFAULT_PROM, + context.variableState, + listDatasourceSelectItems + ) ?? DEFAULT_PROM; + + const client: PrometheusClient = await context.datasourceStore.getDatasourceClient(datasourceSelector); + + const datasource = (await context.datasourceStore.getDatasource( + datasourceSelector + )) as DatasourceSpec; + + const datasourceScrapeInterval = Math.trunc( + milliseconds(parseDurationString(datasource.plugin.spec.scrapeInterval ?? DEFAULT_SCRAPE_INTERVAL)) / 1000 + ); + + const timeRange = getPrometheusTimeRange(context.absoluteTimeRange); + + const step = getRangeStep(timeRange, datasourceScrapeInterval); + + const utcOffsetSec = new Date().getTimezoneOffset() * 60; + + const alignedStart = Math.floor((timeRange.start + utcOffsetSec) / step) * step - utcOffsetSec; + let alignedEnd = Math.floor((timeRange.end + utcOffsetSec) / step) * step - utcOffsetSec; + + /* Ensure end is always greater than start: + If the step is greater than equal to the diff of end and start, + both start, and end will eventually be rounded to the same value, + Consequently, the time range will be zero, which does not return any valid value + */ + if (alignedStart === alignedEnd) { + alignedEnd = alignedStart + step; + console.warn(`Step (${step}) was larger than the time range! end of time range was set accordingly.`); + } + + const { data } = await client.rangeQuery( + { + query: replaceVariables(spec.expr, context.variableState), + start: alignedStart, + end: alignedEnd, + step: step, + }, + undefined, + abortSignal + ); + + const result: AnnotationData[] = []; + for (const series of data?.result ?? []) { + const start = series.values[0]?.[0]; + const end = series.values[series.values.length - 1]?.[0]; + + if (start !== undefined && end !== undefined) { + result.push({ + start: start, + end: end, + title: 'TODO title', + legend: 'TODO legend', + tags: { todo: 'tags' }, + }); + } + } + + return result; +}; diff --git a/prometheus/src/components/annotations/index.ts b/prometheus/src/components/annotations/index.ts new file mode 100644 index 000000000..ed674aa35 --- /dev/null +++ b/prometheus/src/components/annotations/index.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the \"License\"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an \"AS IS\" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './PrometheusPromQLAnnotation'; diff --git a/prometheus/src/index.ts b/prometheus/src/index.ts index 1832fbac9..8cfe439ff 100644 --- a/prometheus/src/index.ts +++ b/prometheus/src/index.ts @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +export * from './annotations'; export * from './components'; export * from './explore'; export { getPluginModule } from './getPluginModule'; diff --git a/prometheus/src/plugins/types.ts b/prometheus/src/plugins/types.ts index 5e9b5e56a..06bd99bba 100644 --- a/prometheus/src/plugins/types.ts +++ b/prometheus/src/plugins/types.ts @@ -45,3 +45,8 @@ export type PrometheusPromQLVariableOptions = PrometheusVariableOptionsBase & { // Note: This field is not part of the Prometheus API. labelName: string; }; + +export interface PrometheusPromQLAnnotationOptions { + datasource?: DatasourceSelectValue; + expr: string; +} diff --git a/timeserieschart/package.json b/timeserieschart/package.json index b23007dfa..9f2c8cd5a 100644 --- a/timeserieschart/package.json +++ b/timeserieschart/package.json @@ -1,6 +1,6 @@ { "name": "@perses-dev/timeseries-chart-plugin", - "version": "0.13.0-beta.2", + "version": "0.13.0-beta.1", "license": "Apache-2.0", "homepage": "https://github.com/perses/plugins/blob/main/README.md", "repository": { @@ -32,6 +32,7 @@ "@emotion/styled": "^11.6.0", "@hookform/resolvers": "^3.2.0", "@perses-dev/components": "^0.54.0-beta.8", + "@perses-dev/dashboards": "^0.54.0-beta.8", "@perses-dev/plugin-system": "^0.54.0-beta.8", "@perses-dev/spec": "^0.2.0-beta.6", "date-fns": "^4.1.0", diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index b1f759a23..4bee5b553 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { forwardRef, MouseEvent, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { Box } from '@mui/material'; +import { Box, Portal } from '@mui/material'; import merge from 'lodash/merge'; import isEqual from 'lodash/isEqual'; @@ -62,6 +62,7 @@ import { } from '@perses-dev/components'; import { DatasetOption } from 'echarts/types/dist/shared'; import { TimeScale, TimeSeries } from '@perses-dev/spec'; +import { AnnotationDefinitionWithData } from '@perses-dev/dashboards'; import { createTimezoneAwareAxisFormatter } from './utils/timezone-formatter'; use([ @@ -79,10 +80,45 @@ use([ CanvasRenderer, ]); +const DUMMY_ANNOTATIONS: AnnotationDefinitionWithData[] = [ + { + definition: { + display: { + name: 'test', + color: 'red', + }, + plugin: {}, + }, + data: { + start: Date.now() - 10 * 60 * 1000, // 10 minutes ago + end: Date.now(), + title: 'Test Annotation', + legend: 'Deployment v1.2.3', + tags: { environment: 'production', team: 'platform' }, + }, + }, + { + definition: { + display: { + name: 'test21', + color: 'green', + }, + plugin: {}, + }, + data: { + start: Date.now() - 30 * 60 * 1000, // 30 minutes ago (point annotation) + title: 'Single Event', + legend: 'Config Change', + tags: { type: 'config', user: 'admin' }, + }, + }, +]; + export interface TimeChartProps { height: number; data: TimeSeries[]; seriesMapping: TimeChartSeriesMapping; + annotations?: AnnotationDefinitionWithData[]; timeScale?: TimeScale; yAxis?: YAXisComponentOption | YAXisComponentOption[]; format?: FormatOptions; @@ -129,6 +165,8 @@ export const TimeSeriesChartBase = forwardRef(fun const [pinnedCrosshair, setPinnedCrosshair] = useState(null); const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); + const [hoveredAnnotation, setHoveredAnnotation] = useState(null); + const [annotationTooltipPos, setAnnotationTooltipPos] = useState<{ x: number; y: number } | null>(null); const { timeZone } = useTimeZone(); const getTimezoneAwareAxisFormatter = useCallback( @@ -199,9 +237,155 @@ export const TimeSeriesChartBase = forwardRef(fun enableDataZoom(chartRef.current); } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mouseover: (params: any): void => { + // Handle annotation hover for markPoint (triangle markers under X-axis) + if (params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { + const annotations = DUMMY_ANNOTATIONS; + const annotationIndex = params.data.annotationIndex; + const matchedAnnotation = annotations[annotationIndex] || null; + if (matchedAnnotation) { + setHoveredAnnotation(matchedAnnotation); + setAnnotationTooltipPos({ x: params.event?.offsetX || 0, y: params.event?.offsetY || 0 }); + } + } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mouseout: (params: any): void => { + if (params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { + setHoveredAnnotation(null); + setAnnotationTooltipPos(null); + } + }, }; }, [onDataZoom, setTooltipPinnedCoords]); + // Generate annotation series for ECharts markArea (range), markLine (point), and markPoint (markers under X-axis) + const annotationSeries = useMemo(() => { + const annotations = DUMMY_ANNOTATIONS; // Using dummy annotations for testing + if (!annotations || annotations.length === 0) return []; + + const markAreaData: Array<[{ xAxis: number; itemStyle?: { color: string; opacity: number } }, { xAxis: number }]> = + []; + const markLineData: Array<{ + xAxis: number; + lineStyle?: { color: string; width: number; type: 'dashed' | 'solid' | 'dotted' }; + label?: { show: boolean }; + }> = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const markPointData: any[] = []; + + annotations.forEach((annotation, index) => { + const color = '#FF6B6B'; // TODO + const opacity = 0.3; // TODO + + if (annotation.end !== undefined) { + // Range annotation - use markArea and markLine (silent) + markers at start/end + markLineData.push({ + xAxis: annotation.start, + lineStyle: { color, width: 2, type: 'dashed' as const }, + label: { show: false }, + }); + + markLineData.push({ + xAxis: annotation.end, + lineStyle: { color, width: 2, type: 'dashed' as const }, + label: { show: false }, + }); + + markAreaData.push([ + { + xAxis: annotation.start, + itemStyle: { color, opacity }, + }, + { xAxis: annotation.end }, + ]); + + // Add start marker + markPointData.push({ + coord: [annotation.start, 0], + symbol: 'triangle', + symbolSize: [12, 10], + symbolRotate: 0, + symbolOffset: [0, '120%'], // Position below X-axis + itemStyle: { color }, + annotationIndex: index, + isStart: true, + }); + + // Add end marker + markPointData.push({ + coord: [annotation.end, 0], + symbol: 'triangle', + symbolSize: [12, 10], + symbolRotate: 0, + symbolOffset: [0, '120%'], // Position below X-axis + itemStyle: { color }, + annotationIndex: index, + isEnd: true, + }); + } else { + // Point annotation - use markLine (silent) + single marker + markLineData.push({ + xAxis: annotation.start, + lineStyle: { color, width: 2, type: 'dashed' as const }, + label: { show: false }, + }); + + // Add point marker + markPointData.push({ + coord: [annotation.start, 0], + symbol: 'triangle', + symbolSize: [12, 10], + symbolRotate: 0, + symbolOffset: [0, '120%'], // Position below X-axis + itemStyle: { color }, + annotationIndex: index, + isPoint: true, + }); + } + }); + + const series: LineSeriesOption = { + type: 'line', + data: [], + silent: false, + markArea: + markAreaData.length > 0 + ? { + silent: true, // Make area silent, only markers are interactive + data: markAreaData, + label: { + show: false, + }, + } + : undefined, + markLine: + markLineData.length > 0 + ? { + silent: true, // Make line silent, only markers are interactive + symbol: ['none', 'none'], + data: markLineData, + lineStyle: { + type: 'dashed', + }, + } + : undefined, + markPoint: + markPointData.length > 0 + ? { + silent: false, // Markers are interactive + data: markPointData, + label: { + show: false, + }, + } + : undefined, + }; + + return [series]; + }, []); + const { noDataOption } = chartsTheme; const option: EChartsCoreOption = useMemo(() => { @@ -221,7 +405,9 @@ export const TimeSeriesChartBase = forwardRef(fun }); const updatedSeriesMapping = - enablePinning && pinnedCrosshair !== null ? [...seriesMapping, pinnedCrosshair] : seriesMapping; + enablePinning && pinnedCrosshair !== null + ? [...seriesMapping, pinnedCrosshair, ...annotationSeries] + : [...seriesMapping, ...annotationSeries]; const option: EChartsCoreOption = { dataset: dataset, @@ -276,6 +462,7 @@ export const TimeSeriesChartBase = forwardRef(fun }, [ data, seriesMapping, + annotationSeries, timeScale, yAxis, format, @@ -457,6 +644,65 @@ export const TimeSeriesChartBase = forwardRef(fun }} /> )} + {/* Annotation tooltip */} + {hoveredAnnotation && annotationTooltipPos && ( + + + {hoveredAnnotation.title && ( + + {hoveredAnnotation.title} + + )} + {hoveredAnnotation.legend && ( + {hoveredAnnotation.legend} + )} + + {new Date(hoveredAnnotation.start).toLocaleString()} + {hoveredAnnotation.end && ` - ${new Date(hoveredAnnotation.end).toLocaleString()}`} + + {hoveredAnnotation.tags && Object.keys(hoveredAnnotation.tags).length > 0 && ( + + {Object.entries(hoveredAnnotation.tags).map(([key, value]) => ( + + {key}: {value} + + ))} + + )} + + + )} ['sorting']>(); const { setTimeRange } = useTimeRange(); + const annotations = useAnnotationsWithData(); + console.log(annotations); // Populate series data based on query results const { @@ -494,6 +497,7 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement height={height} data={timeChartData} seriesMapping={timeSeriesMapping} + annotations={annotations} timeScale={timeScale} yAxis={multipleYAxes ?? echartsYAxis} format={format} From e37cb489094df533f93a5a14cc8e6623fffe878b Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 22 Apr 2026 15:23:55 +0200 Subject: [PATCH 02/15] Dummy examples working Signed-off-by: Guillaume LADORME --- prometheus/rsbuild.config.ts | 1 + .../PrometheusPromQLAnnotation.tsx | 2 +- ...PrometheusPromQLAnnotationOptionEditor.tsx | 33 +++++++++---- .../annotations/get-annotation-data.ts | 10 +--- .../src/{components => }/annotations/index.ts | 0 timeserieschart/src/TimeSeriesChartBase.tsx | 46 +++++++------------ timeserieschart/src/TimeSeriesChartPanel.tsx | 7 ++- timeserieschart/src/utils/annotation.ts | 19 ++++++++ 8 files changed, 68 insertions(+), 50 deletions(-) rename prometheus/src/{components => }/annotations/PrometheusPromQLAnnotation.tsx (91%) rename prometheus/src/{components => }/annotations/PrometheusPromQLAnnotationOptionEditor.tsx (80%) rename prometheus/src/{components => }/annotations/get-annotation-data.ts (93%) rename prometheus/src/{components => }/annotations/index.ts (100%) create mode 100644 timeserieschart/src/utils/annotation.ts diff --git a/prometheus/rsbuild.config.ts b/prometheus/rsbuild.config.ts index cdf2ae1a9..c4956cbb4 100644 --- a/prometheus/rsbuild.config.ts +++ b/prometheus/rsbuild.config.ts @@ -28,6 +28,7 @@ export default createConfigForPlugin({ './PrometheusLabelNamesVariable': './src/plugins/PrometheusLabelNamesVariable.tsx', './PrometheusPromQLVariable': './src/plugins/PrometheusPromQLVariable.tsx', './PrometheusExplorer': './src/explore/PrometheusExplorer.tsx', + './PrometheusPromQLAnnotation': './src/annotations/PrometheusPromQLAnnotation.tsx', }, shared: { react: { requiredVersion: '18.2.0', singleton: true }, diff --git a/prometheus/src/components/annotations/PrometheusPromQLAnnotation.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx similarity index 91% rename from prometheus/src/components/annotations/PrometheusPromQLAnnotation.tsx rename to prometheus/src/annotations/PrometheusPromQLAnnotation.tsx index 991f2412a..7b641253a 100644 --- a/prometheus/src/components/annotations/PrometheusPromQLAnnotation.tsx +++ b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx @@ -1,5 +1,5 @@ import { AnnotationPlugin, AnnotationQueryQueryPluginDependencies, parseVariables } from '@perses-dev/plugin-system'; -import { PrometheusPromQLAnnotationOptions } from '../../plugins'; +import { PrometheusPromQLAnnotationOptions } from '../plugins'; import { PrometheusPromQLAnnotationOptionEditor } from './PrometheusPromQLAnnotationOptionEditor'; import { getAnnotationData } from './get-annotation-data'; diff --git a/prometheus/src/components/annotations/PrometheusPromQLAnnotationOptionEditor.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx similarity index 80% rename from prometheus/src/components/annotations/PrometheusPromQLAnnotationOptionEditor.tsx rename to prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx index 42084fd8b..94da73fa4 100644 --- a/prometheus/src/components/annotations/PrometheusPromQLAnnotationOptionEditor.tsx +++ b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx @@ -5,10 +5,11 @@ import { FormControl, Stack } from '@mui/material'; import { DatasourceSelect, DatasourceSelectProps, + DatasourceSelectValue, + OptionsEditorProps, useDatasourceClient, useDatasourceSelectValueToSelector, } from '@perses-dev/plugin-system'; -import { PrometheusTimeSeriesQueryEditorProps, useQueryState } from '../../plugins'; import { DEFAULT_PROM, isDefaultPromSelector, @@ -16,15 +17,22 @@ import { PROM_DATASOURCE_KIND, PrometheusClient, PrometheusDatasourceSelector, -} from '../../model'; +} from '../model'; -import { PromQLEditor } from '../PromQLEditor'; +import { PromQLEditor } from '../components'; -export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusTimeSeriesQueryEditorProps): ReactElement { +export interface PrometheusAnnotationsQuerySpec { + expr: string; + datasource?: DatasourceSelectValue; +} + +export type PrometheusAnnotationsQueryEditorProps = OptionsEditorProps; + +export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotationsQueryEditorProps): ReactElement { const { onChange, value, - value: { query, datasource }, + value: { expr, datasource }, isReadonly, } = props; @@ -40,8 +48,6 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusTimeSeri const { data: client } = useDatasourceClient(selectedDatasource); const promURL = client?.options.datasourceUrl; - const { handleQueryChange, handleQueryBlur } = useQueryState(props); - const handleDatasourceChange: DatasourceSelectProps['onChange'] = (next) => { if (isPrometheusDatasourceSelector(next)) { /* Good to know: The usage of onchange here causes an immediate spec update which eventually updates the panel @@ -61,6 +67,14 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusTimeSeri throw new Error('Got unexpected non-Prometheus datasource selector'); }; + const handleExprChange = (next: string) => { + onChange( + produce(value, (draft) => { + draft.expr = next; + }) + ); + }; + return ( @@ -76,10 +90,9 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusTimeSeri diff --git a/prometheus/src/components/annotations/get-annotation-data.ts b/prometheus/src/annotations/get-annotation-data.ts similarity index 93% rename from prometheus/src/components/annotations/get-annotation-data.ts rename to prometheus/src/annotations/get-annotation-data.ts index 1c607145c..d49a8ae08 100644 --- a/prometheus/src/components/annotations/get-annotation-data.ts +++ b/prometheus/src/annotations/get-annotation-data.ts @@ -2,14 +2,8 @@ import { AnnotationData } from '@perses-dev/spec'; import { AnnotationContext, datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system'; import { DatasourceSpec, parseDurationString } from '@perses-dev/core'; import { milliseconds } from 'date-fns'; -import { DEFAULT_SCRAPE_INTERVAL, PrometheusDatasourceSpec, PrometheusPromQLAnnotationOptions } from '../../plugins'; -import { - DEFAULT_PROM, - getPrometheusTimeRange, - getRangeStep, - PROM_DATASOURCE_KIND, - PrometheusClient, -} from '../../model'; +import { DEFAULT_SCRAPE_INTERVAL, PrometheusDatasourceSpec, PrometheusPromQLAnnotationOptions } from '../plugins'; +import { DEFAULT_PROM, getPrometheusTimeRange, getRangeStep, PROM_DATASOURCE_KIND, PrometheusClient } from '../model'; export const getAnnotationData = async ( spec: PrometheusPromQLAnnotationOptions, diff --git a/prometheus/src/components/annotations/index.ts b/prometheus/src/annotations/index.ts similarity index 100% rename from prometheus/src/components/annotations/index.ts rename to prometheus/src/annotations/index.ts diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 4bee5b553..debdc55e7 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -64,6 +64,7 @@ import { DatasetOption } from 'echarts/types/dist/shared'; import { TimeScale, TimeSeries } from '@perses-dev/spec'; import { AnnotationDefinitionWithData } from '@perses-dev/dashboards'; import { createTimezoneAwareAxisFormatter } from './utils/timezone-formatter'; +import { TimeSeriesAnnotation } from './utils/annotation'; use([ EChartsLineChart, @@ -80,37 +81,22 @@ use([ CanvasRenderer, ]); -const DUMMY_ANNOTATIONS: AnnotationDefinitionWithData[] = [ +const DUMMY_ANNOTATIONS: TimeSeriesAnnotation[] = [ { - definition: { - display: { - name: 'test', - color: 'red', - }, - plugin: {}, - }, - data: { - start: Date.now() - 10 * 60 * 1000, // 10 minutes ago - end: Date.now(), - title: 'Test Annotation', - legend: 'Deployment v1.2.3', - tags: { environment: 'production', team: 'platform' }, - }, + name: 'test', + color: 'red', + start: Date.now() - 10 * 60 * 1000, // 10 minutes ago + end: Date.now(), + title: 'Test Annotation', + legend: 'Deployment v1.2.3', + tags: { environment: 'production', team: 'platform' }, }, { - definition: { - display: { - name: 'test21', - color: 'green', - }, - plugin: {}, - }, - data: { - start: Date.now() - 30 * 60 * 1000, // 30 minutes ago (point annotation) - title: 'Single Event', - legend: 'Config Change', - tags: { type: 'config', user: 'admin' }, - }, + name: 'test', + start: Date.now() - 30 * 60 * 1000, // 30 minutes ago (point annotation) + title: 'Single Event', + legend: 'Config Change', + tags: { type: 'config', user: 'admin' }, }, ]; @@ -118,7 +104,7 @@ export interface TimeChartProps { height: number; data: TimeSeries[]; seriesMapping: TimeChartSeriesMapping; - annotations?: AnnotationDefinitionWithData[]; + annotations?: TimeSeriesAnnotation[]; timeScale?: TimeScale; yAxis?: YAXisComponentOption | YAXisComponentOption[]; format?: FormatOptions; @@ -165,7 +151,7 @@ export const TimeSeriesChartBase = forwardRef(fun const [pinnedCrosshair, setPinnedCrosshair] = useState(null); const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); - const [hoveredAnnotation, setHoveredAnnotation] = useState(null); + const [hoveredAnnotation, setHoveredAnnotation] = useState(null); const [annotationTooltipPos, setAnnotationTooltipPos] = useState<{ x: number; y: number } | null>(null); const { timeZone } = useTimeZone(); diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index ec1e610bf..6920f6f44 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -62,6 +62,7 @@ import { } from './utils/data-transform'; import { getSeriesColor } from './utils/palette-gen'; import { TimeSeriesChartBase } from './TimeSeriesChartBase'; +import { convertAnnotationToTimeSeriesAnnotation, TimeSeriesAnnotation } from './utils/annotation'; export type TimeSeriesChartProps = PanelProps; @@ -154,7 +155,11 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement const [legendSorting, setLegendSorting] = useState['sorting']>(); const { setTimeRange } = useTimeRange(); - const annotations = useAnnotationsWithData(); + const annotationsWithData = useAnnotationsWithData(); + const annotations: TimeSeriesAnnotation[] = useMemo( + () => convertAnnotationToTimeSeriesAnnotation(annotationsWithData), + [annotationsWithData] + ); console.log(annotations); // Populate series data based on query results diff --git a/timeserieschart/src/utils/annotation.ts b/timeserieschart/src/utils/annotation.ts new file mode 100644 index 000000000..3fdaad41d --- /dev/null +++ b/timeserieschart/src/utils/annotation.ts @@ -0,0 +1,19 @@ +import { AnnotationData, AnnotationDisplay } from '@perses-dev/spec'; +import { AnnotationDefinitionWithData } from '@perses-dev/dashboards'; + +export type TimeSeriesAnnotation = AnnotationData & AnnotationDisplay; + +export function convertAnnotationToTimeSeriesAnnotation( + annotations: AnnotationDefinitionWithData[] +): TimeSeriesAnnotation[] { + const result: TimeSeriesAnnotation[] = []; + for (const annotation of annotations) { + for (const item of annotation.data) { + result.push({ + ...annotation.definition.spec.display, + ...item, + }); + } + } + return result; +} From 2bbdd3707a9ccfecd009ea29c4d5901f4752aa36 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Thu, 23 Apr 2026 12:54:19 +0200 Subject: [PATCH 03/15] Move from annotation def to spec + add cue schema Signed-off-by: Guillaume LADORME --- .../prometheus-promql-annotation.cue | 25 +++++++++++++++++++ timeserieschart/src/utils/annotation.ts | 8 +++--- 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue diff --git a/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue new file mode 100644 index 000000000..857e0823f --- /dev/null +++ b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue @@ -0,0 +1,25 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "strings" + promDs "github.com/perses/plugins/prometheus/schemas/datasource:model" +) + +kind: "PrometheusPromQLAnnotation" +spec: close({ + promDs.#selector + expr: strings.MinRunes(1) +}) diff --git a/timeserieschart/src/utils/annotation.ts b/timeserieschart/src/utils/annotation.ts index 3fdaad41d..83ab1dcbb 100644 --- a/timeserieschart/src/utils/annotation.ts +++ b/timeserieschart/src/utils/annotation.ts @@ -1,16 +1,14 @@ import { AnnotationData, AnnotationDisplay } from '@perses-dev/spec'; -import { AnnotationDefinitionWithData } from '@perses-dev/dashboards'; +import { AnnotationSpecWithData } from '@perses-dev/dashboards'; export type TimeSeriesAnnotation = AnnotationData & AnnotationDisplay; -export function convertAnnotationToTimeSeriesAnnotation( - annotations: AnnotationDefinitionWithData[] -): TimeSeriesAnnotation[] { +export function convertAnnotationToTimeSeriesAnnotation(annotations: AnnotationSpecWithData[]): TimeSeriesAnnotation[] { const result: TimeSeriesAnnotation[] = []; for (const annotation of annotations) { for (const item of annotation.data) { result.push({ - ...annotation.definition.spec.display, + ...annotation.definition.display, ...item, }); } From 59cba3916b3782b38865279ee2a6f2cb66266eed Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Thu, 23 Apr 2026 17:14:24 +0200 Subject: [PATCH 04/15] Fix context singleton Signed-off-by: Guillaume LADORME --- timeserieschart/rsbuild.config.ts | 1 + timeserieschart/src/TimeSeriesChartBase.tsx | 53 ++++++++++---------- timeserieschart/src/TimeSeriesChartPanel.tsx | 5 +- timeserieschart/src/utils/annotation.ts | 2 + 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/timeserieschart/rsbuild.config.ts b/timeserieschart/rsbuild.config.ts index 6eb1d36ae..af5637e6c 100644 --- a/timeserieschart/rsbuild.config.ts +++ b/timeserieschart/rsbuild.config.ts @@ -33,6 +33,7 @@ export default createConfigForPlugin({ lodash: { singleton: true }, '@perses-dev/components': { singleton: true }, '@perses-dev/plugin-system': { singleton: true }, + '@perses-dev/dashboards': { singleton: true }, '@emotion/react': { requiredVersion: '^11.11.3', singleton: true }, '@emotion/styled': { singleton: true }, '@hookform/resolvers': { singleton: true }, diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index debdc55e7..dfa726e56 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -81,24 +81,24 @@ use([ CanvasRenderer, ]); -const DUMMY_ANNOTATIONS: TimeSeriesAnnotation[] = [ - { - name: 'test', - color: 'red', - start: Date.now() - 10 * 60 * 1000, // 10 minutes ago - end: Date.now(), - title: 'Test Annotation', - legend: 'Deployment v1.2.3', - tags: { environment: 'production', team: 'platform' }, - }, - { - name: 'test', - start: Date.now() - 30 * 60 * 1000, // 30 minutes ago (point annotation) - title: 'Single Event', - legend: 'Config Change', - tags: { type: 'config', user: 'admin' }, - }, -]; +// const DUMMY_ANNOTATIONS: TimeSeriesAnnotation[] = [ +// { +// name: 'test', +// color: 'red', +// start: Date.now() - 10 * 60 * 1000, // 10 minutes ago +// end: Date.now(), +// title: 'Test Annotation', +// legend: 'Deployment v1.2.3', +// tags: { environment: 'production', team: 'platform' }, +// }, +// { +// name: 'test', +// start: Date.now() - 30 * 60 * 1000, // 30 minutes ago (point annotation) +// title: 'Single Event', +// legend: 'Config Change', +// tags: { type: 'config', user: 'admin' }, +// }, +// ]; export interface TimeChartProps { height: number; @@ -127,6 +127,7 @@ export const TimeSeriesChartBase = forwardRef(fun height, data, seriesMapping, + annotations, timeScale: timeScaleProp, yAxis, format, @@ -226,8 +227,8 @@ export const TimeSeriesChartBase = forwardRef(fun // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseover: (params: any): void => { // Handle annotation hover for markPoint (triangle markers under X-axis) - if (params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { - const annotations = DUMMY_ANNOTATIONS; + if (annotations && params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { + //const annotations = DUMMY_ANNOTATIONS; const annotationIndex = params.data.annotationIndex; const matchedAnnotation = annotations[annotationIndex] || null; if (matchedAnnotation) { @@ -244,11 +245,11 @@ export const TimeSeriesChartBase = forwardRef(fun } }, }; - }, [onDataZoom, setTooltipPinnedCoords]); + }, [annotations, onDataZoom]); // Generate annotation series for ECharts markArea (range), markLine (point), and markPoint (markers under X-axis) const annotationSeries = useMemo(() => { - const annotations = DUMMY_ANNOTATIONS; // Using dummy annotations for testing + //const annotations = DUMMY_ANNOTATIONS; // Using dummy annotations for testing if (!annotations || annotations.length === 0) return []; const markAreaData: Array<[{ xAxis: number; itemStyle?: { color: string; opacity: number } }, { xAxis: number }]> = @@ -370,7 +371,7 @@ export const TimeSeriesChartBase = forwardRef(fun }; return [series]; - }, []); + }, [annotations]); const { noDataOption } = chartsTheme; @@ -646,7 +647,7 @@ export const TimeSeriesChartBase = forwardRef(fun top: annotationTooltipPos.y + 10, backgroundColor: 'background.paper', border: '1px solid', - borderColor: 'divider', // TODO: hoveredAnnotation.color || 'divider', + borderColor: hoveredAnnotation.color || 'divider', borderRadius: 1, padding: 1.5, boxShadow: 3, @@ -657,9 +658,7 @@ export const TimeSeriesChartBase = forwardRef(fun }} > {hoveredAnnotation.title && ( - - {hoveredAnnotation.title} - + {hoveredAnnotation.title} )} {hoveredAnnotation.legend && ( {hoveredAnnotation.legend} diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index 6920f6f44..e9a2768ba 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -155,12 +155,15 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement const [legendSorting, setLegendSorting] = useState['sorting']>(); const { setTimeRange } = useTimeRange(); + const annotationsWithData = useAnnotationsWithData(); + console.log('annotationsWithData', annotationsWithData); + const annotations: TimeSeriesAnnotation[] = useMemo( () => convertAnnotationToTimeSeriesAnnotation(annotationsWithData), [annotationsWithData] ); - console.log(annotations); + console.log('annotations', annotations); // Populate series data based on query results const { diff --git a/timeserieschart/src/utils/annotation.ts b/timeserieschart/src/utils/annotation.ts index 83ab1dcbb..fe5667824 100644 --- a/timeserieschart/src/utils/annotation.ts +++ b/timeserieschart/src/utils/annotation.ts @@ -10,6 +10,8 @@ export function convertAnnotationToTimeSeriesAnnotation(annotations: AnnotationS result.push({ ...annotation.definition.display, ...item, + start: item.start * 1000, + end: item.end ? item.end * 1000 : undefined, }); } } From 512b52b8d07d91b3fff43c447db669b536c83b4a Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Fri, 24 Apr 2026 15:50:15 +0200 Subject: [PATCH 05/15] Improve tooltip display Signed-off-by: Guillaume LADORME --- timeserieschart/src/TimeSeriesChartBase.tsx | 268 +++++++++++++++----- 1 file changed, 204 insertions(+), 64 deletions(-) diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index dfa726e56..7f2c675ef 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -12,7 +12,10 @@ // limitations under the License. import { forwardRef, MouseEvent, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { Box, Portal } from '@mui/material'; +import { Box, Divider, Portal, Stack, Typography } from '@mui/material'; +import Pin from 'mdi-material-ui/Pin'; +import PinOutline from 'mdi-material-ui/PinOutline'; +import useResizeObserver from 'use-resize-observer'; import merge from 'lodash/merge'; import isEqual from 'lodash/isEqual'; @@ -38,6 +41,7 @@ import { } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; import { + assembleTransform, ChartInstance, ChartInstanceFocusOpts, clearHighlightedSeries, @@ -51,12 +55,18 @@ import { getCommonTimeScale, getFormattedAxis, getPointInGrid, + getTooltipStyles, OnEventsType, + PIN_TOOLTIP_HELP_TEXT, restoreChart, TimeChartSeriesMapping, TimeChartTooltip, + TOOLTIP_BG_COLOR_FALLBACK, + TOOLTIP_MAX_WIDTH, TooltipConfig, + UNPIN_TOOLTIP_HELP_TEXT, useChartsContext, + useMousePosition, useTimeZone, ZoomEventData, } from '@perses-dev/components'; @@ -153,8 +163,9 @@ export const TimeSeriesChartBase = forwardRef(fun const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); const [hoveredAnnotation, setHoveredAnnotation] = useState(null); - const [annotationTooltipPos, setAnnotationTooltipPos] = useState<{ x: number; y: number } | null>(null); - const { timeZone } = useTimeZone(); + const [pinnedAnnotation, setPinnedAnnotation] = useState(null); + const [pinnedAnnotationPos, setPinnedAnnotationPos] = useState(null); + const { timeZone, formatWithUserTimeZone } = useTimeZone(); const getTimezoneAwareAxisFormatter = useCallback( (rangeMs: number): ((value: number) => string) => createTimezoneAwareAxisFormatter(rangeMs, timeZone), @@ -226,22 +237,26 @@ export const TimeSeriesChartBase = forwardRef(fun }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseover: (params: any): void => { - // Handle annotation hover for markPoint (triangle markers under X-axis) - if (annotations && params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { - //const annotations = DUMMY_ANNOTATIONS; + // Handle annotation hover for markPoint (triangles under X-axis) and markLine (vertical dashed lines) + if ( + annotations && + (params.componentType === 'markPoint' || params.componentType === 'markLine') && + params.data?.annotationIndex !== undefined + ) { const annotationIndex = params.data.annotationIndex; const matchedAnnotation = annotations[annotationIndex] || null; if (matchedAnnotation) { setHoveredAnnotation(matchedAnnotation); - setAnnotationTooltipPos({ x: params.event?.offsetX || 0, y: params.event?.offsetY || 0 }); } } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseout: (params: any): void => { - if (params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { + if ( + (params.componentType === 'markPoint' || params.componentType === 'markLine') && + params.data?.annotationIndex !== undefined + ) { setHoveredAnnotation(null); - setAnnotationTooltipPos(null); } }, }; @@ -258,6 +273,7 @@ export const TimeSeriesChartBase = forwardRef(fun xAxis: number; lineStyle?: { color: string; width: number; type: 'dashed' | 'solid' | 'dotted' }; label?: { show: boolean }; + annotationIndex?: number; }> = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const markPointData: any[] = []; @@ -272,12 +288,14 @@ export const TimeSeriesChartBase = forwardRef(fun xAxis: annotation.start, lineStyle: { color, width: 2, type: 'dashed' as const }, label: { show: false }, + annotationIndex: index, }); markLineData.push({ xAxis: annotation.end, lineStyle: { color, width: 2, type: 'dashed' as const }, label: { show: false }, + annotationIndex: index, }); markAreaData.push([ @@ -317,6 +335,7 @@ export const TimeSeriesChartBase = forwardRef(fun xAxis: annotation.start, lineStyle: { color, width: 2, type: 'dashed' as const }, label: { show: false }, + annotationIndex: index, }); // Add point marker @@ -350,12 +369,15 @@ export const TimeSeriesChartBase = forwardRef(fun markLine: markLineData.length > 0 ? { - silent: true, // Make line silent, only markers are interactive + silent: false, // Interactive so vertical line hover opens annotation tooltip symbol: ['none', 'none'], data: markLineData, lineStyle: { type: 'dashed', }, + emphasis: { + disabled: true, + }, } : undefined, markPoint: @@ -488,6 +510,26 @@ export const TimeSeriesChartBase = forwardRef(fun // e.preventDefault(); // Prevent the default behaviour when right clicked // }} onClick={(e) => { + // 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) { + 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, + }; + setPinnedAnnotation((current) => { + if (current === hoveredAnnotation) { + setPinnedAnnotationPos(null); + return null; + } + setPinnedAnnotationPos(pinnedPos); + return hoveredAnnotation; + }); + 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) { @@ -609,8 +651,10 @@ export const TimeSeriesChartBase = forwardRef(fun } }} > - {/* Allows overrides prop to hide custom tooltip and use the ECharts option.tooltip instead */} + {/* 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) && (option.tooltip as TooltipComponentOption)?.showContent === false && tooltipConfig.hidden !== true && ( (fun }} /> )} - {/* Annotation tooltip */} - {hoveredAnnotation && annotationTooltipPos && ( - + {/* Annotation tooltip - reuses TimeChartTooltip styling. Pinned takes priority over hovered. */} + {(pinnedAnnotation ?? hoveredAnnotation) && ( + { + setPinnedAnnotation(null); + setPinnedAnnotationPos(null); + }} + /> + )} + + + ); +}); + +interface AnnotationTooltipProps { + annotation: TimeSeriesAnnotation; + containerId?: string; + formatWithUserTimeZone: (date: Date, formatString: string) => string; + pinnedPos: CursorCoordinates | null; + enablePinning?: boolean; + onUnpinClick?: () => void; +} + +function AnnotationTooltip({ + annotation, + containerId, + formatWithUserTimeZone, + pinnedPos, + enablePinning = true, + onUnpinClick, +}: AnnotationTooltipProps): JSX.Element | null { + const mousePos = useMousePosition(); + const { height, width, ref: tooltipRef } = useResizeObserver(); + + const isPinned = pinnedPos !== null; + if (!isPinned && mousePos === null) return null; + + const containerElement = containerId ? document.querySelector(containerId) : undefined; + const maxHeight = containerElement ? containerElement.getBoundingClientRect().height : undefined; + const transform = assembleTransform(mousePos, pinnedPos, height ?? 0, width ?? 0, containerElement); + + const formatDate = (timeMs: number): { date: string; time: string } => { + const d = new Date(timeMs); + return { + date: formatWithUserTimeZone(d, 'MMM dd, yyyy - '), + time: formatWithUserTimeZone(d, 'HH:mm:ss'), + }; + }; + + const start = formatDate(annotation.start); + const end = annotation.end !== undefined ? formatDate(annotation.end) : null; + + return ( + + getTooltipStyles(theme, pinnedPos, maxHeight)} style={{ transform }}> + ({ + width: '100%', + maxWidth: TOOLTIP_MAX_WIDTH, + padding: theme.spacing(1.5, 2, 0.5, 2), + backgroundColor: theme.palette.designSystem?.grey[800] ?? TOOLTIP_BG_COLOR_FALLBACK, + position: 'sticky', + top: 0, + left: 0, + })} > - {hoveredAnnotation.title && ( - {hoveredAnnotation.title} + + + + ({ color: theme.palette.common.white })}> + {start.date} + + + {start.time} + + {end && ( + <> + ({ color: theme.palette.common.white })}> + {' → '} + {end.date} + + + {end.time} + + + )} + + {enablePinning && ( + + + {isPinned ? UNPIN_TOOLTIP_HELP_TEXT : PIN_TOOLTIP_HELP_TEXT} + + {isPinned ? ( + { + if (onUnpinClick !== undefined) onUnpinClick(); + }} + sx={{ fontSize: 16, cursor: 'pointer' }} + /> + ) : ( + + )} + + )} + + ({ width: '100%', borderColor: theme.palette.grey['500'] })} /> + + ({ padding: theme.spacing(0.5, 2, 1.5, 2) })}> + {annotation.title && ( + + {annotation.title} + )} - {hoveredAnnotation.legend && ( - {hoveredAnnotation.legend} + {annotation.legend && ( + + {annotation.legend} + )} - - {new Date(hoveredAnnotation.start).toLocaleString()} - {hoveredAnnotation.end && ` - ${new Date(hoveredAnnotation.end).toLocaleString()}`} - - {hoveredAnnotation.tags && Object.keys(hoveredAnnotation.tags).length > 0 && ( + {annotation.tags && Object.keys(annotation.tags).length > 0 && ( - {Object.entries(hoveredAnnotation.tags).map(([key, value]) => ( + {Object.entries(annotation.tags).map(([key, value]) => ( ({ + backgroundColor: theme.palette.grey['700'], + borderRadius: '4px', padding: '2px 6px', fontSize: '0.7rem', fontFamily: 'monospace', - }} + })} > {key}: {value} @@ -686,19 +837,8 @@ export const TimeSeriesChartBase = forwardRef(fun )} - - )} - - + + + ); -}); +} From adcb504ca05d0f8b0d51fd9202c144bc5ef1d581 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 27 Apr 2026 11:40:35 +0200 Subject: [PATCH 06/15] Add formating + improve tooltip + markPoint Signed-off-by: Guillaume LADORME --- ...PrometheusPromQLAnnotationOptionEditor.tsx | 73 ++++++++++++++++++- .../src/annotations/get-annotation-data.ts | 20 ++++- prometheus/src/plugins/types.ts | 3 + timeserieschart/src/TimeSeriesChartBase.tsx | 39 ++++------ timeserieschart/src/TimeSeriesChartPanel.tsx | 2 - 5 files changed, 103 insertions(+), 34 deletions(-) diff --git a/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx index 94da73fa4..da208ff26 100644 --- a/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx +++ b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx @@ -1,7 +1,7 @@ import { ReactElement } from 'react'; import { useId } from '@perses-dev/components'; import { produce } from 'immer'; -import { FormControl, Stack } from '@mui/material'; +import { Autocomplete, Chip, FormControl, Stack, TextField } from '@mui/material'; import { DatasourceSelect, DatasourceSelectProps, @@ -24,6 +24,9 @@ import { PromQLEditor } from '../components'; export interface PrometheusAnnotationsQuerySpec { expr: string; datasource?: DatasourceSelectValue; + title?: string; + legend?: string; + tags?: string[]; } export type PrometheusAnnotationsQueryEditorProps = OptionsEditorProps; @@ -32,7 +35,7 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotati const { onChange, value, - value: { expr, datasource }, + value: { expr, datasource, title, legend, tags }, isReadonly, } = props; @@ -67,7 +70,7 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotati throw new Error('Got unexpected non-Prometheus datasource selector'); }; - const handleExprChange = (next: string) => { + const handleExprChange = (next: string): void => { onChange( produce(value, (draft) => { draft.expr = next; @@ -75,6 +78,30 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotati ); }; + const handleTitleChange = (next: string): void => { + onChange( + produce(value, (draft) => { + draft.title = next || undefined; + }) + ); + }; + + const handleLegendChange = (next: string): void => { + onChange( + produce(value, (draft) => { + draft.legend = next || undefined; + }) + ); + }; + + const handleTagsChange = (next: string[]): void => { + onChange( + produce(value, (draft) => { + draft.tags = next.length > 0 ? next : undefined; + }) + ); + }; + return ( @@ -96,6 +123,46 @@ export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotati isReadOnly={isReadonly} treeViewMetadata={undefined} /> + handleTitleChange(e.target.value)} + slotProps={{ + inputLabel: { shrink: isReadonly ? true : undefined }, + input: { readOnly: isReadonly }, + }} + /> + handleLegendChange(e.target.value)} + slotProps={{ + inputLabel: { shrink: isReadonly ? true : undefined }, + input: { readOnly: isReadonly }, + }} + /> + handleTagsChange(next as string[])} + readOnly={isReadonly} + renderInput={(params) => ( + + )} + /> ); } diff --git a/prometheus/src/annotations/get-annotation-data.ts b/prometheus/src/annotations/get-annotation-data.ts index d49a8ae08..ea915c766 100644 --- a/prometheus/src/annotations/get-annotation-data.ts +++ b/prometheus/src/annotations/get-annotation-data.ts @@ -4,6 +4,7 @@ import { DatasourceSpec, parseDurationString } from '@perses-dev/core'; import { milliseconds } from 'date-fns'; import { DEFAULT_SCRAPE_INTERVAL, PrometheusDatasourceSpec, PrometheusPromQLAnnotationOptions } from '../plugins'; import { DEFAULT_PROM, getPrometheusTimeRange, getRangeStep, PROM_DATASOURCE_KIND, PrometheusClient } from '../model'; +import { formatSeriesName } from '../utils'; export const getAnnotationData = async ( spec: PrometheusPromQLAnnotationOptions, @@ -69,12 +70,25 @@ export const getAnnotationData = async ( const end = series.values[series.values.length - 1]?.[0]; if (start !== undefined && end !== undefined) { + const labels = series.metric ?? {}; + const title = spec.title ? formatSeriesName(spec.title, labels) : undefined; + const legend = spec.legend ? formatSeriesName(spec.legend, labels) : undefined; + // If spec.tags is provided, only expose the selected label names as tags. + // Otherwise, expose all labels. + const tags = + spec.tags && spec.tags.length > 0 + ? spec.tags.reduce>((acc, name) => { + const v = labels[name]; + if (v !== undefined) acc[name] = v; + return acc; + }, {}) + : labels; result.push({ start: start, end: end, - title: 'TODO title', - legend: 'TODO legend', - tags: { todo: 'tags' }, + title: title, + legend: legend, + tags: tags, }); } } diff --git a/prometheus/src/plugins/types.ts b/prometheus/src/plugins/types.ts index 06bd99bba..5a0286a21 100644 --- a/prometheus/src/plugins/types.ts +++ b/prometheus/src/plugins/types.ts @@ -49,4 +49,7 @@ export type PrometheusPromQLVariableOptions = PrometheusVariableOptionsBase & { export interface PrometheusPromQLAnnotationOptions { datasource?: DatasourceSelectValue; expr: string; + title?: string; + legend?: string; + tags?: string[]; } diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 7f2c675ef..e481877b2 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -91,25 +91,6 @@ use([ CanvasRenderer, ]); -// const DUMMY_ANNOTATIONS: TimeSeriesAnnotation[] = [ -// { -// name: 'test', -// color: 'red', -// start: Date.now() - 10 * 60 * 1000, // 10 minutes ago -// end: Date.now(), -// title: 'Test Annotation', -// legend: 'Deployment v1.2.3', -// tags: { environment: 'production', team: 'platform' }, -// }, -// { -// name: 'test', -// start: Date.now() - 30 * 60 * 1000, // 30 minutes ago (point annotation) -// title: 'Single Event', -// legend: 'Config Change', -// tags: { type: 'config', user: 'admin' }, -// }, -// ]; - export interface TimeChartProps { height: number; data: TimeSeries[]; @@ -279,8 +260,8 @@ export const TimeSeriesChartBase = forwardRef(fun const markPointData: any[] = []; annotations.forEach((annotation, index) => { - const color = '#FF6B6B'; // TODO - const opacity = 0.3; // TODO + const color = annotation.color ?? '#FF6B6B'; + const opacity = 0.3; if (annotation.end !== undefined) { // Range annotation - use markArea and markLine (silent) + markers at start/end @@ -310,11 +291,14 @@ export const TimeSeriesChartBase = forwardRef(fun markPointData.push({ coord: [annotation.start, 0], symbol: 'triangle', - symbolSize: [12, 10], + symbolSize: [12, 12], symbolRotate: 0, - symbolOffset: [0, '120%'], // Position below X-axis + symbolOffset: [0, 4], // Position below X-axis itemStyle: { color }, annotationIndex: index, + emphasis: { + disabled: true, + }, isStart: true, }); @@ -322,11 +306,14 @@ export const TimeSeriesChartBase = forwardRef(fun markPointData.push({ coord: [annotation.end, 0], symbol: 'triangle', - symbolSize: [12, 10], + symbolSize: [12, 12], symbolRotate: 0, - symbolOffset: [0, '120%'], // Position below X-axis + symbolOffset: [0, 4], // Position below X-axis itemStyle: { color }, annotationIndex: index, + emphasis: { + disabled: true, + }, isEnd: true, }); } else { @@ -763,7 +750,7 @@ function AnnotationTooltip({ width: 8, height: 8, borderRadius: '50%', - backgroundColor: annotation.color || '#FF6B6B', + backgroundColor: annotation.color ?? '#FF6B6B', marginRight: 1, flexShrink: 0, }} diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index e9a2768ba..b9339f638 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -157,13 +157,11 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement const { setTimeRange } = useTimeRange(); const annotationsWithData = useAnnotationsWithData(); - console.log('annotationsWithData', annotationsWithData); const annotations: TimeSeriesAnnotation[] = useMemo( () => convertAnnotationToTimeSeriesAnnotation(annotationsWithData), [annotationsWithData] ); - console.log('annotations', annotations); // Populate series data based on query results const { From 480b6a72af506450c58235a4995063c233a7b5fa Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 27 Apr 2026 13:58:22 +0200 Subject: [PATCH 07/15] Hide annotation if visility is hidden Signed-off-by: Guillaume LADORME --- timeserieschart/src/utils/annotation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/timeserieschart/src/utils/annotation.ts b/timeserieschart/src/utils/annotation.ts index fe5667824..1a46457d3 100644 --- a/timeserieschart/src/utils/annotation.ts +++ b/timeserieschart/src/utils/annotation.ts @@ -7,6 +7,9 @@ export function convertAnnotationToTimeSeriesAnnotation(annotations: AnnotationS const result: TimeSeriesAnnotation[] = []; for (const annotation of annotations) { for (const item of annotation.data) { + if (annotation.definition.display.hidden) { + continue; + } result.push({ ...annotation.definition.display, ...item, From df8a47b22cf290fa17556e415df004afb162e280 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 27 Apr 2026 15:09:04 +0200 Subject: [PATCH 08/15] Fix cue Signed-off-by: Guillaume LADORME --- .../prometheus-promql-annotation.cue | 5 ++++- timeserieschart/src/TimeSeriesChartBase.tsx | 14 ++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue index 857e0823f..2e181e9d4 100644 --- a/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue +++ b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue @@ -21,5 +21,8 @@ import ( kind: "PrometheusPromQLAnnotation" spec: close({ promDs.#selector - expr: strings.MinRunes(1) + expr: strings.MinRunes(1) + title?: string + legend?: string + tags?: [string] }) diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index e481877b2..a79023a84 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -721,7 +721,7 @@ function AnnotationTooltip({ const formatDate = (timeMs: number): { date: string; time: string } => { const d = new Date(timeMs); return { - date: formatWithUserTimeZone(d, 'MMM dd, yyyy - '), + date: formatWithUserTimeZone(d, 'MMM dd, yyyy'), time: formatWithUserTimeZone(d, 'HH:mm:ss'), }; }; @@ -756,20 +756,14 @@ function AnnotationTooltip({ }} /> - ({ color: theme.palette.common.white })}> - {start.date} - - {start.time} + {start.date} - {start.time} {end && ( <> - ({ color: theme.palette.common.white })}> - {' → '} - {end.date} - + {' → '} - {end.time} + {end.date} - {end.time} )} From 614493cff002bbebc0723ed53a215c1910f5047a Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 27 Apr 2026 15:28:46 +0200 Subject: [PATCH 09/15] Separate files Signed-off-by: Guillaume LADORME --- timeserieschart/src/TimeSeriesChartBase.tsx | 298 +---------------- .../src/annotations/AnnotationTooltip.tsx | 303 ++++++++++++++++++ 2 files changed, 310 insertions(+), 291 deletions(-) create mode 100644 timeserieschart/src/annotations/AnnotationTooltip.tsx diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index a79023a84..5624f760a 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -12,10 +12,7 @@ // limitations under the License. import { forwardRef, MouseEvent, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { Box, Divider, Portal, Stack, Typography } from '@mui/material'; -import Pin from 'mdi-material-ui/Pin'; -import PinOutline from 'mdi-material-ui/PinOutline'; -import useResizeObserver from 'use-resize-observer'; +import { Box } from '@mui/material'; import merge from 'lodash/merge'; import isEqual from 'lodash/isEqual'; @@ -41,7 +38,6 @@ import { } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; import { - assembleTransform, ChartInstance, ChartInstanceFocusOpts, clearHighlightedSeries, @@ -55,18 +51,12 @@ import { getCommonTimeScale, getFormattedAxis, getPointInGrid, - getTooltipStyles, OnEventsType, - PIN_TOOLTIP_HELP_TEXT, restoreChart, TimeChartSeriesMapping, TimeChartTooltip, - TOOLTIP_BG_COLOR_FALLBACK, - TOOLTIP_MAX_WIDTH, TooltipConfig, - UNPIN_TOOLTIP_HELP_TEXT, useChartsContext, - useMousePosition, useTimeZone, ZoomEventData, } from '@perses-dev/components'; @@ -75,6 +65,7 @@ import { TimeScale, TimeSeries } from '@perses-dev/spec'; import { AnnotationDefinitionWithData } from '@perses-dev/dashboards'; import { createTimezoneAwareAxisFormatter } from './utils/timezone-formatter'; import { TimeSeriesAnnotation } from './utils/annotation'; +import { AnnotationTooltip, buildAnnotationSeries } from './annotations/AnnotationTooltip'; use([ EChartsLineChart, @@ -218,12 +209,9 @@ export const TimeSeriesChartBase = forwardRef(fun }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseover: (params: any): void => { - // Handle annotation hover for markPoint (triangles under X-axis) and markLine (vertical dashed lines) - if ( - annotations && - (params.componentType === 'markPoint' || params.componentType === 'markLine') && - params.data?.annotationIndex !== undefined - ) { + // Only markPoint (triangles under the X-axis) opens the annotation tooltip. + // Hovering markLine keeps the regular TimeSeries tooltip visible. + if (annotations && params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { const annotationIndex = params.data.annotationIndex; const matchedAnnotation = annotations[annotationIndex] || null; if (matchedAnnotation) { @@ -233,10 +221,7 @@ export const TimeSeriesChartBase = forwardRef(fun }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseout: (params: any): void => { - if ( - (params.componentType === 'markPoint' || params.componentType === 'markLine') && - params.data?.annotationIndex !== undefined - ) { + if (params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { setHoveredAnnotation(null); } }, @@ -244,143 +229,7 @@ export const TimeSeriesChartBase = forwardRef(fun }, [annotations, onDataZoom]); // Generate annotation series for ECharts markArea (range), markLine (point), and markPoint (markers under X-axis) - const annotationSeries = useMemo(() => { - //const annotations = DUMMY_ANNOTATIONS; // Using dummy annotations for testing - if (!annotations || annotations.length === 0) return []; - - const markAreaData: Array<[{ xAxis: number; itemStyle?: { color: string; opacity: number } }, { xAxis: number }]> = - []; - const markLineData: Array<{ - xAxis: number; - lineStyle?: { color: string; width: number; type: 'dashed' | 'solid' | 'dotted' }; - label?: { show: boolean }; - annotationIndex?: number; - }> = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const markPointData: any[] = []; - - annotations.forEach((annotation, index) => { - const color = annotation.color ?? '#FF6B6B'; - const opacity = 0.3; - - if (annotation.end !== undefined) { - // Range annotation - use markArea and markLine (silent) + markers at start/end - markLineData.push({ - xAxis: annotation.start, - lineStyle: { color, width: 2, type: 'dashed' as const }, - label: { show: false }, - annotationIndex: index, - }); - - markLineData.push({ - xAxis: annotation.end, - lineStyle: { color, width: 2, type: 'dashed' as const }, - label: { show: false }, - annotationIndex: index, - }); - - markAreaData.push([ - { - xAxis: annotation.start, - itemStyle: { color, opacity }, - }, - { xAxis: annotation.end }, - ]); - - // Add start marker - markPointData.push({ - coord: [annotation.start, 0], - symbol: 'triangle', - symbolSize: [12, 12], - symbolRotate: 0, - symbolOffset: [0, 4], // Position below X-axis - itemStyle: { color }, - annotationIndex: index, - emphasis: { - disabled: true, - }, - isStart: true, - }); - - // Add end marker - markPointData.push({ - coord: [annotation.end, 0], - symbol: 'triangle', - symbolSize: [12, 12], - symbolRotate: 0, - symbolOffset: [0, 4], // Position below X-axis - itemStyle: { color }, - annotationIndex: index, - emphasis: { - disabled: true, - }, - isEnd: true, - }); - } else { - // Point annotation - use markLine (silent) + single marker - markLineData.push({ - xAxis: annotation.start, - lineStyle: { color, width: 2, type: 'dashed' as const }, - label: { show: false }, - annotationIndex: index, - }); - - // Add point marker - markPointData.push({ - coord: [annotation.start, 0], - symbol: 'triangle', - symbolSize: [12, 10], - symbolRotate: 0, - symbolOffset: [0, '120%'], // Position below X-axis - itemStyle: { color }, - annotationIndex: index, - isPoint: true, - }); - } - }); - - const series: LineSeriesOption = { - type: 'line', - data: [], - silent: false, - markArea: - markAreaData.length > 0 - ? { - silent: true, // Make area silent, only markers are interactive - data: markAreaData, - label: { - show: false, - }, - } - : undefined, - markLine: - markLineData.length > 0 - ? { - silent: false, // Interactive so vertical line hover opens annotation tooltip - symbol: ['none', 'none'], - data: markLineData, - lineStyle: { - type: 'dashed', - }, - emphasis: { - disabled: true, - }, - } - : undefined, - markPoint: - markPointData.length > 0 - ? { - silent: false, // Markers are interactive - data: markPointData, - label: { - show: false, - }, - } - : undefined, - }; - - return [series]; - }, [annotations]); + const annotationSeries = useMemo(() => buildAnnotationSeries(annotations), [annotations]); const { noDataOption } = chartsTheme; @@ -690,136 +539,3 @@ export const TimeSeriesChartBase = forwardRef(fun ); }); - -interface AnnotationTooltipProps { - annotation: TimeSeriesAnnotation; - containerId?: string; - formatWithUserTimeZone: (date: Date, formatString: string) => string; - pinnedPos: CursorCoordinates | null; - enablePinning?: boolean; - onUnpinClick?: () => void; -} - -function AnnotationTooltip({ - annotation, - containerId, - formatWithUserTimeZone, - pinnedPos, - enablePinning = true, - onUnpinClick, -}: AnnotationTooltipProps): JSX.Element | null { - const mousePos = useMousePosition(); - const { height, width, ref: tooltipRef } = useResizeObserver(); - - const isPinned = pinnedPos !== null; - if (!isPinned && mousePos === null) return null; - - const containerElement = containerId ? document.querySelector(containerId) : undefined; - const maxHeight = containerElement ? containerElement.getBoundingClientRect().height : undefined; - const transform = assembleTransform(mousePos, pinnedPos, height ?? 0, width ?? 0, containerElement); - - const formatDate = (timeMs: number): { date: string; time: string } => { - const d = new Date(timeMs); - return { - date: formatWithUserTimeZone(d, 'MMM dd, yyyy'), - time: formatWithUserTimeZone(d, 'HH:mm:ss'), - }; - }; - - const start = formatDate(annotation.start); - const end = annotation.end !== undefined ? formatDate(annotation.end) : null; - - return ( - - getTooltipStyles(theme, pinnedPos, maxHeight)} style={{ transform }}> - - ({ - width: '100%', - maxWidth: TOOLTIP_MAX_WIDTH, - padding: theme.spacing(1.5, 2, 0.5, 2), - backgroundColor: theme.palette.designSystem?.grey[800] ?? TOOLTIP_BG_COLOR_FALLBACK, - position: 'sticky', - top: 0, - left: 0, - })} - > - - - - - {start.date} - {start.time} - - {end && ( - <> - {' → '} - - {end.date} - {end.time} - - - )} - - {enablePinning && ( - - - {isPinned ? UNPIN_TOOLTIP_HELP_TEXT : PIN_TOOLTIP_HELP_TEXT} - - {isPinned ? ( - { - if (onUnpinClick !== undefined) onUnpinClick(); - }} - sx={{ fontSize: 16, cursor: 'pointer' }} - /> - ) : ( - - )} - - )} - - ({ width: '100%', borderColor: theme.palette.grey['500'] })} /> - - ({ padding: theme.spacing(0.5, 2, 1.5, 2) })}> - {annotation.title && ( - - {annotation.title} - - )} - {annotation.legend && ( - - {annotation.legend} - - )} - {annotation.tags && Object.keys(annotation.tags).length > 0 && ( - - {Object.entries(annotation.tags).map(([key, value]) => ( - ({ - backgroundColor: theme.palette.grey['700'], - borderRadius: '4px', - padding: '2px 6px', - fontSize: '0.7rem', - fontFamily: 'monospace', - })} - > - {key}: {value} - - ))} - - )} - - - - - ); -} diff --git a/timeserieschart/src/annotations/AnnotationTooltip.tsx b/timeserieschart/src/annotations/AnnotationTooltip.tsx new file mode 100644 index 000000000..5c230d3ed --- /dev/null +++ b/timeserieschart/src/annotations/AnnotationTooltip.tsx @@ -0,0 +1,303 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Box, Divider, Portal, Stack, Typography } from '@mui/material'; +import Pin from 'mdi-material-ui/Pin'; +import PinOutline from 'mdi-material-ui/PinOutline'; +import useResizeObserver from 'use-resize-observer'; +import type { LineSeriesOption } from 'echarts'; +import { + assembleTransform, + CursorCoordinates, + getTooltipStyles, + PIN_TOOLTIP_HELP_TEXT, + TOOLTIP_BG_COLOR_FALLBACK, + TOOLTIP_MAX_WIDTH, + UNPIN_TOOLTIP_HELP_TEXT, + useMousePosition, +} from '@perses-dev/components'; +import { TimeSeriesAnnotation } from '../utils/annotation'; + +export interface AnnotationTooltipProps { + annotation: TimeSeriesAnnotation; + containerId?: string; + formatWithUserTimeZone: (date: Date, formatString: string) => string; + pinnedPos: CursorCoordinates | null; + enablePinning?: boolean; + onUnpinClick?: () => void; +} + +export function AnnotationTooltip({ + annotation, + containerId, + formatWithUserTimeZone, + pinnedPos, + enablePinning = true, + onUnpinClick, +}: AnnotationTooltipProps): JSX.Element | null { + const mousePos = useMousePosition(); + const { height, width, ref: tooltipRef } = useResizeObserver(); + + const isPinned = pinnedPos !== null; + if (!isPinned && mousePos === null) return null; + + const containerElement = containerId ? document.querySelector(containerId) : undefined; + const maxHeight = containerElement ? containerElement.getBoundingClientRect().height : undefined; + const transform = assembleTransform(mousePos, pinnedPos, height ?? 0, width ?? 0, containerElement); + + const formatDate = (timeMs: number): { date: string; time: string } => { + const d = new Date(timeMs); + return { + date: formatWithUserTimeZone(d, 'MMM dd, yyyy'), + time: formatWithUserTimeZone(d, 'HH:mm:ss'), + }; + }; + + const start = formatDate(annotation.start); + const end = annotation.end !== undefined ? formatDate(annotation.end) : null; + + return ( + + getTooltipStyles(theme, pinnedPos, maxHeight)} style={{ transform }}> + + ({ + width: '100%', + maxWidth: TOOLTIP_MAX_WIDTH, + padding: theme.spacing(1.5, 2, 0.5, 2), + backgroundColor: theme.palette.designSystem?.grey[800] ?? TOOLTIP_BG_COLOR_FALLBACK, + position: 'sticky', + top: 0, + left: 0, + })} + > + + + + + {start.date} - {start.time} + + {end && ( + <> + {' → '} + + {end.date} - {end.time} + + + )} + + {enablePinning && ( + + + {isPinned ? UNPIN_TOOLTIP_HELP_TEXT : PIN_TOOLTIP_HELP_TEXT} + + {isPinned ? ( + { + if (onUnpinClick !== undefined) onUnpinClick(); + }} + sx={{ fontSize: 16, cursor: 'pointer' }} + /> + ) : ( + + )} + + )} + + ({ width: '100%', borderColor: theme.palette.grey['500'] })} /> + + ({ padding: theme.spacing(0.5, 2, 1.5, 2) })}> + {annotation.title && ( + + {annotation.title} + + )} + {annotation.legend && ( + + {annotation.legend} + + )} + {annotation.tags && Object.keys(annotation.tags).length > 0 && ( + + {Object.entries(annotation.tags).map(([key, value]) => ( + ({ + backgroundColor: theme.palette.grey['700'], + borderRadius: '4px', + padding: '2px 6px', + fontSize: '0.7rem', + fontFamily: 'monospace', + })} + > + {key}: {value} + + ))} + + )} + + + + + ); +} + +/** + * Build ECharts series options for rendering annotations as markArea (range), + * markLine (vertical dashed lines) and markPoint (triangle markers under the X-axis). + */ +export function buildAnnotationSeries(annotations: TimeSeriesAnnotation[] | undefined): LineSeriesOption[] { + if (!annotations || annotations.length === 0) return []; + + const markAreaData: Array<[{ xAxis: number; itemStyle?: { color: string; opacity: number } }, { xAxis: number }]> = + []; + const markLineData: Array<{ + xAxis: number; + lineStyle?: { color: string; width: number; type: 'dashed' | 'solid' | 'dotted' }; + label?: { show: boolean }; + annotationIndex?: number; + }> = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const markPointData: any[] = []; + + annotations.forEach((annotation, index) => { + const color = annotation.color ?? '#FF6B6B'; + const opacity = 0.3; + + if (annotation.end !== undefined) { + // Range annotation - use markArea and markLine (silent) + markers at start/end + markLineData.push({ + xAxis: annotation.start, + lineStyle: { color, width: 2, type: 'dashed' as const }, + label: { show: false }, + annotationIndex: index, + }); + + markLineData.push({ + xAxis: annotation.end, + lineStyle: { color, width: 2, type: 'dashed' as const }, + label: { show: false }, + annotationIndex: index, + }); + + markAreaData.push([ + { + xAxis: annotation.start, + itemStyle: { color, opacity }, + }, + { xAxis: annotation.end }, + ]); + + // Add start marker + markPointData.push({ + coord: [annotation.start, 0], + symbol: 'triangle', + symbolSize: [12, 12], + symbolRotate: 0, + symbolOffset: [0, 4], // Position below X-axis + itemStyle: { color }, + annotationIndex: index, + emphasis: { + disabled: true, + }, + isStart: true, + }); + + // Add end marker + markPointData.push({ + coord: [annotation.end, 0], + symbol: 'triangle', + symbolSize: [12, 12], + symbolRotate: 0, + symbolOffset: [0, 4], // Position below X-axis + itemStyle: { color }, + annotationIndex: index, + emphasis: { + disabled: true, + }, + isEnd: true, + }); + } else { + // Point annotation - use markLine (silent) + single marker + markLineData.push({ + xAxis: annotation.start, + lineStyle: { color, width: 2, type: 'dashed' as const }, + label: { show: false }, + annotationIndex: index, + }); + + // Add point marker + markPointData.push({ + coord: [annotation.start, 0], + symbol: 'triangle', + symbolSize: [12, 10], + symbolRotate: 0, + symbolOffset: [0, '120%'], // Position below X-axis + itemStyle: { color }, + annotationIndex: index, + isPoint: true, + }); + } + }); + + const series: LineSeriesOption = { + type: 'line', + data: [], + silent: false, + markArea: + markAreaData.length > 0 + ? { + silent: true, // Make area silent, only markers are interactive + data: markAreaData, + label: { + show: false, + }, + } + : undefined, + markLine: + markLineData.length > 0 + ? { + silent: true, // Non-interactive: hovering the dashed line keeps the TimeSeries tooltip + symbol: ['none', 'none'], + data: markLineData, + lineStyle: { + type: 'dashed', + }, + emphasis: { + disabled: true, + }, + } + : undefined, + markPoint: + markPointData.length > 0 + ? { + silent: false, // Markers are interactive + data: markPointData, + label: { + show: false, + }, + } + : undefined, + }; + + return [series]; +} From 9f36bdc52950e619f6fba49b190f0749d5c2dd8f Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Mon, 27 Apr 2026 15:45:08 +0200 Subject: [PATCH 10/15] Fix glitch tooltip Signed-off-by: Guillaume LADORME --- timeserieschart/src/TimeSeriesChartBase.tsx | 28 +++++++++++++++---- .../src/annotations/AnnotationTooltip.tsx | 3 -- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index 5624f760a..edb3941d0 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -210,18 +210,34 @@ export const TimeSeriesChartBase = forwardRef(fun // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseover: (params: any): void => { // Only markPoint (triangles under the X-axis) opens the annotation tooltip. - // Hovering markLine keeps the regular TimeSeries tooltip visible. + // Hovering markLine or anything else keeps the regular TimeSeries tooltip visible + // and clears any stale hovered annotation (mouseout is sometimes missed by ECharts). if (annotations && params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { - const annotationIndex = params.data.annotationIndex; - const matchedAnnotation = annotations[annotationIndex] || null; + const matchedAnnotation = annotations[params.data.annotationIndex] || null; if (matchedAnnotation) { setHoveredAnnotation(matchedAnnotation); + return; } } + setHoveredAnnotation(null); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseout: (params: any): void => { - if (params.componentType === 'markPoint' && params.data?.annotationIndex !== undefined) { + if ( + annotations && + params.componentType === 'markPoint' && + params.data?.annotationIndex !== undefined && + annotations + ) { + // Only clear if the mouseout corresponds to the currently hovered annotation, so that + // a quick move from one markPoint to another isn't cancelled by a late mouseout event. + const leaving = annotations[params.data.annotationIndex] || null; + setHoveredAnnotation((current) => (current === leaving ? null : current)); + } + }, + globalout: (): void => { + if (annotations) { + // Cursor left the chart canvas — guarantee the annotation tooltip is dismissed. setHoveredAnnotation(null); } }, @@ -465,6 +481,8 @@ export const TimeSeriesChartBase = forwardRef(fun if (tooltipPinnedCoords === null) { setShowTooltip(false); } + // Defensive: clear hovered annotation in case ECharts missed a mouseout event. + setHoveredAnnotation(null); if (chartRef.current !== undefined) { clearHighlightedSeries(chartRef.current); } @@ -511,7 +529,7 @@ export const TimeSeriesChartBase = forwardRef(fun }} /> )} - {/* Annotation tooltip - reuses TimeChartTooltip styling. Pinned takes priority over hovered. */} + {/* Pinned annotation takes priority over hovered. */} {(pinnedAnnotation ?? hoveredAnnotation) && ( Date: Mon, 27 Apr 2026 16:30:52 +0200 Subject: [PATCH 11/15] Fix sec/ms Signed-off-by: Guillaume LADORME --- .../annotations/PrometheusPromQLAnnotation.tsx | 13 +++++++++++++ .../PrometheusPromQLAnnotationOptionEditor.tsx | 15 ++++++++++++++- .../src/annotations/get-annotation-data.ts | 17 +++++++++++++++-- timeserieschart/src/TimeSeriesChartBase.tsx | 1 - timeserieschart/src/utils/annotation.ts | 15 +++++++++++++-- 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx index 7b641253a..c7400eda5 100644 --- a/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx +++ b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx @@ -1,3 +1,16 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the \"License\"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an \"AS IS\" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { AnnotationPlugin, AnnotationQueryQueryPluginDependencies, parseVariables } from '@perses-dev/plugin-system'; import { PrometheusPromQLAnnotationOptions } from '../plugins'; import { PrometheusPromQLAnnotationOptionEditor } from './PrometheusPromQLAnnotationOptionEditor'; diff --git a/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx index da208ff26..f92e17e1a 100644 --- a/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx +++ b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx @@ -1,7 +1,20 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the \"License\"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an \"AS IS\" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { ReactElement } from 'react'; import { useId } from '@perses-dev/components'; import { produce } from 'immer'; -import { Autocomplete, Chip, FormControl, Stack, TextField } from '@mui/material'; +import { Autocomplete, FormControl, Stack, TextField } from '@mui/material'; import { DatasourceSelect, DatasourceSelectProps, diff --git a/prometheus/src/annotations/get-annotation-data.ts b/prometheus/src/annotations/get-annotation-data.ts index ea915c766..87ccd8068 100644 --- a/prometheus/src/annotations/get-annotation-data.ts +++ b/prometheus/src/annotations/get-annotation-data.ts @@ -1,3 +1,16 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the \"License\"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an \"AS IS\" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { AnnotationData } from '@perses-dev/spec'; import { AnnotationContext, datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system'; import { DatasourceSpec, parseDurationString } from '@perses-dev/core'; @@ -84,8 +97,8 @@ export const getAnnotationData = async ( }, {}) : labels; result.push({ - start: start, - end: end, + start: start * 1000, + end: end * 1000, title: title, legend: legend, tags: tags, diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index edb3941d0..3a212dd39 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -62,7 +62,6 @@ import { } from '@perses-dev/components'; import { DatasetOption } from 'echarts/types/dist/shared'; import { TimeScale, TimeSeries } from '@perses-dev/spec'; -import { AnnotationDefinitionWithData } from '@perses-dev/dashboards'; import { createTimezoneAwareAxisFormatter } from './utils/timezone-formatter'; import { TimeSeriesAnnotation } from './utils/annotation'; import { AnnotationTooltip, buildAnnotationSeries } from './annotations/AnnotationTooltip'; diff --git a/timeserieschart/src/utils/annotation.ts b/timeserieschart/src/utils/annotation.ts index 1a46457d3..6fa2cc51d 100644 --- a/timeserieschart/src/utils/annotation.ts +++ b/timeserieschart/src/utils/annotation.ts @@ -1,3 +1,16 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the \"License\"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an \"AS IS\" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { AnnotationData, AnnotationDisplay } from '@perses-dev/spec'; import { AnnotationSpecWithData } from '@perses-dev/dashboards'; @@ -13,8 +26,6 @@ export function convertAnnotationToTimeSeriesAnnotation(annotations: AnnotationS result.push({ ...annotation.definition.display, ...item, - start: item.start * 1000, - end: item.end ? item.end * 1000 : undefined, }); } } From f91609cd4196cc4814b02c5bd0017d21e001b2a3 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Tue, 9 Jun 2026 14:05:29 +0200 Subject: [PATCH 12/15] Fix cue Signed-off-by: Guillaume LADORME --- .../prometheus-promql-annotation.cue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue index 2e181e9d4..118af408b 100644 --- a/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue +++ b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue @@ -21,8 +21,8 @@ import ( kind: "PrometheusPromQLAnnotation" spec: close({ promDs.#selector - expr: strings.MinRunes(1) + expr: strings.MinRunes(1) title?: string legend?: string - tags?: [string] + tags?: [string] }) From 4b4c973b2a78c714d64e35436c78dec4abd5d466 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 10 Jun 2026 14:40:33 +0200 Subject: [PATCH 13/15] Fix Signed-off-by: Guillaume LADORME --- prometheus/package.json | 9 +++++++++ prometheus/src/annotations/get-annotation-data.ts | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/prometheus/package.json b/prometheus/package.json index bf5b30bf6..ffcff21c1 100644 --- a/prometheus/package.json +++ b/prometheus/package.json @@ -118,6 +118,15 @@ }, "name": "PrometheusExplorer" } + }, + { + "kind": "Annotation", + "spec": { + "display": { + "name": "Prometheus PromQL Annotation" + }, + "name": "PrometheusPromQLAnnotation" + } } ] } diff --git a/prometheus/src/annotations/get-annotation-data.ts b/prometheus/src/annotations/get-annotation-data.ts index 87ccd8068..82344b83e 100644 --- a/prometheus/src/annotations/get-annotation-data.ts +++ b/prometheus/src/annotations/get-annotation-data.ts @@ -11,13 +11,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AnnotationData } from '@perses-dev/spec'; +import { AnnotationData, DatasourceSpec, parseDurationString } from '@perses-dev/spec'; import { AnnotationContext, datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system'; -import { DatasourceSpec, parseDurationString } from '@perses-dev/core'; import { milliseconds } from 'date-fns'; import { DEFAULT_SCRAPE_INTERVAL, PrometheusDatasourceSpec, PrometheusPromQLAnnotationOptions } from '../plugins'; import { DEFAULT_PROM, getPrometheusTimeRange, getRangeStep, PROM_DATASOURCE_KIND, PrometheusClient } from '../model'; import { formatSeriesName } from '../utils'; +import { interpolateDatasourceProxyParams } from '../plugins/interpolation'; export const getAnnotationData = async ( spec: PrometheusPromQLAnnotationOptions, @@ -42,6 +42,7 @@ export const getAnnotationData = async ( const datasource = (await context.datasourceStore.getDatasource( datasourceSelector )) as DatasourceSpec; + const interpolatedOptions = interpolateDatasourceProxyParams(datasource, context.variableState); const datasourceScrapeInterval = Math.trunc( milliseconds(parseDurationString(datasource.plugin.spec.scrapeInterval ?? DEFAULT_SCRAPE_INTERVAL)) / 1000 @@ -73,8 +74,7 @@ export const getAnnotationData = async ( end: alignedEnd, step: step, }, - undefined, - abortSignal + { ...interpolatedOptions, signal: abortSignal } ); const result: AnnotationData[] = []; From d8196d7b1f993da026104d8959eb0af9cb1711f1 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Wed, 17 Jun 2026 10:09:27 +0200 Subject: [PATCH 14/15] Apply comments Signed-off-by: Guillaume LADORME --- .../src/annotations/AnnotationTooltip.tsx | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/timeserieschart/src/annotations/AnnotationTooltip.tsx b/timeserieschart/src/annotations/AnnotationTooltip.tsx index 495dd6069..c95742508 100644 --- a/timeserieschart/src/annotations/AnnotationTooltip.tsx +++ b/timeserieschart/src/annotations/AnnotationTooltip.tsx @@ -166,7 +166,7 @@ export function AnnotationTooltip({ * markLine (vertical dashed lines) and markPoint (triangle markers under the X-axis). */ export function buildAnnotationSeries(annotations: TimeSeriesAnnotation[] | undefined): LineSeriesOption[] { - if (!annotations || annotations.length === 0) return []; + if (!annotations?.length) return []; const markAreaData: Array<[{ xAxis: number; itemStyle?: { color: string; opacity: number } }, { xAxis: number }]> = []; @@ -207,35 +207,23 @@ export function buildAnnotationSeries(annotations: TimeSeriesAnnotation[] | unde { xAxis: annotation.end }, ]); - // Add start marker - markPointData.push({ - coord: [annotation.start, 0], - symbol: 'triangle', - symbolSize: [12, 12], - symbolRotate: 0, - symbolOffset: [0, 4], // Position below X-axis - itemStyle: { color }, - annotationIndex: index, - emphasis: { - disabled: true, - }, - isStart: true, - }); - - // Add end marker - markPointData.push({ - coord: [annotation.end, 0], - symbol: 'triangle', - symbolSize: [12, 12], - symbolRotate: 0, - symbolOffset: [0, 4], // Position below X-axis - itemStyle: { color }, - annotationIndex: index, - emphasis: { - disabled: true, - }, - isEnd: true, - }); + // Add start and end markers + for (const isStart of [true, false]) { + markPointData.push({ + coord: [isStart ? annotation.start : annotation.end, 0], + symbol: 'triangle', + symbolSize: [12, 12], + symbolRotate: 0, + symbolOffset: [0, 4], // Position below X-axis + itemStyle: { color }, + annotationIndex: index, + emphasis: { + disabled: true, + }, + isStart: isStart, + isEnd: !isStart, + }); + } } else { // Point annotation - use markLine (silent) + single marker markLineData.push({ From 20bfc35cb4f3798cb14099dabc2adf2b985ec002 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Fri, 19 Jun 2026 16:12:38 +0200 Subject: [PATCH 15/15] Use shared methods Signed-off-by: Guillaume LADORME --- heatmapchart/src/components/HeatMapTooltip.ts | 2 +- .../PrometheusPromQLAnnotation.tsx | 4 ++-- .../src/StatusHistoryTooltip.ts | 2 +- timeserieschart/package.json | 2 +- timeserieschart/rsbuild.config.ts | 2 +- .../src/annotations/AnnotationTooltip.tsx | 22 +++++++------------ 6 files changed, 14 insertions(+), 20 deletions(-) diff --git a/heatmapchart/src/components/HeatMapTooltip.ts b/heatmapchart/src/components/HeatMapTooltip.ts index d6bac4a0e..e4d6e955e 100644 --- a/heatmapchart/src/components/HeatMapTooltip.ts +++ b/heatmapchart/src/components/HeatMapTooltip.ts @@ -59,7 +59,7 @@ export function generateTooltipHTML({ return `
-
${formattedDate} ${formattedTime}
+
${formattedDate} - ${formattedTime}
${marker} diff --git a/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx index c7400eda5..bc47e69ea 100644 --- a/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx +++ b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx @@ -11,14 +11,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AnnotationPlugin, AnnotationQueryQueryPluginDependencies, parseVariables } from '@perses-dev/plugin-system'; +import { AnnotationPlugin, AnnotationQueryPluginDependencies, parseVariables } from '@perses-dev/plugin-system'; import { PrometheusPromQLAnnotationOptions } from '../plugins'; import { PrometheusPromQLAnnotationOptionEditor } from './PrometheusPromQLAnnotationOptionEditor'; import { getAnnotationData } from './get-annotation-data'; export const PrometheusPromQLAnnotation: AnnotationPlugin = { getAnnotationData: getAnnotationData, - dependsOn: (spec: PrometheusPromQLAnnotationOptions): AnnotationQueryQueryPluginDependencies => { + dependsOn: (spec: PrometheusPromQLAnnotationOptions): AnnotationQueryPluginDependencies => { const queryVariables = parseVariables(spec.expr); return { variables: [...queryVariables], diff --git a/statushistorychart/src/StatusHistoryTooltip.ts b/statushistorychart/src/StatusHistoryTooltip.ts index 3e71aec88..f7d45485a 100644 --- a/statushistorychart/src/StatusHistoryTooltip.ts +++ b/statushistorychart/src/StatusHistoryTooltip.ts @@ -54,7 +54,7 @@ export function generateTooltipHTML({ return `
-
${formattedDate} ${formattedTime}
+
${formattedDate} - ${formattedTime}
${marker} diff --git a/timeserieschart/package.json b/timeserieschart/package.json index 9f2c8cd5a..3c1102d5f 100644 --- a/timeserieschart/package.json +++ b/timeserieschart/package.json @@ -1,6 +1,6 @@ { "name": "@perses-dev/timeseries-chart-plugin", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "license": "Apache-2.0", "homepage": "https://github.com/perses/plugins/blob/main/README.md", "repository": { diff --git a/timeserieschart/rsbuild.config.ts b/timeserieschart/rsbuild.config.ts index af5637e6c..1b19a3d52 100644 --- a/timeserieschart/rsbuild.config.ts +++ b/timeserieschart/rsbuild.config.ts @@ -32,8 +32,8 @@ export default createConfigForPlugin({ 'date-fns-tz': { singleton: true }, lodash: { singleton: true }, '@perses-dev/components': { singleton: true }, - '@perses-dev/plugin-system': { singleton: true }, '@perses-dev/dashboards': { singleton: true }, + '@perses-dev/plugin-system': { singleton: true }, '@emotion/react': { requiredVersion: '^11.11.3', singleton: true }, '@emotion/styled': { singleton: true }, '@hookform/resolvers': { singleton: true }, diff --git a/timeserieschart/src/annotations/AnnotationTooltip.tsx b/timeserieschart/src/annotations/AnnotationTooltip.tsx index c95742508..fd6e5b44a 100644 --- a/timeserieschart/src/annotations/AnnotationTooltip.tsx +++ b/timeserieschart/src/annotations/AnnotationTooltip.tsx @@ -19,6 +19,7 @@ import type { LineSeriesOption } from 'echarts'; import { assembleTransform, CursorCoordinates, + getDateAndTime, getTooltipStyles, PIN_TOOLTIP_HELP_TEXT, TOOLTIP_BG_COLOR_FALLBACK, @@ -26,6 +27,7 @@ import { UNPIN_TOOLTIP_HELP_TEXT, useMousePosition, } from '@perses-dev/components'; +import { DEFAULT_ANNOTATION_COLOR } from '@perses-dev/plugin-system'; import { TimeSeriesAnnotation } from '../utils/annotation'; export interface AnnotationTooltipProps { @@ -55,16 +57,8 @@ export function AnnotationTooltip({ const maxHeight = containerElement ? containerElement.getBoundingClientRect().height : undefined; const transform = assembleTransform(mousePos, pinnedPos, height ?? 0, width ?? 0, containerElement); - const formatDate = (timeMs: number): { date: string; time: string } => { - const d = new Date(timeMs); - return { - date: formatWithUserTimeZone(d, 'MMM dd, yyyy'), - time: formatWithUserTimeZone(d, 'HH:mm:ss'), - }; - }; - - const start = formatDate(annotation.start); - const end = annotation.end !== undefined ? formatDate(annotation.end) : null; + const start = getDateAndTime(annotation.start, formatWithUserTimeZone); + const end = annotation.end !== undefined ? getDateAndTime(annotation.end, formatWithUserTimeZone) : null; return ( @@ -87,20 +81,20 @@ export function AnnotationTooltip({ width: 8, height: 8, borderRadius: '50%', - backgroundColor: annotation.color ?? '#FF6B6B', + backgroundColor: annotation.color ?? DEFAULT_ANNOTATION_COLOR, marginRight: 1, flexShrink: 0, }} /> - {start.date} - {start.time} + {start.formattedDate} - {start.formattedTime} {end && ( <> {' → '} - {end.date} - {end.time} + {end.formattedDate} - {end.formattedTime} )} @@ -180,7 +174,7 @@ export function buildAnnotationSeries(annotations: TimeSeriesAnnotation[] | unde const markPointData: any[] = []; annotations.forEach((annotation, index) => { - const color = annotation.color ?? '#FF6B6B'; + const color = annotation.color ?? DEFAULT_ANNOTATION_COLOR; const opacity = 0.3; if (annotation.end !== undefined) {