${marker}
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/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/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue
new file mode 100644
index 000000000..118af408b
--- /dev/null
+++ b/prometheus/schemas/prometheus-promql-annotation/prometheus-promql-annotation.cue
@@ -0,0 +1,28 @@
+// 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)
+ title?: string
+ legend?: string
+ tags?: [string]
+})
diff --git a/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx
new file mode 100644
index 000000000..bc47e69ea
--- /dev/null
+++ b/prometheus/src/annotations/PrometheusPromQLAnnotation.tsx
@@ -0,0 +1,29 @@
+// 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, 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): AnnotationQueryPluginDependencies => {
+ const queryVariables = parseVariables(spec.expr);
+ return {
+ variables: [...queryVariables],
+ };
+ },
+ OptionsEditorComponent: PrometheusPromQLAnnotationOptionEditor,
+ createInitialOptions: () => ({ expr: '' }),
+};
diff --git a/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx
new file mode 100644
index 000000000..f92e17e1a
--- /dev/null
+++ b/prometheus/src/annotations/PrometheusPromQLAnnotationOptionEditor.tsx
@@ -0,0 +1,181 @@
+// 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, FormControl, Stack, TextField } from '@mui/material';
+import {
+ DatasourceSelect,
+ DatasourceSelectProps,
+ DatasourceSelectValue,
+ OptionsEditorProps,
+ useDatasourceClient,
+ useDatasourceSelectValueToSelector,
+} from '@perses-dev/plugin-system';
+import {
+ DEFAULT_PROM,
+ isDefaultPromSelector,
+ isPrometheusDatasourceSelector,
+ PROM_DATASOURCE_KIND,
+ PrometheusClient,
+ PrometheusDatasourceSelector,
+} from '../model';
+
+import { PromQLEditor } from '../components';
+
+export interface PrometheusAnnotationsQuerySpec {
+ expr: string;
+ datasource?: DatasourceSelectValue;
+ title?: string;
+ legend?: string;
+ tags?: string[];
+}
+
+export type PrometheusAnnotationsQueryEditorProps = OptionsEditorProps;
+
+export function PrometheusPromQLAnnotationOptionEditor(props: PrometheusAnnotationsQueryEditorProps): ReactElement {
+ const {
+ onChange,
+ value,
+ value: { expr, datasource, title, legend, tags },
+ 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 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');
+ };
+
+ const handleExprChange = (next: string): void => {
+ onChange(
+ produce(value, (draft) => {
+ draft.expr = next;
+ })
+ );
+ };
+
+ 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 (
+
+
+
+
+
+ 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
new file mode 100644
index 000000000..82344b83e
--- /dev/null
+++ b/prometheus/src/annotations/get-annotation-data.ts
@@ -0,0 +1,110 @@
+// 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, DatasourceSpec, parseDurationString } from '@perses-dev/spec';
+import { AnnotationContext, datasourceSelectValueToSelector, replaceVariables } from '@perses-dev/plugin-system';
+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,
+ 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 interpolatedOptions = interpolateDatasourceProxyParams(datasource, context.variableState);
+
+ 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,
+ },
+ { ...interpolatedOptions, signal: 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) {
+ 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 * 1000,
+ end: end * 1000,
+ title: title,
+ legend: legend,
+ tags: tags,
+ });
+ }
+ }
+
+ return result;
+};
diff --git a/prometheus/src/annotations/index.ts b/prometheus/src/annotations/index.ts
new file mode 100644
index 000000000..ed674aa35
--- /dev/null
+++ b/prometheus/src/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..5a0286a21 100644
--- a/prometheus/src/plugins/types.ts
+++ b/prometheus/src/plugins/types.ts
@@ -45,3 +45,11 @@ export type PrometheusPromQLVariableOptions = PrometheusVariableOptionsBase & {
// Note: This field is not part of the Prometheus API.
labelName: string;
};
+
+export interface PrometheusPromQLAnnotationOptions {
+ datasource?: DatasourceSelectValue;
+ expr: string;
+ title?: string;
+ legend?: string;
+ tags?: string[];
+}
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 b23007dfa..3c1102d5f 100644
--- a/timeserieschart/package.json
+++ b/timeserieschart/package.json
@@ -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/rsbuild.config.ts b/timeserieschart/rsbuild.config.ts
index 6eb1d36ae..1b19a3d52 100644
--- a/timeserieschart/rsbuild.config.ts
+++ b/timeserieschart/rsbuild.config.ts
@@ -32,6 +32,7 @@ export default createConfigForPlugin({
'date-fns-tz': { singleton: true },
lodash: { singleton: true },
'@perses-dev/components': { 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 },
diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx
index b1f759a23..3a212dd39 100644
--- a/timeserieschart/src/TimeSeriesChartBase.tsx
+++ b/timeserieschart/src/TimeSeriesChartBase.tsx
@@ -63,6 +63,8 @@ import {
import { DatasetOption } from 'echarts/types/dist/shared';
import { TimeScale, TimeSeries } from '@perses-dev/spec';
import { createTimezoneAwareAxisFormatter } from './utils/timezone-formatter';
+import { TimeSeriesAnnotation } from './utils/annotation';
+import { AnnotationTooltip, buildAnnotationSeries } from './annotations/AnnotationTooltip';
use([
EChartsLineChart,
@@ -83,6 +85,7 @@ export interface TimeChartProps {
height: number;
data: TimeSeries[];
seriesMapping: TimeChartSeriesMapping;
+ annotations?: TimeSeriesAnnotation[];
timeScale?: TimeScale;
yAxis?: YAXisComponentOption | YAXisComponentOption[];
format?: FormatOptions;
@@ -105,6 +108,7 @@ export const TimeSeriesChartBase = forwardRef
(fun
height,
data,
seriesMapping,
+ annotations,
timeScale: timeScaleProp,
yAxis,
format,
@@ -129,7 +133,10 @@ export const TimeSeriesChartBase = forwardRef(fun
const [pinnedCrosshair, setPinnedCrosshair] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
- const { timeZone } = useTimeZone();
+ const [hoveredAnnotation, setHoveredAnnotation] = useState(null);
+ 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),
@@ -199,8 +206,45 @@ export const TimeSeriesChartBase = forwardRef(fun
enableDataZoom(chartRef.current);
}
},
+ // 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 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 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 (
+ 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);
+ }
+ },
};
- }, [onDataZoom, setTooltipPinnedCoords]);
+ }, [annotations, onDataZoom]);
+
+ // Generate annotation series for ECharts markArea (range), markLine (point), and markPoint (markers under X-axis)
+ const annotationSeries = useMemo(() => buildAnnotationSeries(annotations), [annotations]);
const { noDataOption } = chartsTheme;
@@ -221,7 +265,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 +322,7 @@ export const TimeSeriesChartBase = forwardRef(fun
}, [
data,
seriesMapping,
+ annotationSeries,
timeScale,
yAxis,
format,
@@ -314,6 +361,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) {
@@ -413,6 +480,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);
}
@@ -435,8 +504,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
}}
/>
)}
+ {/* Pinned annotation takes priority over hovered. */}
+ {(pinnedAnnotation ?? hoveredAnnotation) && (
+ {
+ setPinnedAnnotation(null);
+ setPinnedAnnotationPos(null);
+ }}
+ />
+ )}
;
@@ -154,6 +156,13 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
const { setTimeRange } = useTimeRange();
+ const annotationsWithData = useAnnotationsWithData();
+
+ const annotations: TimeSeriesAnnotation[] = useMemo(
+ () => convertAnnotationToTimeSeriesAnnotation(annotationsWithData),
+ [annotationsWithData]
+ );
+
// Populate series data based on query results
const {
timeScale,
@@ -494,6 +503,7 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement
height={height}
data={timeChartData}
seriesMapping={timeSeriesMapping}
+ annotations={annotations}
timeScale={timeScale}
yAxis={multipleYAxes ?? echartsYAxis}
format={format}
diff --git a/timeserieschart/src/annotations/AnnotationTooltip.tsx b/timeserieschart/src/annotations/AnnotationTooltip.tsx
new file mode 100644
index 000000000..fd6e5b44a
--- /dev/null
+++ b/timeserieschart/src/annotations/AnnotationTooltip.tsx
@@ -0,0 +1,282 @@
+// 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,
+ getDateAndTime,
+ getTooltipStyles,
+ PIN_TOOLTIP_HELP_TEXT,
+ TOOLTIP_BG_COLOR_FALLBACK,
+ TOOLTIP_MAX_WIDTH,
+ 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 {
+ 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 start = getDateAndTime(annotation.start, formatWithUserTimeZone);
+ const end = annotation.end !== undefined ? getDateAndTime(annotation.end, formatWithUserTimeZone) : 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.formattedDate} - {start.formattedTime}
+
+ {end && (
+ <>
+ {' → '}
+
+ {end.formattedDate} - {end.formattedTime}
+
+ >
+ )}
+
+ {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?.length) 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 ?? DEFAULT_ANNOTATION_COLOR;
+ 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 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({
+ 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',
+ },
+ }
+ : undefined,
+ markPoint:
+ markPointData.length > 0
+ ? {
+ silent: false, // Markers are interactive
+ data: markPointData,
+ label: {
+ show: false,
+ },
+ }
+ : undefined,
+ };
+
+ return [series];
+}
diff --git a/timeserieschart/src/utils/annotation.ts b/timeserieschart/src/utils/annotation.ts
new file mode 100644
index 000000000..6fa2cc51d
--- /dev/null
+++ b/timeserieschart/src/utils/annotation.ts
@@ -0,0 +1,33 @@
+// 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';
+
+export type TimeSeriesAnnotation = AnnotationData & AnnotationDisplay;
+
+export function convertAnnotationToTimeSeriesAnnotation(annotations: AnnotationSpecWithData[]): TimeSeriesAnnotation[] {
+ 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,
+ });
+ }
+ }
+ return result;
+}