TimeSeries panel: Allow adding annotations from the panel (#36220)

* First stab on UI for adding annotations in time series panel

* Extend panel context with annotations api

* Annotations editor UI & CRUD

* Prevent annotation markers to overflow uPlot canvas

* Do not overflow graphing area with region annotations

* Align annotation id type

* Fix exemplar markers positioning

* Use clipping region rather than adjusting annotation region bounds

* Smaller icons

* Improve annotation tooltip and editor auto positioning, reorg code

* Renames

* Enable annotations ctx menu only when adding annotations is allowed

* Wrap setSelect hooks diring init hook

* Use TagFilter instead of TagsInput

* Add id to annotation events

* Add support for cmd+click for adding point annotations

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Dominik Prokop 2021-07-08 10:39:03 +02:00 committed by GitHub
parent a0dac9c6d9
commit 7df0010412
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1041 additions and 269 deletions

View File

@ -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
*/

View File

@ -5,13 +5,13 @@ import { useStyles2 } from '../../themes';
import { MenuItemProps } from './MenuItem';
/** @internal */
export interface MenuItemsGroup {
export interface MenuItemsGroup<T = any> {
/** Label for the menu items group */
label?: string;
/** Aria label for accessibility support */
ariaLabel?: string;
/** Items of the group */
items: MenuItemProps[];
items: Array<MenuItemProps<T>>;
}
/** @internal */
export interface MenuGroupProps extends Partial<MenuItemsGroup> {

View File

@ -6,7 +6,7 @@ import { Icon } from '../Icon/Icon';
import { IconName } from '../../types';
/** @internal */
export interface MenuItemProps {
export interface MenuItemProps<T = any> {
/** 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<HTMLElement>) => void;
onClick?: (event?: React.SyntheticEvent<HTMLElement>, payload?: T) => void;
/** Custom MenuItem styles*/
className?: string;
/** Active */

View File

@ -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<PanelContext>({

View File

@ -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';

View File

@ -17,7 +17,6 @@ export const Marker: React.FC<MarkerProps> = ({ x, y, children }) => {
position: absolute;
top: ${y}px;
left: ${x}px;
transform: translate3d(-50%, -50%, 0);
`}
>
{children}

View File

@ -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<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => {
// const pluginId = 'AnnotationsEditorPlugin';
//
// return (
// <SelectionPlugin
// id={pluginId}
// onSelect={(selection) => {
// console.log(selection);
// }}
// lazy
// >
// {({ selection, clearSelection }) => {
// return <AnnotationEditor selection={selection} onClose={clearSelection} />;
// }}
// </SelectionPlugin>
// );
// };
//
// const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => {
// const ref = useRef(null);
//
// useClickAway(ref, () => {
// if (onClose) {
// onClose();
// }
// });
//
// return (
// <div>
// <div
// ref={ref}
// className={css`
// position: absolute;
// background: purple;
// top: ${selection.bbox.top}px;
// left: ${selection.bbox.left}px;
// width: ${selection.bbox.width}px;
// height: ${selection.bbox.height}px;
// `}
// >
// Annotations editor maybe?
// <Button onClick={() => {}}>Create annotation</Button>
// </div>
// </div>
// );
// };

View File

@ -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<ZoomPluginProps> = ({ onZoom, config }) => {
const [selection, setSelection] = useState<Selection | null>(null);
const [selection, setSelection] = useState<PlotSelection | null>(null);
useEffect(() => {
if (selection) {

View File

@ -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;
};
}

View File

@ -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};

View File

@ -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' },

View File

@ -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<Props, State> {
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<Props, State> {
}
};
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;

View File

@ -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<TimeSeriesOptions> {}
@ -21,7 +22,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
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<TimeSeriesPanelProps> = ({
);
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
return (
<TimeSeries
frames={frames}
@ -57,16 +59,44 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
mode={sync === DashboardCursorSync.Tooltip ? TooltipDisplayMode.Multi : options.tooltip.mode}
timeZone={timeZone}
/>
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
/>
{/* Renders annotation markers*/}
{data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
)}
{/* Enables annotations creation*/}
<AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={config}>
{({ startAnnotating }) => {
return (
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={
enableAnnotationCreation
? [
{
items: [
{
label: 'Add annotation',
ariaLabel: 'Add annotation',
icon: 'comment-alt',
onClick: (e, p) => {
if (!p) {
return;
}
startAnnotating({ coords: p.coords });
},
},
],
},
]
: []
}
/>
);
}}
</AnnotationEditorPlugin>
{data.annotations && (
<ExemplarsPlugin
config={config}

View File

@ -0,0 +1,166 @@
import React, { useLayoutEffect, useState, useCallback } from 'react';
import { UPlotConfigBuilder, PlotSelection, usePlotContext } from '@grafana/ui';
import { CartesianCoords2D, DataFrame, TimeZone } from '@grafana/data';
import { AnnotationEditor } from './annotations/AnnotationEditor';
type StartAnnotatingFn = (props: {
// pixel coordinates of the clicked point on the uPlot canvas
coords: { viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null;
}) => void;
interface AnnotationEditorPluginProps {
data: DataFrame;
timeZone: TimeZone;
config: UPlotConfigBuilder;
children?: (props: { startAnnotating: StartAnnotatingFn }) => React.ReactNode;
}
/**
* @alpha
*/
export const AnnotationEditorPlugin: React.FC<AnnotationEditorPluginProps> = ({ data, timeZone, config, children }) => {
const plotCtx = usePlotContext();
const [isAddingAnnotation, setIsAddingAnnotation] = useState(false);
const [selection, setSelection] = useState<PlotSelection | null>(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<StartAnnotatingFn>(
({ 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 && (
<AnnotationEditor
selection={selection}
onDismiss={clearSelection}
onSave={clearSelection}
data={data}
timeZone={timeZone}
/>
)}
{children ? children({ startAnnotating }) : null}
</>
);
};

View File

@ -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<HTMLDivElement>(null);
const annotationPopoverRef = useRef<HTMLDivElement>(null);
const popoverRenderTimeout = useRef<NodeJS.Timer>();
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 = (
<div className={styles.alertState}>
<i className={stateModel.stateClass}>{stateModel.text}</i>
</div>
);
alertText = alertDef.getAlertAnnotationInfo(annotation);
} else if (annotation.title) {
text = annotation.title + '<br />' + (typeof text === 'string' ? text : '');
}
return (
<VizTooltipContainer
position={{ x: elBBox.left, y: elBBox.top + elBBox.height }}
offset={{ x: 0, y: 0 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={styles.tooltip}
>
<div ref={annotationPopoverRef} className={styles.wrapper}>
<div className={styles.header}>
{state}
{time && <span className={styles.time}>{time}</span>}
</div>
<div className={styles.body}>
{text && <div dangerouslySetInnerHTML={{ __html: textUtil.sanitize(text) }} />}
{alertText}
<>
<HorizontalGroup spacing="xs" wrap>
{tags?.map((t, i) => (
<Tag name={t} key={`${t}-${i}`} />
))}
</HorizontalGroup>
</>
</div>
</div>
</VizTooltipContainer>
);
}, [onMouseEnter, onMouseLeave, timeFormatter, styles, annotation]);
return (
<>
<div ref={markerRef} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={styles.markerWrapper}>
<div style={markerStyles} />
</div>
{isOpen && <Portal>{renderMarker()}</Portal>}
</>
);
}
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)};
`,
};
};

View File

@ -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<AnnotationsPluginProps> = ({ 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<AnnotationsPluginProps> = ({ 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<AnnotationsPluginProps> = ({ 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,
};
},

View File

@ -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<MenuItemsGroup<ContextMenuItemClickPayload>>;
timeZone: TimeZone;
onOpen?: () => void;
onClose?: () => void;
@ -27,15 +35,15 @@ interface ContextMenuPluginProps {
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
data,
config,
defaultItems,
onClose,
timeZone,
replaceVariables,
...otherProps
}) => {
const plotCtx = usePlotContext();
const plotCanvas = useRef<HTMLDivElement>();
const plotCanvasBBox = useRef<any>({ 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<ContextMenuSelectionCoords | null>(null);
const [point, setPoint] = useState<ContextMenuSelectionPoint | null>(null);
const [isOpen, setIsOpen] = useState(false);
const openMenu = useCallback(() => {
@ -54,23 +62,33 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
// 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<ContextMenuPluginProps> = ({
// REF: https://github.com/leeoniya/uPlot/issues/239
let pts = Array.from(u.root.querySelectorAll<HTMLDivElement>('.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<ContextMenuPluginProps> = ({
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<HTMLElement>) => {
if (!coords) {
return;
}
if (j.onClick) {
j.onClick(e, { coords });
}
},
};
}),
};
})
: [];
}, [coords, otherProps.defaultItems]);
return (
<>

View File

@ -169,6 +169,7 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
width: 8px;
height: 8px;
box-sizing: content-box;
transform: translate3d(-50%, 0, 0);
&:hover {
> svg {

View File

@ -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<AnnotationEditorProps> = ({
onDismiss,
onSave,
timeZone,
data,
selection,
annotation,
}) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const commonStyles = useStyles2(getCommonAnnotationStyles);
const plotCtx = usePlotContext();
const [popperTrigger, setPopperTrigger] = useState<HTMLDivElement | null>(null);
const [editorPopover, setEditorPopover] = useState<HTMLDivElement | null>(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 (
<Portal>
<>
<div // div overlay matching uPlot canvas bbox
className={css`
position: absolute;
top: ${canvasBbox!.top}px;
left: ${canvasBbox!.left}px;
width: ${canvasBbox!.width}px;
height: ${canvasBbox!.height}px;
`}
>
<div // Annotation marker
className={cx(
css`
position: absolute;
top: ${selection.bbox.top}px;
left: ${selection.bbox.left}px;
width: ${selection.bbox.width}px;
height: ${selection.bbox.height}px;
`,
isRegionAnnotation ? styles.overlayRange(annotation) : styles.overlay(annotation)
)}
>
<div
ref={setPopperTrigger}
className={
isRegionAnnotation
? cx(commonStyles(annotation).markerBar, styles.markerBar)
: cx(commonStyles(annotation).markerTriangle, styles.markerTriangle)
}
/>
</div>
</div>
<AnnotationEditorForm
annotation={annotation || ({ time: selection.min, timeEnd: selection.max } as AnnotationsDataFrameViewDTO)}
timeFormatter={(v) => xFieldFmt(v).text}
onSave={onSave}
onDismiss={onDismiss}
ref={setEditorPopover}
style={popper.styles.popper}
{...popper.attributes.popper}
/>
</>
</Portal>
);
};
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;
`,
};
};

View File

@ -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<HTMLDivElement> {
annotation: AnnotationsDataFrameViewDTO;
timeFormatter: (v: number) => string;
onSave: () => void;
onDismiss: () => void;
}
export const AnnotationEditorForm = React.forwardRef<HTMLDivElement, AnnotationEditorFormProps>(
({ 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 = (
<div // Annotation editor
ref={ref}
className={cx(styles.editor, className)}
{...otherProps}
>
<div className={styles.header}>
<HorizontalGroup justify={'space-between'} align={'center'}>
<div className={styles.title}>Add annotation</div>
<div className={styles.ts}>{ts}</div>
</HorizontalGroup>
</div>
<div className={styles.editorForm}>
<Form<AnnotationEditFormDTO>
onSubmit={onSubmit}
defaultValues={{ description: annotation?.text, tags: annotation?.tags || [] }}
>
{({ register, errors, control }) => {
return (
<>
<Field label={'Description'} invalid={!!errors.description} error={errors?.description?.message}>
<TextArea
{...register('description', {
required: 'Annotation description is required',
})}
/>
</Field>
<Field label={'Tags'}>
<InputControl
control={control}
name="tags"
render={({ field: { ref, onChange, ...field } }) => {
return (
<TagFilter
allowCustomValue
placeholder="Add tags"
onChange={onChange}
tagOptions={getAnnotationTags}
tags={field.value}
/>
);
}}
/>
</Field>
<HorizontalGroup justify={'flex-end'}>
<Button size={'sm'} variant="secondary" onClick={onDismiss} fill="outline">
Cancel
</Button>
<Button size={'sm'} type={'submit'} disabled={stateIndicator?.loading}>
{stateIndicator?.loading ? 'Saving' : 'Save'}
</Button>
</HorizontalGroup>
</>
);
}}
</Form>
</div>
</div>
);
return (
<>
<div className={styles.backdrop} />
<div ref={clickAwayRef}>{form}</div>
</>
);
}
);
AnnotationEditorForm.displayName = 'AnnotationEditorForm';
const getStyles = (theme: GrafanaTheme2) => {
return {
backdrop: css`
label: backdrop;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
z-index: ${theme.zIndex.navbarFixed};
`,
editorContainer: css`
position: absolute;
top: calc(100% + 10px);
transform: translate3d(-50%, 0, 0);
`,
editor: css`
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
z-index: ${theme.zIndex.dropdown};
border: 1px solid ${theme.colors.border.weak};
border-radius: ${theme.shape.borderRadius()};
width: 460px;
`,
editorForm: css`
padding: ${theme.spacing(1)};
`,
header: css`
border-bottom: 1px solid ${theme.colors.border.weak};
padding: ${theme.spacing(1.5, 1)};
`,
title: css`
font-weight: ${theme.typography.fontWeightMedium};
`,
ts: css`
font-size: ${theme.typography.bodySmall.fontSize};
color: ${theme.colors.text.secondary};
`,
};
};

View File

@ -0,0 +1,181 @@
import React, { useCallback, useRef, useState } from 'react';
import { GrafanaTheme2, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
import { Portal, useStyles2, usePanelContext, usePlotContext } from '@grafana/ui';
import { css } from '@emotion/css';
import { AnnotationEditorForm } from './AnnotationEditorForm';
import { getCommonAnnotationStyles } from '../styles';
import { usePopper } from 'react-popper';
import { getTooltipContainerStyles } from '@grafana/ui/src/themes/mixins';
import { AnnotationTooltip } from './AnnotationTooltip';
interface Props {
timeZone: TimeZone;
annotation: AnnotationsDataFrameViewDTO;
}
const POPPER_CONFIG = {
modifiers: [
{ name: 'arrow', enabled: false },
{
name: 'preventOverflow',
enabled: true,
options: {
rootBoundary: 'viewport',
},
},
],
};
export function AnnotationMarker({ annotation, timeZone }: Props) {
const commonStyles = useStyles2(getCommonAnnotationStyles);
const styles = useStyles2(getStyles);
const plotCtx = usePlotContext();
const { canAddAnnotations, ...panelCtx } = usePanelContext();
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [markerRef, setMarkerRef] = useState<HTMLDivElement | null>(null);
const [tooltipRef, setTooltipRef] = useState<HTMLDivElement | null>(null);
const [editorRef, setEditorRef] = useState<HTMLDivElement | null>(null);
const popoverRenderTimeout = useRef<NodeJS.Timer>();
const popper = usePopper(markerRef, tooltipRef, POPPER_CONFIG);
const editorPopper = usePopper(markerRef, editorRef, POPPER_CONFIG);
const onAnnotationEdit = useCallback(() => {
setIsEditing(true);
setIsOpen(false);
}, [setIsEditing, setIsOpen]);
const onAnnotationDelete = useCallback(() => {
if (panelCtx.onAnnotationDelete) {
panelCtx.onAnnotationDelete(annotation.id);
}
}, [annotation, panelCtx]);
const onMouseEnter = useCallback(() => {
if (popoverRenderTimeout.current) {
clearTimeout(popoverRenderTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const onPopoverMouseEnter = useCallback(() => {
if (popoverRenderTimeout.current) {
clearTimeout(popoverRenderTimeout.current);
}
}, []);
const onMouseLeave = useCallback(() => {
popoverRenderTimeout.current = setTimeout(() => {
setIsOpen(false);
}, 100);
}, [setIsOpen]);
const timeFormatter = useCallback(
(value: number) => {
return dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone,
});
},
[timeZone]
);
const renderTooltip = useCallback(() => {
return (
<AnnotationTooltip
annotation={annotation}
timeFormatter={timeFormatter}
onEdit={onAnnotationEdit}
onDelete={onAnnotationDelete}
editable={Boolean(canAddAnnotations && canAddAnnotations())}
/>
);
}, [canAddAnnotations, onAnnotationDelete, onAnnotationEdit, timeFormatter, annotation]);
const isRegionAnnotation = Boolean(annotation.isRegion);
let marker = (
<div className={commonStyles(annotation).markerTriangle} style={{ transform: 'translate3d(-100%,-50%, 0)' }} />
);
if (isRegionAnnotation && plotCtx.plot) {
let x0 = plotCtx.plot!.valToPos(annotation.time, 'x');
let x1 = plotCtx.plot!.valToPos(annotation.timeEnd, 'x');
// markers are rendered relatively to uPlot canvas overly, not caring about axes width
if (x0 < 0) {
x0 = 0;
}
if (x1 > plotCtx.plot!.bbox.width / window.devicePixelRatio) {
x1 = plotCtx.plot!.bbox.width / window.devicePixelRatio;
}
marker = (
<div
className={commonStyles(annotation).markerBar}
style={{ width: `${x1 - x0}px`, transform: 'translate3d(0,-50%, 0)' }}
/>
);
}
return (
<>
<div
ref={setMarkerRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={!isRegionAnnotation ? styles.markerWrapper : undefined}
>
{marker}
</div>
{isOpen && (
<Portal>
<div
ref={setTooltipRef}
style={popper.styles.popper}
{...popper.attributes.popper}
className={styles.tooltip}
onMouseEnter={onPopoverMouseEnter}
onMouseLeave={onMouseLeave}
>
{renderTooltip()}
</div>
</Portal>
)}
{isEditing && (
<Portal>
<AnnotationEditorForm
onDismiss={() => setIsEditing(false)}
onSave={() => setIsEditing(false)}
timeFormatter={timeFormatter}
annotation={annotation}
ref={setEditorRef}
style={editorPopper.styles.popper}
{...editorPopper.attributes.popper}
/>
</Portal>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
markerWrapper: css`
label: markerWrapper;
padding: 0 4px 4px 4px;
`,
wrapper: css`
max-width: 400px;
`,
tooltip: css`
${getTooltipContainerStyles(theme)};
padding: 0;
`,
};
};

View File

@ -0,0 +1,135 @@
import React from 'react';
import { HorizontalGroup, IconButton, Tag, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, textUtil } from '@grafana/data';
import alertDef from 'app/features/alerting/state/alertDef';
import { css } from '@emotion/css';
interface AnnotationTooltipProps {
annotation: AnnotationsDataFrameViewDTO;
timeFormatter: (v: number) => string;
editable: boolean;
onEdit: () => void;
onDelete: () => void;
}
export const AnnotationTooltip: React.FC<AnnotationTooltipProps> = ({
annotation,
timeFormatter,
editable,
onEdit,
onDelete,
}) => {
const styles = useStyles2(getStyles);
const time = timeFormatter(annotation.time);
const timeEnd = timeFormatter(annotation.timeEnd);
let text = annotation.text;
const tags = annotation.tags;
let alertText = '';
let avatar;
let editControls;
let state: React.ReactNode | null = null;
const ts = <span className={styles.time}>{Boolean(annotation.isRegion) ? `${time} - ${timeEnd}` : time}</span>;
if (annotation.login && annotation.avatarUrl) {
avatar = <img className={styles.avatar} src={annotation.avatarUrl} />;
}
if (annotation.alertId) {
const stateModel = alertDef.getStateDisplayModel(annotation.newState!);
state = (
<div className={styles.alertState}>
<i className={stateModel.stateClass}>{stateModel.text}</i>
</div>
);
alertText = alertDef.getAlertAnnotationInfo(annotation);
} else if (annotation.title) {
text = annotation.title + '<br />' + (typeof text === 'string' ? text : '');
}
if (editable) {
editControls = (
<div className={styles.editControls}>
<IconButton name={'pen'} size={'sm'} onClick={onEdit} />
<IconButton name={'trash-alt'} size={'sm'} onClick={onDelete} />
</div>
);
}
return (
<div className={styles.wrapper}>
<div className={styles.header}>
<HorizontalGroup justify={'space-between'} align={'center'} spacing={'md'}>
<div className={styles.meta}>
<span>
{avatar}
{state}
</span>
{ts}
</div>
{editControls}
</HorizontalGroup>
</div>
<div className={styles.body}>
{text && <div dangerouslySetInnerHTML={{ __html: textUtil.sanitize(text) }} />}
{alertText}
<>
<HorizontalGroup spacing="xs" wrap>
{tags?.map((t, i) => (
<Tag name={t} key={`${t}-${i}`} />
))}
</HorizontalGroup>
</>
</div>
</div>
);
};
AnnotationTooltip.displayName = 'AnnotationTooltip';
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css`
max-width: 400px;
`,
header: css`
padding: ${theme.spacing(0.5, 1)};
border-bottom: 1px solid ${theme.colors.border.weak};
font-size: ${theme.typography.bodySmall.fontSize};
display: flex;
`,
meta: css`
display: flex;
justify-content: space-between;
`,
editControls: css`
display: flex;
align-items: center;
> :last-child {
margin-right: 0;
}
`,
avatar: css`
border-radius: 50%;
width: 16px;
height: 16px;
margin-right: ${theme.spacing(1)};
`,
alertState: css`
padding-right: ${theme.spacing(1)};
font-weight: ${theme.typography.fontWeightMedium};
`,
time: css`
color: ${theme.colors.text.secondary};
font-weight: normal;
display: inline-block;
position: relative;
top: 1px;
`,
body: css`
padding: ${theme.spacing(1)};
`,
};
};

View File

@ -0,0 +1,24 @@
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
export const getCommonAnnotationStyles = (theme: GrafanaTheme2) => {
return (annotation?: AnnotationsDataFrameViewDTO) => {
const color = theme.visualization.getColorByName(annotation?.color || DEFAULT_ANNOTATION_COLOR);
return {
markerTriangle: css`
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid ${color};
`,
markerBar: css`
display: block;
width: calc(100%);
height: 5px;
background: ${color};
`,
};
};
};

View File

@ -1,9 +1,14 @@
interface AnnotationsDataFrameViewDTO {
id: string;
time: number;
timeEnd: number;
text: string;
tags: string[];
alertId?: number;
newState?: string;
title?: string;
color: string;
login?: string;
avatarUrl?: string;
isRegion?: boolean;
}