diff --git a/packages/grafana-data/src/types/annotations.ts b/packages/grafana-data/src/types/annotations.ts index b5397da44ee..7cfa10225a3 100644 --- a/packages/grafana-data/src/types/annotations.ts +++ b/packages/grafana-data/src/types/annotations.ts @@ -51,6 +51,14 @@ export interface AnnotationEvent { source?: any; // source.type === 'dashboard' } +export interface AnnotationEventUIModel { + id?: string; + from: number; + to: number; + tags: string[]; + description: string; +} + /** * @alpha -- any value other than `field` is experimental */ diff --git a/packages/grafana-ui/src/components/Menu/MenuGroup.tsx b/packages/grafana-ui/src/components/Menu/MenuGroup.tsx index f71fefecbff..71822258a9d 100644 --- a/packages/grafana-ui/src/components/Menu/MenuGroup.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuGroup.tsx @@ -5,13 +5,13 @@ import { useStyles2 } from '../../themes'; import { MenuItemProps } from './MenuItem'; /** @internal */ -export interface MenuItemsGroup { +export interface MenuItemsGroup { /** Label for the menu items group */ label?: string; /** Aria label for accessibility support */ ariaLabel?: string; /** Items of the group */ - items: MenuItemProps[]; + items: Array>; } /** @internal */ export interface MenuGroupProps extends Partial { diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 130dab295d3..01428114d86 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -6,7 +6,7 @@ import { Icon } from '../Icon/Icon'; import { IconName } from '../../types'; /** @internal */ -export interface MenuItemProps { +export interface MenuItemProps { /** Label of the menu item */ label: string; /** Aria label for accessibility support */ @@ -18,7 +18,7 @@ export interface MenuItemProps { /** Url of the menu item */ url?: string; /** Handler for the click behaviour */ - onClick?: (event?: React.SyntheticEvent) => void; + onClick?: (event?: React.SyntheticEvent, payload?: T) => void; /** Custom MenuItem styles*/ className?: string; /** Active */ diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index 7fe9cb07bc2..8c10709b1e4 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -1,4 +1,4 @@ -import { EventBusSrv, EventBus, DashboardCursorSync } from '@grafana/data'; +import { EventBusSrv, EventBus, DashboardCursorSync, AnnotationEventUIModel } from '@grafana/data'; import React from 'react'; import { SeriesVisibilityChangeMode } from '.'; @@ -17,6 +17,11 @@ export interface PanelContext { onSeriesColorChange?: (label: string, color: string) => void; onToggleSeriesVisibility?: (label: string, mode: SeriesVisibilityChangeMode) => void; + + canAddAnnotations?: () => boolean; + onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void; + onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void; + onAnnotationDelete?: (id: string) => void; } export const PanelContextRoot = React.createContext({ diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 696527725ff..11e94eb82e6 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -245,7 +245,7 @@ export { PlotLegend } from './uPlot/PlotLegend'; export * from './uPlot/geometries'; export * from './uPlot/plugins'; export { usePlotContext } from './uPlot/context'; -export { PlotTooltipInterpolator } from './uPlot/types'; +export { PlotTooltipInterpolator, PlotSelection } from './uPlot/types'; export { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG'; export { TimeSeries } from './TimeSeries/TimeSeries'; export { useGraphNGContext } from './GraphNG/hooks'; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx index 6a9cbbc56c3..0c70c14fa39 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx +++ b/packages/grafana-ui/src/components/uPlot/geometries/Marker.tsx @@ -17,7 +17,6 @@ export const Marker: React.FC = ({ x, y, children }) => { position: absolute; top: ${y}px; left: ${x}px; - transform: translate3d(-50%, -50%, 0); `} > {children} diff --git a/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx deleted file mode 100644 index 07ed93184a8..00000000000 --- a/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// import React, { useRef } from 'react'; -// import { SelectionPlugin } from './SelectionPlugin'; -// import { css } from '@emotion/css'; -// import { Button } from '../../Button'; -// import useClickAway from 'react-use/lib/useClickAway'; -// -// interface AnnotationsEditorPluginProps { -// onAnnotationCreate: () => void; -// } -// -// /** -// * @alpha -// */ -// export const AnnotationsEditorPlugin: React.FC = ({ onAnnotationCreate }) => { -// const pluginId = 'AnnotationsEditorPlugin'; -// -// return ( -// { -// console.log(selection); -// }} -// lazy -// > -// {({ selection, clearSelection }) => { -// return ; -// }} -// -// ); -// }; -// -// const AnnotationEditor: React.FC = ({ onClose, selection }) => { -// const ref = useRef(null); -// -// useClickAway(ref, () => { -// if (onClose) { -// onClose(); -// } -// }); -// -// return ( -//
-//
-// Annotations editor maybe? -// -//
-//
-// ); -// }; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx index f3acb3c406a..83308f6489c 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx @@ -1,19 +1,7 @@ import React, { useEffect, useLayoutEffect, useState } from 'react'; import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; import { pluginLog } from '../utils'; - -interface Selection { - min: number; - max: number; - - // selection bounding box, relative to canvas - bbox: { - top: number; - left: number; - width: number; - height: number; - }; -} +import { PlotSelection } from '../types'; interface ZoomPluginProps { onZoom: (range: { from: number; to: number }) => void; @@ -27,7 +15,7 @@ const MIN_ZOOM_DIST = 5; * @alpha */ export const ZoomPlugin: React.FC = ({ onZoom, config }) => { - const [selection, setSelection] = useState(null); + const [selection, setSelection] = useState(null); useEffect(() => { if (selection) { diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 7c4de95e6bb..f63f3b36adf 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -36,3 +36,16 @@ export type PlotTooltipInterpolator = ( updateActiveDatapointIdx: (dIdx: number | null) => void, updateTooltipPosition: (clear?: boolean) => void ) => (u: uPlot) => void; + +export interface PlotSelection { + min: number; + max: number; + + // selection bounding box, relative to canvas + bbox: { + top: number; + left: number; + width: number; + height: number; + }; +} diff --git a/packages/grafana-ui/src/themes/mixins.ts b/packages/grafana-ui/src/themes/mixins.ts index 1c23bd1afef..737dcf4bf8a 100644 --- a/packages/grafana-ui/src/themes/mixins.ts +++ b/packages/grafana-ui/src/themes/mixins.ts @@ -64,7 +64,6 @@ export function getFocusStyles(theme: GrafanaTheme2): CSSObject { // max-width is set up based on .grafana-tooltip class that's used in dashboard export const getTooltipContainerStyles = (theme: GrafanaTheme2) => ` - pointer-events: none; overflow: hidden; background: ${theme.colors.background.secondary}; box-shadow: ${theme.shadows.z2}; diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts index 9e2653f7352..beacb8f9fb7 100644 --- a/public/app/features/annotations/standardAnnotationSupport.ts +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -106,6 +106,9 @@ export const annotationEventNames: AnnotationFieldInfo[] = [ placeholder: 'text, or the first text field', }, { key: 'tags', split: ',', help: 'The results will be split on comma (,)' }, + { + key: 'id', + }, // { key: 'userId' }, // { key: 'login' }, // { key: 'email' }, diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 0c5d542db1a..64992fccafb 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -15,6 +15,7 @@ import { DashboardModel, PanelModel } from '../state'; import { PANEL_BORDER } from 'app/core/constants'; import { AbsoluteTimeRange, + AnnotationEventUIModel, DashboardCursorSync, EventFilterOptions, FieldConfigSource, @@ -31,6 +32,8 @@ import { loadSnapshotData } from '../utils/loadSnapshotData'; import { RefreshEvent, RenderEvent } from 'app/types/events'; import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory'; import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; +import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annotations/api'; +import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; @@ -74,6 +77,10 @@ export class PanelChrome extends Component { eventBus, onSeriesColorChange: this.onSeriesColorChange, onToggleSeriesVisibility: this.onSeriesVisibilityChange, + onAnnotationCreate: this.onAnnotationCreate, + onAnnotationUpdate: this.onAnnotationUpdate, + onAnnotationDelete: this.onAnnotationDelete, + canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable), }, data: this.getInitialPanelDataState(), }; @@ -268,6 +275,41 @@ export class PanelChrome extends Component { } }; + onAnnotationCreate = async (event: AnnotationEventUIModel) => { + const isRegion = event.from !== event.to; + await saveAnnotation({ + dashboardId: this.props.dashboard.id, + panelId: this.props.panel.id, + isRegion, + time: event.from, + timeEnd: isRegion ? event.to : 0, + tags: event.tags, + text: event.description, + }); + getDashboardQueryRunner().run({ dashboard: this.props.dashboard, range: this.timeSrv.timeRange() }); + }; + + onAnnotationDelete = async (id: string) => { + await deleteAnnotation({ id }); + getDashboardQueryRunner().run({ dashboard: this.props.dashboard, range: this.timeSrv.timeRange() }); + }; + + onAnnotationUpdate = async (event: AnnotationEventUIModel) => { + const isRegion = event.from !== event.to; + await updateAnnotation({ + id: event.id, + dashboardId: this.props.dashboard.id, + panelId: this.props.panel.id, + isRegion, + time: event.from, + timeEnd: isRegion ? event.to : 0, + tags: event.tags, + text: event.description, + }); + + getDashboardQueryRunner().run({ dashboard: this.props.dashboard, range: this.timeSrv.timeRange() }); + }; + get hasPanelSnapshot() { const { panel } = this.props; return panel.snapshotData && panel.snapshotData.length; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 54b9317ca2e..bfb77536cde 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -8,6 +8,7 @@ import { ContextMenuPlugin } from './plugins/ContextMenuPlugin'; import { ExemplarsPlugin } from './plugins/ExemplarsPlugin'; import { TimeSeriesOptions } from './types'; import { prepareGraphableFields } from './utils'; +import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin'; interface TimeSeriesPanelProps extends PanelProps {} @@ -21,7 +22,7 @@ export const TimeSeriesPanel: React.FC = ({ onChangeTimeRange, replaceVariables, }) => { - const { sync } = usePanelContext(); + const { sync, canAddAnnotations } = usePanelContext(); const getFieldLinks = (field: Field, rowIndex: number) => { return getFieldLinksForExplore({ field, rowIndex, range: timeRange }); @@ -37,6 +38,7 @@ export const TimeSeriesPanel: React.FC = ({ ); } + const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); return ( = ({ mode={sync === DashboardCursorSync.Tooltip ? TooltipDisplayMode.Multi : options.tooltip.mode} timeZone={timeZone} /> - + {/* Renders annotation markers*/} {data.annotations && ( )} - + {/* Enables annotations creation*/} + + {({ startAnnotating }) => { + return ( + { + if (!p) { + return; + } + startAnnotating({ coords: p.coords }); + }, + }, + ], + }, + ] + : [] + } + /> + ); + }} + {data.annotations && ( void; + +interface AnnotationEditorPluginProps { + data: DataFrame; + timeZone: TimeZone; + config: UPlotConfigBuilder; + children?: (props: { startAnnotating: StartAnnotatingFn }) => React.ReactNode; +} + +/** + * @alpha + */ +export const AnnotationEditorPlugin: React.FC = ({ data, timeZone, config, children }) => { + const plotCtx = usePlotContext(); + const [isAddingAnnotation, setIsAddingAnnotation] = useState(false); + const [selection, setSelection] = useState(null); + + const clearSelection = useCallback(() => { + setSelection(null); + const plotInstance = plotCtx.plot; + if (plotInstance) { + plotInstance.setSelect({ top: 0, left: 0, width: 0, height: 0 }); + } + setIsAddingAnnotation(false); + }, [setSelection, , setIsAddingAnnotation, plotCtx]); + + useLayoutEffect(() => { + let annotating = false; + let isClick = false; + + const setSelect = (u: uPlot) => { + if (annotating) { + setIsAddingAnnotation(true); + const min = u.posToVal(u.select.left, 'x'); + const max = u.posToVal(u.select.left + u.select.width, 'x'); + + setSelection({ + min, + max, + bbox: { + left: u.select.left, + top: 0, + height: u.bbox.height / window.devicePixelRatio, + width: u.select.width, + }, + }); + annotating = false; + } + }; + + config.addHook('setSelect', setSelect); + + config.addHook('init', (u) => { + // Wrap all setSelect hooks to prevent them from firing if user is annotating + const setSelectHooks = u.hooks['setSelect']; + if (setSelectHooks) { + for (let i = 0; i < setSelectHooks.length; i++) { + const hook = setSelectHooks[i]; + if (hook === setSelect) { + continue; + } + + setSelectHooks[i] = (...args) => { + if (!annotating) { + hook!(...args); + } + }; + } + } + }); + + config.setCursor({ + bind: { + mousedown: (u, targ, handler) => (e) => { + if (e.button === 0) { + handler(e); + if (e.metaKey) { + isClick = true; + annotating = true; + } + } + + return null; + }, + mousemove: (u, targ, handler) => (e) => { + if (e.button === 0) { + handler(e); + // handle cmd+drag + if (e.metaKey) { + isClick = false; + annotating = true; + } + } + + return null; + }, + mouseup: (u, targ, handler) => (e) => { + // handle cmd+click + if (isClick && u.cursor.left && e.button === 0 && e.metaKey) { + u.setSelect({ left: u.cursor.left, width: 0, top: 0, height: 0 }); + annotating = true; + } + handler(e); + return null; + }, + }, + }); + }, [config, setIsAddingAnnotation]); + + const startAnnotating = useCallback( + ({ coords }) => { + if (!plotCtx || !plotCtx.plot || !coords) { + return; + } + + const bbox = plotCtx.getCanvasBoundingBox(); + + if (!bbox) { + return; + } + + const min = plotCtx.plot.posToVal(coords.plotCanvas.x, 'x'); + + if (!min) { + return; + } + + setSelection({ + min, + max: min, + bbox: { + left: coords.plotCanvas.x, + top: 0, + height: bbox.height, + width: 0, + }, + }); + setIsAddingAnnotation(true); + }, + [plotCtx, setSelection, setIsAddingAnnotation] + ); + + return ( + <> + {isAddingAnnotation && selection && ( + + )} + {children ? children({ startAnnotating }) : null} + + ); +}; diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx deleted file mode 100644 index 28c76c206b2..00000000000 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React, { CSSProperties, useCallback, useRef, useState } from 'react'; -import { GrafanaTheme2, dateTimeFormat, systemDateFormats, TimeZone, textUtil, getColorForTheme } from '@grafana/data'; -import { HorizontalGroup, Portal, Tag, VizTooltipContainer, useStyles2, useTheme2 } from '@grafana/ui'; -import { css } from '@emotion/css'; -import alertDef from 'app/features/alerting/state/alertDef'; - -interface Props { - timeZone: TimeZone; - annotation: AnnotationsDataFrameViewDTO; -} - -export function AnnotationMarker({ annotation, timeZone }: Props) { - const theme = useTheme2(); - const styles = useStyles2(getAnnotationMarkerStyles); - const [isOpen, setIsOpen] = useState(false); - const markerRef = useRef(null); - const annotationPopoverRef = useRef(null); - const popoverRenderTimeout = useRef(); - - const onMouseEnter = useCallback(() => { - if (popoverRenderTimeout.current) { - clearTimeout(popoverRenderTimeout.current); - } - setIsOpen(true); - }, [setIsOpen]); - - const onMouseLeave = useCallback(() => { - popoverRenderTimeout.current = setTimeout(() => { - setIsOpen(false); - }, 100); - }, [setIsOpen]); - - const timeFormatter = useCallback( - (value: number) => { - return dateTimeFormat(value, { - format: systemDateFormats.fullDate, - timeZone, - }); - }, - [timeZone] - ); - - const markerStyles: CSSProperties = { - width: 0, - height: 0, - borderLeft: '4px solid transparent', - borderRight: '4px solid transparent', - borderBottom: `4px solid ${getColorForTheme(annotation.color, theme.v1)}`, - pointerEvents: 'none', - }; - - const renderMarker = useCallback(() => { - if (!markerRef?.current) { - return null; - } - - const el = markerRef.current; - const elBBox = el.getBoundingClientRect(); - const time = timeFormatter(annotation.time); - let text = annotation.text; - const tags = annotation.tags; - let alertText = ''; - let state: React.ReactNode | null = null; - - if (annotation.alertId) { - const stateModel = alertDef.getStateDisplayModel(annotation.newState!); - state = ( -
- {stateModel.text} -
- ); - - alertText = alertDef.getAlertAnnotationInfo(annotation); - } else if (annotation.title) { - text = annotation.title + '
' + (typeof text === 'string' ? text : ''); - } - - return ( - -
-
- {state} - {time && {time}} -
-
- {text &&
} - {alertText} - <> - - {tags?.map((t, i) => ( - - ))} - - -
-
- - ); - }, [onMouseEnter, onMouseLeave, timeFormatter, styles, annotation]); - - return ( - <> -
-
-
- {isOpen && {renderMarker()}} - - ); -} - -const getAnnotationMarkerStyles = (theme: GrafanaTheme2) => { - return { - markerWrapper: css` - padding: 0 4px 4px 4px; - `, - wrapper: css` - max-width: 400px; - `, - tooltip: css` - padding: 0; - `, - header: css` - padding: ${theme.spacing(0.5, 1)}; - font-size: ${theme.typography.bodySmall.fontSize}; - display: flex; - `, - alertState: css` - padding-right: ${theme.spacing(1)}; - font-weight: ${theme.typography.fontWeightMedium}; - `, - time: css` - color: ${theme.colors.text.secondary}; - font-style: italic; - font-weight: normal; - display: inline-block; - position: relative; - top: 1px; - `, - body: css` - padding: ${theme.spacing(1)}; - `, - }; -}; diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx index e42e1eb5013..9205b840427 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx @@ -1,7 +1,7 @@ -import { DataFrame, DataFrameFieldIndex, DataFrameView, getColorForTheme, TimeZone } from '@grafana/data'; +import { colorManipulator, DataFrame, DataFrameFieldIndex, DataFrameView, TimeZone } from '@grafana/data'; import { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui'; import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; -import { AnnotationMarker } from './AnnotationMarker'; +import { AnnotationMarker } from './annotations/AnnotationMarker'; interface AnnotationsPluginProps { config: UPlotConfigBuilder; @@ -40,6 +40,22 @@ export const AnnotationsPlugin: React.FC = ({ annotation if (!ctx) { return; } + ctx.save(); + ctx.beginPath(); + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.clip(); + + const renderLine = (x: number, color: string) => { + ctx.beginPath(); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.setLineDash([5, 5]); + ctx.moveTo(x, u.bbox.top); + ctx.lineTo(x, u.bbox.top + u.bbox.height); + ctx.stroke(); + ctx.closePath(); + }; + for (let i = 0; i < annotationsRef.current.length; i++) { const annotationsView = annotationsRef.current[i]; for (let j = 0; j < annotationsView.length; j++) { @@ -49,17 +65,23 @@ export const AnnotationsPlugin: React.FC = ({ annotation continue; } - const xpos = u.valToPos(annotation.time, 'x', true); - ctx.beginPath(); - ctx.lineWidth = 2; - ctx.strokeStyle = getColorForTheme(annotation.color, theme); - ctx.setLineDash([5, 5]); - ctx.moveTo(xpos, u.bbox.top); - ctx.lineTo(xpos, u.bbox.top + u.bbox.height); - ctx.stroke(); - ctx.closePath(); + let x0 = u.valToPos(annotation.time, 'x', true); + const color = theme.visualization.getColorByName(annotation.color); + + renderLine(x0, color); + + if (annotation.isRegion && annotation.timeEnd) { + let x1 = u.valToPos(annotation.timeEnd, 'x', true); + + renderLine(x1, color); + + ctx.fillStyle = colorManipulator.alpha(color, 0.1); + ctx.rect(x0, u.bbox.top, x1 - x0, u.bbox.height); + ctx.fill(); + } } } + ctx.restore(); return; }); }, [config, theme]); @@ -72,9 +94,13 @@ export const AnnotationsPlugin: React.FC = ({ annotation if (!annotation.time || !plotInstance) { return undefined; } + let x = plotInstance.valToPos(annotation.time, 'x'); + if (x < 0) { + x = 0; + } return { - x: plotInstance.valToPos(annotation.time, 'x'), + x, y: plotInstance.bbox.height / window.devicePixelRatio + 4, }; }, diff --git a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx index 9d1e122b062..20616bf8217 100644 --- a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { css as cssCore, Global } from '@emotion/react'; import { ContextMenu, @@ -9,15 +9,23 @@ import { MenuGroup, MenuItem, UPlotConfigBuilder, + usePlotContext, } from '@grafana/ui'; import { CartesianCoords2D, DataFrame, getFieldDisplayName, InterpolateFunction, TimeZone } from '@grafana/data'; import { useClickAway } from 'react-use'; import { pluginLog } from '@grafana/ui/src/components/uPlot/utils'; +type ContextMenuSelectionCoords = { viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D }; +type ContextMenuSelectionPoint = { seriesIdx: number | null; dataIdx: number | null }; + +export interface ContextMenuItemClickPayload { + coords: ContextMenuSelectionCoords; +} + interface ContextMenuPluginProps { data: DataFrame; config: UPlotConfigBuilder; - defaultItems?: MenuItemsGroup[]; + defaultItems?: Array>; timeZone: TimeZone; onOpen?: () => void; onClose?: () => void; @@ -27,15 +35,15 @@ interface ContextMenuPluginProps { export const ContextMenuPlugin: React.FC = ({ data, config, - defaultItems, onClose, timeZone, replaceVariables, + ...otherProps }) => { + const plotCtx = usePlotContext(); const plotCanvas = useRef(); - const plotCanvasBBox = useRef({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }); - const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null); - const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>(); + const [coords, setCoords] = useState(null); + const [point, setPoint] = useState(null); const [isOpen, setIsOpen] = useState(false); const openMenu = useCallback(() => { @@ -54,23 +62,33 @@ export const ContextMenuPlugin: React.FC = ({ // Add uPlot hooks to the config, or re-add when the config changed useLayoutEffect(() => { const onMouseCapture = (e: MouseEvent) => { - setCoords({ - plotCanvas: { - x: e.clientX - plotCanvasBBox.current.left, - y: e.clientY - plotCanvasBBox.current.top, - }, + const bbox = plotCtx.getCanvasBoundingBox(); + let update = { viewport: { x: e.clientX, y: e.clientY, }, - }); + plotCanvas: { + x: 0, + y: 0, + }, + }; + if (bbox) { + update = { + ...update, + plotCanvas: { + x: e.clientX - bbox.left, + y: e.clientY - bbox.top, + }, + }; + } + setCoords(update); }; config.addHook('init', (u) => { const canvas = u.over; plotCanvas.current = canvas || undefined; plotCanvas.current?.addEventListener('mousedown', onMouseCapture); - plotCanvas.current?.addEventListener('mouseleave', () => {}); pluginLog('ContextMenuPlugin', false, 'init'); // for naive click&drag check @@ -79,16 +97,18 @@ export const ContextMenuPlugin: React.FC = ({ // REF: https://github.com/leeoniya/uPlot/issues/239 let pts = Array.from(u.root.querySelectorAll('.u-cursor-pt')); - plotCanvas.current?.addEventListener('mousedown', (e: MouseEvent) => { + plotCanvas.current?.addEventListener('mousedown', () => { isClick = true; }); - plotCanvas.current?.addEventListener('mousemove', (e: MouseEvent) => { + + plotCanvas.current?.addEventListener('mousemove', () => { isClick = false; }); // TODO: remove listeners on unmount plotCanvas.current?.addEventListener('mouseup', (e: MouseEvent) => { - if (!isClick) { + // ignore cmd+click, this is handled by annotation editor + if (!isClick || e.metaKey) { setPoint(null); return; } @@ -101,22 +121,46 @@ export const ContextMenuPlugin: React.FC = ({ setPoint({ seriesIdx: null, dataIdx: null }); } } + + openMenu(); }); if (pts.length > 0) { pts.forEach((pt, i) => { // TODO: remove listeners on unmount - pt.addEventListener('click', (e) => { + pt.addEventListener('click', () => { const seriesIdx = i + 1; const dataIdx = u.cursor.idx; pluginLog('ContextMenuPlugin', false, seriesIdx, dataIdx); - openMenu(); setPoint({ seriesIdx, dataIdx: dataIdx || null }); }); }); } }); - }, [config, openMenu]); + }, [config, openMenu, setCoords, setPoint, plotCtx]); + + const defaultItems = useMemo(() => { + return otherProps.defaultItems + ? otherProps.defaultItems.map((i) => { + return { + ...i, + items: i.items.map((j) => { + return { + ...j, + onClick: (e: React.SyntheticEvent) => { + if (!coords) { + return; + } + if (j.onClick) { + j.onClick(e, { coords }); + } + }, + }; + }), + }; + }) + : []; + }, [coords, otherProps.defaultItems]); return ( <> diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx index 9b76eaff2e3..b99bcfd5121 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx @@ -169,6 +169,7 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => { width: 8px; height: 8px; box-sizing: content-box; + transform: translate3d(-50%, 0, 0); &:hover { > svg { diff --git a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx new file mode 100644 index 00000000000..bf20b7c01d0 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditor.tsx @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import { usePopper } from 'react-popper'; +import { css, cx } from '@emotion/css'; +import { PlotSelection, usePlotContext, useStyles2, useTheme2, Portal, DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; +import { colorManipulator, DataFrame, getDisplayProcessor, GrafanaTheme2, TimeZone } from '@grafana/data'; +import { getCommonAnnotationStyles } from '../styles'; +import { AnnotationEditorForm } from './AnnotationEditorForm'; + +interface AnnotationEditorProps { + data: DataFrame; + timeZone: TimeZone; + selection: PlotSelection; + onSave: () => void; + onDismiss: () => void; + annotation?: AnnotationsDataFrameViewDTO; +} + +export const AnnotationEditor: React.FC = ({ + onDismiss, + onSave, + timeZone, + data, + selection, + annotation, +}) => { + const theme = useTheme2(); + const styles = useStyles2(getStyles); + const commonStyles = useStyles2(getCommonAnnotationStyles); + const plotCtx = usePlotContext(); + const [popperTrigger, setPopperTrigger] = useState(null); + const [editorPopover, setEditorPopover] = useState(null); + + const popper = usePopper(popperTrigger, editorPopover, { + modifiers: [ + { name: 'arrow', enabled: false }, + { + name: 'preventOverflow', + enabled: true, + options: { + rootBoundary: 'viewport', + }, + }, + ], + }); + + if (!plotCtx || !plotCtx.getCanvasBoundingBox()) { + return null; + } + const canvasBbox = plotCtx.getCanvasBoundingBox(); + + let xField = data.fields[0]; + if (!xField) { + return null; + } + const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone, theme }); + const isRegionAnnotation = selection.min !== selection.max; + + return ( + + <> +
+
+
+
+
+ + xFieldFmt(v).text} + onSave={onSave} + onDismiss={onDismiss} + ref={setEditorPopover} + style={popper.styles.popper} + {...popper.attributes.popper} + /> + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + overlay: (annotation?: AnnotationsDataFrameViewDTO) => { + const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR); + return css` + border-left: 1px dashed ${color}; + `; + }, + overlayRange: (annotation?: AnnotationsDataFrameViewDTO) => { + const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR); + return css` + background: ${colorManipulator.alpha(color, 0.1)}; + border-left: 1px dashed ${color}; + border-right: 1px dashed ${color}; + `; + }, + markerTriangle: css` + top: calc(100% + 2px); + left: -4px; + position: absolute; + `, + markerBar: css` + top: 100%; + left: 0; + position: absolute; + `, + }; +}; diff --git a/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx new file mode 100644 index 00000000000..fb26a82951a --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/annotations/AnnotationEditorForm.tsx @@ -0,0 +1,177 @@ +import React, { HTMLAttributes, useRef } from 'react'; +import { css, cx } from '@emotion/css'; +import { Button, Field, Form, HorizontalGroup, InputControl, TextArea, usePanelContext, useStyles2 } from '@grafana/ui'; +import { AnnotationEventUIModel, GrafanaTheme2 } from '@grafana/data'; +import useClickAway from 'react-use/lib/useClickAway'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; +import { getAnnotationTags } from 'app/features/annotations/api'; + +interface AnnotationEditFormDTO { + description: string; + tags: string[]; +} + +interface AnnotationEditorFormProps extends HTMLAttributes { + annotation: AnnotationsDataFrameViewDTO; + timeFormatter: (v: number) => string; + onSave: () => void; + onDismiss: () => void; +} + +export const AnnotationEditorForm = React.forwardRef( + ({ annotation, onSave, onDismiss, timeFormatter, className, ...otherProps }, ref) => { + const styles = useStyles2(getStyles); + const panelContext = usePanelContext(); + const clickAwayRef = useRef(null); + + useClickAway(clickAwayRef, () => { + onDismiss(); + }); + + const [createAnnotationState, createAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { + const result = await panelContext.onAnnotationCreate!(event); + if (onSave) { + onSave(); + } + return result; + }); + + const [updateAnnotationState, updateAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { + const result = await panelContext.onAnnotationUpdate!(event); + if (onSave) { + onSave(); + } + return result; + }); + + const isUpdatingAnnotation = annotation.id !== undefined; + const isRegionAnnotation = annotation.time !== annotation.timeEnd; + const operation = isUpdatingAnnotation ? updateAnnotation : createAnnotation; + const stateIndicator = isUpdatingAnnotation ? updateAnnotationState : createAnnotationState; + const ts = isRegionAnnotation + ? `${timeFormatter(annotation.time)} - ${timeFormatter(annotation.timeEnd)}` + : timeFormatter(annotation.time); + + const onSubmit = ({ tags, description }: AnnotationEditFormDTO) => { + operation({ + id: annotation.id, + tags, + description, + from: Math.round(annotation.time!), + to: Math.round(annotation.timeEnd!), + }); + }; + + const form = ( +
+
+ +
Add annotation
+
{ts}
+
+
+
+ + onSubmit={onSubmit} + defaultValues={{ description: annotation?.text, tags: annotation?.tags || [] }} + > + {({ register, errors, control }) => { + return ( + <> + +