mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
a0dac9c6d9
commit
7df0010412
@ -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
|
||||
*/
|
||||
|
@ -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> {
|
||||
|
@ -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 */
|
||||
|
@ -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>({
|
||||
|
@ -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';
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
// );
|
||||
// };
|
@ -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) {
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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};
|
||||
|
@ -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' },
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
},
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -169,6 +169,7 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
box-sizing: content-box;
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
|
||||
&:hover {
|
||||
> svg {
|
||||
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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};
|
||||
`,
|
||||
};
|
||||
};
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
24
public/app/plugins/panel/timeseries/plugins/styles.ts
Normal file
24
public/app/plugins/panel/timeseries/plugins/styles.ts
Normal 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};
|
||||
`,
|
||||
};
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user