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/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; +}