From b53e0521d2f5b89c06dd50ec09fc13ea211cfe49 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 12 Jan 2024 01:02:40 -0600 Subject: [PATCH] Panels: AnnotationsPlugin2 (#79531) Co-authored-by: Adela Almasan --- .betterer.results | 12 + .../VizTooltip/VizTooltipFooter.tsx | 8 +- .../uPlot/plugins/TooltipPlugin2.tsx | 59 ++++- .../panel/candlestick/CandlestickPanel.tsx | 41 ++- .../panel/heatmap/HeatmapHoverView.tsx | 9 +- .../plugins/panel/heatmap/HeatmapPanel.tsx | 134 ++++++---- .../state-timeline/StateTimelinePanel.tsx | 34 ++- .../state-timeline/StateTimelineTooltip2.tsx | 4 +- .../status-history/StatusHistoryPanel.tsx | 52 +++- .../status-history/StatusHistoryTooltip2.tsx | 5 +- .../panel/timeseries/TimeSeriesPanel.tsx | 54 +++- .../panel/timeseries/TimeSeriesTooltip.tsx | 5 +- .../timeseries/plugins/AnnotationsPlugin2.tsx | 249 ++++++++++++++++++ .../annotations2/AnnotationEditor2.tsx | 160 +++++++++++ .../annotations2/AnnotationMarker2.tsx | 144 ++++++++++ .../annotations2/AnnotationTooltip2.tsx | 157 +++++++++++ public/app/plugins/panel/trend/TrendPanel.tsx | 4 +- .../plugins/panel/xychart/XYChartPanel.tsx | 4 +- .../plugins/panel/xychart/XYChartTooltip.tsx | 2 +- public/app/plugins/panel/xychart/scatter.ts | 4 +- 20 files changed, 1024 insertions(+), 117 deletions(-) create mode 100644 public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx create mode 100644 public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx diff --git a/.betterer.results b/.betterer.results index 8c1faef7a44..2137d9c9884 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6442,6 +6442,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "8"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"] ], + "public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/plugins/panel/timeseries/plugins/ThresholdDragHandle.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -6484,6 +6487,15 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "7"], [0, 0, 0, "Styles should be written using objects.", "8"] ], + "public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/plugins/panel/timeseries/plugins/styles.ts:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index 9bbbf862b50..1a9a3ff4e87 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -8,12 +8,12 @@ import { useStyles2 } from '../../themes'; interface Props { dataLinks: Array>; - canAnnotate: boolean; + annotate?: () => void; } export const ADD_ANNOTATION_ID = 'add-annotation-button'; -export const VizTooltipFooter = ({ dataLinks, canAnnotate }: Props) => { +export const VizTooltipFooter = ({ dataLinks, annotate }: Props) => { const styles = useStyles2(getStyles); const renderDataLinks = () => { @@ -33,9 +33,9 @@ export const VizTooltipFooter = ({ dataLinks, canAnnotate }: Props) => { return (
{dataLinks.length > 0 &&
{renderDataLinks()}
} - {canAnnotate && ( + {annotate && (
-
diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 17c812f9ace..1bea358f51c 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -1,11 +1,12 @@ import { css, cx } from '@emotion/css'; -import React, { useLayoutEffect, useRef, useReducer, CSSProperties } from 'react'; +import React, { useLayoutEffect, useRef, useReducer, CSSProperties, useContext, useEffect } from 'react'; import { createPortal } from 'react-dom'; import uPlot from 'uplot'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../themes'; +import { LayoutItemContext } from '../../Layout/LayoutItemContext'; import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; import { CloseButton } from './CloseButton'; @@ -36,7 +37,9 @@ interface TooltipPlugin2Props { dataIdxs: Array, seriesIdx: number | null, isPinned: boolean, - dismiss: () => void + dismiss: () => void, + // selected time range (for annotation triggering) + timeRange: TimeRange2 | null ) => React.ReactNode; } @@ -55,6 +58,11 @@ interface TooltipContainerSize { height: number; } +export interface TimeRange2 { + from: number; + to: number; +} + function mergeState(prevState: TooltipContainerState, nextState: Partial) { return { ...prevState, @@ -88,6 +96,9 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, INITIAL_STATE); + const { boostZIndex } = useContext(LayoutItemContext); + useEffect(() => (isPinned ? boostZIndex() : undefined), [isPinned]); + const sizeRef = useRef(); const styles = useStyles2(getStyles); @@ -134,6 +145,7 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, winHeight = htmlEl.clientHeight - 5; }); + let selectedRange: TimeRange2 | null = null; let seriesIdxs: Array = plot?.cursor.idxs!.slice()!; let closestSeriesIdx: number | null = null; @@ -160,9 +172,7 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, // in some ways this is similar to ClickOutsideWrapper.tsx const downEventOutside = (e: Event) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - let isOutside = (e.target as HTMLDivElement).closest(`.${styles.tooltipWrapper}`) !== domRef.current; - - if (isOutside) { + if (!domRef.current!.contains(e.target as Node)) { dismiss(); } }; @@ -173,8 +183,6 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, if (pendingPinned) { _style = { pointerEvents: _isPinned ? 'all' : 'none' }; - domRef.current?.closest('.react-grid-item')?.classList.toggle('context-menu-open', _isPinned); - // @ts-ignore _plot!.cursor._lock = _isPinned; @@ -193,18 +201,24 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, style: _style, isPinned: _isPinned, isHovering: _isHovering, - contents: _isHovering ? renderRef.current(_plot!, seriesIdxs, closestSeriesIdx, _isPinned, dismiss) : null, + contents: + _isHovering || selectedRange != null + ? renderRef.current(_plot!, seriesIdxs, closestSeriesIdx, _isPinned, dismiss, selectedRange) + : null, dismiss, }; setState(state); + + selectedRange = null; }; const dismiss = () => { + let prevIsPinned = _isPinned; _isPinned = false; _isHovering = false; _plot!.setCursor({ left: -10, top: -10 }); - scheduleRender(true); + scheduleRender(prevIsPinned); }; config.addHook('init', (u) => { @@ -240,10 +254,22 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, // this handles pinning u.over.addEventListener('click', (e) => { - // only pinnable tooltip is visible *and* is within proximity to series/point - if (_isHovering && closestSeriesIdx != null && !_isPinned && e.target === u.over) { - _isPinned = true; - scheduleRender(true); + if (e.target === u.over) { + if (e.ctrlKey || e.metaKey) { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + selectedRange = { + from: xVal, + to: xVal, + }; + + scheduleRender(false); + } + // only pinnable tooltip is visible *and* is within proximity to series/point + else if (_isHovering && closestSeriesIdx != null && !_isPinned) { + _isPinned = true; + scheduleRender(true); + } } }); }); @@ -276,6 +302,13 @@ export const TooltipPlugin2 = ({ config, hoverMode, render, clientZoom = false, yZoomed = false; } } + } else { + selectedRange = { + from: u.posToVal(u.select.left!, 'x'), + to: u.posToVal(u.select.left! + u.select.width, 'x'), + }; + + scheduleRender(true); } } diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index ea6d2b90616..7aad6f2720f 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -1,7 +1,7 @@ // this file is pretty much a copy-paste of TimeSeriesPanel.tsx :( // with some extra renderers passed to the component -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import uPlot from 'uplot'; import { DashboardCursorSync, Field, getDisplayProcessor, getLinksSupplier, PanelProps } from '@grafana/data'; @@ -10,13 +10,14 @@ import { TooltipDisplayMode } from '@grafana/schema'; import { TooltipPlugin, TooltipPlugin2, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui'; import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin'; import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; @@ -48,6 +49,9 @@ export const CandlestickPanel = ({ return prepareCandlestickFields(data.series, options, theme, timeRange); }, [data.series, options, theme, timeRange]); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState(null); + const { renderers, tweakScale, tweakAxis, shouldRenderPrice } = useMemo(() => { let tweakScale = (opts: ScaleProps, forField: Field) => opts; let tweakAxis = (opts: AxisProps, forField: Field) => opts; @@ -229,7 +233,8 @@ export const CandlestickPanel = ({ } const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); - const showNewVizTooltips = config.featureToggles.newVizTooltips && sync && sync() === DashboardCursorSync.Off; + const showNewVizTooltips = + config.featureToggles.newVizTooltips && (sync == null || sync() === DashboardCursorSync.Off); return ( { + render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2) => { + if (timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( ); }} @@ -290,8 +309,18 @@ export const CandlestickPanel = ({ )} {/* Renders annotation markers*/} - {data.annotations && ( - + {showNewVizTooltips ? ( + + ) : ( + data.annotations && ( + + ) )} {/* Enables annotations creation*/} {!showNewVizTooltips ? ( diff --git a/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx index 1f8a637973a..bf32a167584 100644 --- a/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapHoverView.tsx @@ -39,10 +39,10 @@ interface Props { showColorScale?: boolean; isPinned: boolean; dismiss: () => void; - canAnnotate: boolean; panelData: PanelData; replaceVars: InterpolateFunction; scopedVars: ScopedVars[]; + annotate?: () => void; } export const HeatmapHoverView = (props: Props) => { @@ -65,11 +65,11 @@ const HeatmapHoverCell = ({ dataRef, showHistogram, isPinned, - canAnnotate, showColorScale = false, scopedVars, replaceVars, mode, + annotate, }: Props) => { const index = dataIdxs[1]!; const data = dataRef.current; @@ -375,9 +375,6 @@ const HeatmapHoverCell = ({ return content; }; - // @TODO remove this when adding annotations support - canAnnotate = false; - const styles = useStyles2(getStyles); return ( @@ -388,7 +385,7 @@ const HeatmapHoverCell = ({ customContent={getCustomContent()} isPinned={isPinned} /> - {isPinned && } + {isPinned && }
); }; diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index a3592699709..0ab56d8f6b1 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -27,11 +27,11 @@ import { VizLayout, VizTooltipContainer, } from '@grafana/ui'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; -import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { ExemplarModalHeader } from './ExemplarModalHeader'; import { HeatmapHoverView } from './HeatmapHoverView'; @@ -60,7 +60,8 @@ export const HeatmapPanel = ({ const styles = useStyles2(getStyles); const { sync, canAddAnnotations } = usePanelContext(); - const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState(null); // necessary for enabling datalinks in hover view let scopedVarsFromRawData: ScopedVars[] = []; @@ -158,7 +159,8 @@ export const HeatmapPanel = ({ // ugh const dataRef = useRef(info); dataRef.current = info; - const showNewVizTooltips = config.featureToggles.newVizTooltips && sync && sync() === DashboardCursorSync.Off; + const showNewVizTooltips = + config.featureToggles.newVizTooltips && (sync == null || sync() === DashboardCursorSync.Off); const builder = useMemo(() => { const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution; @@ -226,69 +228,89 @@ export const HeatmapPanel = ({ ); } + const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + return ( <> {(vizWidth: number, vizHeight: number) => ( - {/*children ? children(config, alignedFrame) : null*/} {!showNewVizTooltips && } - {showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None && ( - { - return ( - - ); - }} - /> - )} - {data.annotations && ( - + {showNewVizTooltips && ( + <> + {options.tooltip.mode !== TooltipDisplayMode.None && ( + { + if (timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + + return ( + + ); + }} + /> + )} + + )} )} {!showNewVizTooltips && ( - - {hover && options.tooltip.mode !== TooltipDisplayMode.None && ( - - {shouldDisplayCloseButton && } - - - )} - + <> + + {hover && options.tooltip.mode !== TooltipDisplayMode.None && ( + + {shouldDisplayCloseButton && } + + + )} + + )} ); diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index 385804ad129..d04a2960569 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -14,7 +14,7 @@ import { ZoomPlugin, } from '@grafana/ui'; import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; import { @@ -25,6 +25,7 @@ import { import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; @@ -60,6 +61,8 @@ export const StateTimelinePanel = ({ const [focusedPointIdx, setFocusedPointIdx] = useState(null); const [isActive, setIsActive] = useState(false); const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState(null); const { sync, canAddAnnotations } = usePanelContext(); const onCloseToolTip = () => { @@ -163,7 +166,8 @@ export const StateTimelinePanel = ({ } } const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); - const showNewVizTooltips = config.featureToggles.newVizTooltips && sync && sync() === DashboardCursorSync.Off; + const showNewVizTooltips = + config.featureToggles.newVizTooltips && (sync == null || sync() === DashboardCursorSync.Off); return ( { + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2) => { + if (timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( ); }} /> )} + {/* Renders annotations */} + ) : ( <> + {/* Renders annotation markers*/} {data.annotations && ( )} diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx index a5f86c27373..409b598cc2c 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx @@ -34,6 +34,7 @@ interface StateTimelineTooltip2Props { timeRange: TimeRange; mode?: TooltipDisplayMode; sortOrder?: SortOrder; + annotate?: () => void; } export const StateTimelineTooltip2 = ({ @@ -46,6 +47,7 @@ export const StateTimelineTooltip2 = ({ mode = TooltipDisplayMode.Single, sortOrder = SortOrder.None, isPinned, + annotate, }: StateTimelineTooltip2Props) => { const styles = useStyles2(getStyles); const theme = useTheme2(); @@ -182,7 +184,7 @@ export const StateTimelineTooltip2 = ({
- {isPinned && } + {isPinned && }
); }; diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 3d73160272b..0b1891af582 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -13,7 +13,7 @@ import { ZoomPlugin, } from '@grafana/ui'; import { addTooltipSupport, HoverEvent } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; import { @@ -23,6 +23,7 @@ import { } from 'app/core/components/TimelineChart/utils'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; @@ -57,7 +58,11 @@ export const StatusHistoryPanel = ({ const [focusedPointIdx, setFocusedPointIdx] = useState(null); const [isActive, setIsActive] = useState(false); const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); - const { sync } = usePanelContext(); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState(null); + const { sync, canAddAnnotations } = usePanelContext(); + + const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); const onCloseToolTip = () => { isToolTipOpen.current = false; @@ -190,7 +195,8 @@ export const StatusHistoryPanel = ({ ); } - const showNewVizTooltips = config.featureToggles.newVizTooltips && sync && sync() === DashboardCursorSync.Off; + const showNewVizTooltips = + config.featureToggles.newVizTooltips && (sync == null || sync() === DashboardCursorSync.Off); return ( - {data.annotations && ( - - )} {showNewVizTooltips ? ( <> {options.tooltip.mode !== TooltipDisplayMode.None && ( @@ -237,7 +235,20 @@ export const StatusHistoryPanel = ({ config={builder} hoverMode={TooltipHoverMode.xyOne} queryZoom={onChangeTimeRange} - render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2) => { + if (timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( ); }} /> )} + ) : ( <> {renderTooltip(alignedFrame)} + {data.annotations && ( + + )} )} diff --git a/public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx b/public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx index e7ad9270372..f06a78fa705 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryTooltip2.tsx @@ -31,6 +31,7 @@ interface StatusHistoryTooltipProps { isPinned: boolean; mode?: TooltipDisplayMode; sortOrder?: SortOrder; + annotate?: () => void; } function fmt(field: Field, val: number): string { @@ -42,13 +43,13 @@ function fmt(field: Field, val: number): string { } export const StatusHistoryTooltip2 = ({ - data, dataIdxs, alignedData, seriesIdx, mode = TooltipDisplayMode.Single, sortOrder = SortOrder.None, isPinned, + annotate, }: StatusHistoryTooltipProps) => { const styles = useStyles2(getStyles); @@ -144,7 +145,7 @@ export const StatusHistoryTooltip2 = ({
- {isPinned && } + {isPinned && }
); }; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 8f858438e78..659e41390e3 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -1,10 +1,10 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { PanelProps, DataFrameType, DashboardCursorSync } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode } from '@grafana/schema'; import { KeyboardPlugin, TooltipPlugin, TooltipPlugin2, usePanelContext, ZoomPlugin } from '@grafana/ui'; -import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; +import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; @@ -12,6 +12,7 @@ import { TimeSeriesTooltip } from './TimeSeriesTooltip'; import { Options } from './panelcfg.gen'; import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from './plugins/AnnotationsPlugin'; +import { AnnotationsPlugin2 } from './plugins/AnnotationsPlugin2'; import { ContextMenuPlugin } from './plugins/ContextMenuPlugin'; import { ExemplarsPlugin, getVisibleLabels } from './plugins/ExemplarsPlugin'; import { OutsideRangePlugin } from './plugins/OutsideRangePlugin'; @@ -49,6 +50,12 @@ export const TimeSeriesPanel = ({ return undefined; }, [frames, id]); + const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); + const showNewVizTooltips = + config.featureToggles.newVizTooltips && (sync == null || sync() === DashboardCursorSync.Off); + // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 + const [newAnnotationRange, setNewAnnotationRange] = useState(null); + if (!frames || suggestions) { return ( - + {!showNewVizTooltips && } {options.tooltip.mode === TooltipDisplayMode.None || ( <> {showNewVizTooltips ? ( @@ -101,8 +113,22 @@ export const TimeSeriesPanel = ({ } queryZoom={onChangeTimeRange} clientZoom={true} - render={(u, dataIdxs, seriesIdx, isPinned = false) => { + render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2) => { + if (timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } + + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); + + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; + return ( + // not sure it header time here works for annotations, since it's taken from nearest datapoint index ); }} @@ -132,9 +159,20 @@ export const TimeSeriesPanel = ({ )} {/* Renders annotation markers*/} - {data.annotations && ( - + {showNewVizTooltips ? ( + + ) : ( + data.annotations && ( + + ) )} + {/*Enables annotations creation*/} {!showNewVizTooltips ? ( enableAnnotationCreation ? ( diff --git a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx index 6280c0a67aa..be84e5d8d02 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx @@ -38,6 +38,8 @@ interface TimeSeriesTooltipProps { sortOrder?: SortOrder; isPinned: boolean; + + annotate?: () => void; } export const TimeSeriesTooltip = ({ @@ -48,6 +50,7 @@ export const TimeSeriesTooltip = ({ mode = TooltipDisplayMode.Single, sortOrder = SortOrder.None, isPinned, + annotate, }: TimeSeriesTooltipProps) => { const theme = useTheme2(); const styles = useStyles2(getStyles); @@ -147,7 +150,7 @@ export const TimeSeriesTooltip = ({
- {isPinned && } + {isPinned && }
); diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx new file mode 100644 index 00000000000..3a8fa05af14 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx @@ -0,0 +1,249 @@ +import { css } from '@emotion/css'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import tinycolor from 'tinycolor2'; +import uPlot from 'uplot'; + +import { arrayToDataFrame, colorManipulator, DataFrame, DataTopic, GrafanaTheme2 } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; +import { DEFAULT_ANNOTATION_COLOR, UPlotConfigBuilder, useStyles2, useTheme2 } from '@grafana/ui'; + +import { AnnotationMarker2 } from './annotations2/AnnotationMarker2'; + +// (copied from TooltipPlugin2) +interface TimeRange2 { + from: number; + to: number; +} + +interface AnnotationsPluginProps { + config: UPlotConfigBuilder; + annotations: DataFrame[]; + timeZone: TimeZone; + newRange: TimeRange2 | null; + setNewRange: (newRage: TimeRange2 | null) => void; + canvasRegionRendering?: boolean; +} + +// TODO: batch by color, use Path2D objects +const renderLine = (ctx: CanvasRenderingContext2D, y0: number, y1: number, x: number, color: string) => { + ctx.beginPath(); + ctx.moveTo(x, y0); + ctx.lineTo(x, y1); + ctx.strokeStyle = color; + ctx.stroke(); +}; + +// const renderUpTriangle = (ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, color: string) => { +// ctx.beginPath(); +// ctx.moveTo(x - w/2, y + h/2); +// ctx.lineTo(x + w/2, y + h/2); +// ctx.lineTo(x, y); +// ctx.closePath(); +// ctx.fillStyle = color; +// ctx.fill(); +// } + +const DEFAULT_ANNOTATION_COLOR_HEX8 = tinycolor(DEFAULT_ANNOTATION_COLOR).toHex8String(); + +function getVals(frame: DataFrame) { + let vals: Record = {}; + frame.fields.forEach((f) => { + vals[f.name] = f.values; + }); + + return vals; +} + +export const AnnotationsPlugin2 = ({ + annotations, + timeZone, + config, + newRange, + setNewRange, + canvasRegionRendering = true, +}: AnnotationsPluginProps) => { + const [plot, setPlot] = useState(); + + const styles = useStyles2(getStyles); + const getColorByName = useTheme2().visualization.getColorByName; + + const annos = useMemo(() => { + let annos = annotations.slice(); + + if (newRange) { + let isRegion = newRange.to > newRange.from; + + const wipAnnoFrame = arrayToDataFrame([ + { + time: newRange.from, + timeEnd: isRegion ? newRange.to : null, + isRegion: isRegion, + color: DEFAULT_ANNOTATION_COLOR_HEX8, + }, + ]); + + wipAnnoFrame.meta = { + dataTopic: DataTopic.Annotations, + custom: { + isWip: true, + }, + }; + + annos.push(wipAnnoFrame); + } + + return annos; + }, [annotations, newRange]); + + const exitWipEdit = useCallback(() => { + setNewRange(null); + }, [setNewRange]); + + const annoRef = useRef(annos); + annoRef.current = annos; + const newRangeRef = useRef(newRange); + newRangeRef.current = newRange; + + const xAxisRef = useRef(); + + useLayoutEffect(() => { + config.addHook('ready', (u) => { + let xAxisEl = u.root.querySelector('.u-axis')!; + xAxisRef.current = xAxisEl; + setPlot(u); + }); + + config.addHook('draw', (u) => { + let annos = annoRef.current; + + const ctx = u.ctx; + + let y0 = u.bbox.top; + let y1 = y0 + u.bbox.height; + + ctx.save(); + + ctx.beginPath(); + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.clip(); + + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + + annos.forEach((frame) => { + let vals = getVals(frame); + + for (let i = 0; i < vals.time.length; i++) { + let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); + + let x0 = u.valToPos(vals.time[i], 'x', true); + + if (!vals.isRegion?.[i]) { + renderLine(ctx, y0, y1, x0, color); + // renderUpTriangle(ctx, x0, y1, 8 * uPlot.pxRatio, 5 * uPlot.pxRatio, color); + } else if (canvasRegionRendering) { + renderLine(ctx, y0, y1, x0, color); + + let x1 = u.valToPos(vals.timeEnd[i], 'x', true); + + renderLine(ctx, y0, y1, x1, color); + + ctx.fillStyle = colorManipulator.alpha(color, 0.1); + ctx.fillRect(x0, y0, x1 - x0, u.bbox.height); + } + } + }); + + ctx.restore(); + }); + }, [config, canvasRegionRendering, getColorByName]); + + // ensure annos are re-drawn whenever they change + useEffect(() => { + if (plot) { + plot.redraw(); + } + }, [annos, plot]); + + if (plot) { + let markers = annos.flatMap((frame, frameIdx) => { + let vals = getVals(frame); + + let markers: React.ReactNode[] = []; + + for (let i = 0; i < vals.time.length; i++) { + let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR); + let left = plot.valToPos(vals.time[i], 'x'); + let style: React.CSSProperties | null = null; + let className = ''; + let isVisible = true; + + if (vals.isRegion?.[i]) { + let right = plot.valToPos(vals.timeEnd?.[i], 'x'); + + isVisible = left < plot.rect.width && right > 0; + + if (isVisible) { + let clampedLeft = Math.max(0, left); + let clampedRight = Math.min(plot.rect.width, right); + + style = { left: clampedLeft, background: color, width: clampedRight - clampedLeft }; + className = styles.annoRegion; + } + } else { + isVisible = left > 0 && left <= plot.rect.width; + + if (isVisible) { + style = { left, borderBottomColor: color }; + className = styles.annoMarker; + } + } + + // @TODO: Reset newRange after annotation is saved + if (isVisible) { + let isWip = frame.meta?.custom?.isWip; + + markers.push( + + ); + } + } + + return markers; + }); + + return createPortal(markers, xAxisRef.current!); + } + + return null; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + annoMarker: css({ + position: 'absolute', + width: 0, + height: 0, + borderLeft: '6px solid transparent', + borderRight: '6px solid transparent', + borderBottomWidth: '6px', + borderBottomStyle: 'solid', + transform: 'translateX(-50%)', + cursor: 'pointer', + zIndex: 1, + }), + annoRegion: css({ + position: 'absolute', + height: '5px', + cursor: 'pointer', + zIndex: 1, + }), +}); diff --git a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx new file mode 100644 index 00000000000..6f56a9a1a18 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx @@ -0,0 +1,160 @@ +import { css } from '@emotion/css'; +import React, { useContext, useEffect } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { AnnotationEventUIModel, GrafanaTheme2 } from '@grafana/data'; +import { + Button, + Field, + Form, + HorizontalGroup, + InputControl, + LayoutItemContext, + TextArea, + usePanelContext, + useStyles2, +} from '@grafana/ui'; +import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; +import { getAnnotationTags } from 'app/features/annotations/api'; + +interface Props { + annoVals: Record; + annoIdx: number; + timeFormatter: (v: number) => string; + dismiss: () => void; +} + +interface AnnotationEditFormDTO { + description: string; + tags: string[]; +} + +export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeFormatter, ...otherProps }: Props) => { + const styles = useStyles2(getStyles); + const panelContext = usePanelContext(); + + const layoutCtx = useContext(LayoutItemContext); + useEffect(() => layoutCtx.boostZIndex(), [layoutCtx]); + + const [createAnnotationState, createAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { + const result = await panelContext.onAnnotationCreate!(event); + dismiss(); + return result; + }); + + const [updateAnnotationState, updateAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { + const result = await panelContext.onAnnotationUpdate!(event); + dismiss(); + return result; + }); + + const isUpdatingAnnotation = annoVals.id?.[annoIdx] != null; + const isRegionAnnotation = annoVals.isRegion?.[annoIdx]; + const operation = isUpdatingAnnotation ? updateAnnotation : createAnnotation; + const stateIndicator = isUpdatingAnnotation ? updateAnnotationState : createAnnotationState; + const time = isRegionAnnotation + ? `${timeFormatter(annoVals.time[annoIdx])} - ${timeFormatter(annoVals.timeEnd[annoIdx])}` + : timeFormatter(annoVals.time[annoIdx]); + + const onSubmit = ({ tags, description }: AnnotationEditFormDTO) => { + operation({ + id: annoVals.id?.[annoIdx] ?? undefined, + tags, + description, + from: Math.round(annoVals.time[annoIdx]!), + to: Math.round(annoVals.timeEnd?.[annoIdx] ?? annoVals.time[annoIdx]!), + }); + }; + + // Annotation editor + return ( +
+
+ +
{isUpdatingAnnotation ? 'Edit annotation' : 'Add annotation'}
+
{time}
+
+
+ + onSubmit={onSubmit} + defaultValues={{ description: annoVals.text?.[annoIdx], tags: annoVals.tags?.[annoIdx] || [] }} + > + {({ register, errors, control }) => { + return ( + <> +
+ +