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'
|
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
|
* @alpha -- any value other than `field` is experimental
|
||||||
*/
|
*/
|
||||||
|
@ -5,13 +5,13 @@ import { useStyles2 } from '../../themes';
|
|||||||
import { MenuItemProps } from './MenuItem';
|
import { MenuItemProps } from './MenuItem';
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export interface MenuItemsGroup {
|
export interface MenuItemsGroup<T = any> {
|
||||||
/** Label for the menu items group */
|
/** Label for the menu items group */
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Aria label for accessibility support */
|
/** Aria label for accessibility support */
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
/** Items of the group */
|
/** Items of the group */
|
||||||
items: MenuItemProps[];
|
items: Array<MenuItemProps<T>>;
|
||||||
}
|
}
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export interface MenuGroupProps extends Partial<MenuItemsGroup> {
|
export interface MenuGroupProps extends Partial<MenuItemsGroup> {
|
||||||
|
@ -6,7 +6,7 @@ import { Icon } from '../Icon/Icon';
|
|||||||
import { IconName } from '../../types';
|
import { IconName } from '../../types';
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export interface MenuItemProps {
|
export interface MenuItemProps<T = any> {
|
||||||
/** Label of the menu item */
|
/** Label of the menu item */
|
||||||
label: string;
|
label: string;
|
||||||
/** Aria label for accessibility support */
|
/** Aria label for accessibility support */
|
||||||
@ -18,7 +18,7 @@ export interface MenuItemProps {
|
|||||||
/** Url of the menu item */
|
/** Url of the menu item */
|
||||||
url?: string;
|
url?: string;
|
||||||
/** Handler for the click behaviour */
|
/** Handler for the click behaviour */
|
||||||
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
|
onClick?: (event?: React.SyntheticEvent<HTMLElement>, payload?: T) => void;
|
||||||
/** Custom MenuItem styles*/
|
/** Custom MenuItem styles*/
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Active */
|
/** 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 React from 'react';
|
||||||
import { SeriesVisibilityChangeMode } from '.';
|
import { SeriesVisibilityChangeMode } from '.';
|
||||||
|
|
||||||
@ -17,6 +17,11 @@ export interface PanelContext {
|
|||||||
onSeriesColorChange?: (label: string, color: string) => void;
|
onSeriesColorChange?: (label: string, color: string) => void;
|
||||||
|
|
||||||
onToggleSeriesVisibility?: (label: string, mode: SeriesVisibilityChangeMode) => 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>({
|
export const PanelContextRoot = React.createContext<PanelContext>({
|
||||||
|
@ -245,7 +245,7 @@ export { PlotLegend } from './uPlot/PlotLegend';
|
|||||||
export * from './uPlot/geometries';
|
export * from './uPlot/geometries';
|
||||||
export * from './uPlot/plugins';
|
export * from './uPlot/plugins';
|
||||||
export { usePlotContext } from './uPlot/context';
|
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 { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG';
|
||||||
export { TimeSeries } from './TimeSeries/TimeSeries';
|
export { TimeSeries } from './TimeSeries/TimeSeries';
|
||||||
export { useGraphNGContext } from './GraphNG/hooks';
|
export { useGraphNGContext } from './GraphNG/hooks';
|
||||||
|
@ -17,7 +17,6 @@ export const Marker: React.FC<MarkerProps> = ({ x, y, children }) => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: ${y}px;
|
top: ${y}px;
|
||||||
left: ${x}px;
|
left: ${x}px;
|
||||||
transform: translate3d(-50%, -50%, 0);
|
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{children}
|
{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 React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||||
import { pluginLog } from '../utils';
|
import { pluginLog } from '../utils';
|
||||||
|
import { PlotSelection } from '../types';
|
||||||
interface Selection {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
|
|
||||||
// selection bounding box, relative to canvas
|
|
||||||
bbox: {
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZoomPluginProps {
|
interface ZoomPluginProps {
|
||||||
onZoom: (range: { from: number; to: number }) => void;
|
onZoom: (range: { from: number; to: number }) => void;
|
||||||
@ -27,7 +15,7 @@ const MIN_ZOOM_DIST = 5;
|
|||||||
* @alpha
|
* @alpha
|
||||||
*/
|
*/
|
||||||
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom, config }) => {
|
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom, config }) => {
|
||||||
const [selection, setSelection] = useState<Selection | null>(null);
|
const [selection, setSelection] = useState<PlotSelection | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selection) {
|
if (selection) {
|
||||||
|
@ -36,3 +36,16 @@ export type PlotTooltipInterpolator = (
|
|||||||
updateActiveDatapointIdx: (dIdx: number | null) => void,
|
updateActiveDatapointIdx: (dIdx: number | null) => void,
|
||||||
updateTooltipPosition: (clear?: boolean) => void
|
updateTooltipPosition: (clear?: boolean) => void
|
||||||
) => (u: uPlot) => 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
|
// max-width is set up based on .grafana-tooltip class that's used in dashboard
|
||||||
export const getTooltipContainerStyles = (theme: GrafanaTheme2) => `
|
export const getTooltipContainerStyles = (theme: GrafanaTheme2) => `
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: ${theme.colors.background.secondary};
|
background: ${theme.colors.background.secondary};
|
||||||
box-shadow: ${theme.shadows.z2};
|
box-shadow: ${theme.shadows.z2};
|
||||||
|
@ -106,6 +106,9 @@ export const annotationEventNames: AnnotationFieldInfo[] = [
|
|||||||
placeholder: 'text, or the first text field',
|
placeholder: 'text, or the first text field',
|
||||||
},
|
},
|
||||||
{ key: 'tags', split: ',', help: 'The results will be split on comma (,)' },
|
{ key: 'tags', split: ',', help: 'The results will be split on comma (,)' },
|
||||||
|
{
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
// { key: 'userId' },
|
// { key: 'userId' },
|
||||||
// { key: 'login' },
|
// { key: 'login' },
|
||||||
// { key: 'email' },
|
// { key: 'email' },
|
||||||
|
@ -15,6 +15,7 @@ import { DashboardModel, PanelModel } from '../state';
|
|||||||
import { PANEL_BORDER } from 'app/core/constants';
|
import { PANEL_BORDER } from 'app/core/constants';
|
||||||
import {
|
import {
|
||||||
AbsoluteTimeRange,
|
AbsoluteTimeRange,
|
||||||
|
AnnotationEventUIModel,
|
||||||
DashboardCursorSync,
|
DashboardCursorSync,
|
||||||
EventFilterOptions,
|
EventFilterOptions,
|
||||||
FieldConfigSource,
|
FieldConfigSource,
|
||||||
@ -31,6 +32,8 @@ import { loadSnapshotData } from '../utils/loadSnapshotData';
|
|||||||
import { RefreshEvent, RenderEvent } from 'app/types/events';
|
import { RefreshEvent, RenderEvent } from 'app/types/events';
|
||||||
import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
|
import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
|
||||||
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
|
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';
|
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
|
||||||
|
|
||||||
@ -74,6 +77,10 @@ export class PanelChrome extends Component<Props, State> {
|
|||||||
eventBus,
|
eventBus,
|
||||||
onSeriesColorChange: this.onSeriesColorChange,
|
onSeriesColorChange: this.onSeriesColorChange,
|
||||||
onToggleSeriesVisibility: this.onSeriesVisibilityChange,
|
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(),
|
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() {
|
get hasPanelSnapshot() {
|
||||||
const { panel } = this.props;
|
const { panel } = this.props;
|
||||||
return panel.snapshotData && panel.snapshotData.length;
|
return panel.snapshotData && panel.snapshotData.length;
|
||||||
|
@ -8,6 +8,7 @@ import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
|
|||||||
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
||||||
import { TimeSeriesOptions } from './types';
|
import { TimeSeriesOptions } from './types';
|
||||||
import { prepareGraphableFields } from './utils';
|
import { prepareGraphableFields } from './utils';
|
||||||
|
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
|
||||||
|
|
||||||
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
|
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
|||||||
onChangeTimeRange,
|
onChangeTimeRange,
|
||||||
replaceVariables,
|
replaceVariables,
|
||||||
}) => {
|
}) => {
|
||||||
const { sync } = usePanelContext();
|
const { sync, canAddAnnotations } = usePanelContext();
|
||||||
|
|
||||||
const getFieldLinks = (field: Field, rowIndex: number) => {
|
const getFieldLinks = (field: Field, rowIndex: number) => {
|
||||||
return getFieldLinksForExplore({ field, rowIndex, range: timeRange });
|
return getFieldLinksForExplore({ field, rowIndex, range: timeRange });
|
||||||
@ -37,6 +38,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
|
||||||
return (
|
return (
|
||||||
<TimeSeries
|
<TimeSeries
|
||||||
frames={frames}
|
frames={frames}
|
||||||
@ -57,16 +59,44 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
|||||||
mode={sync === DashboardCursorSync.Tooltip ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
mode={sync === DashboardCursorSync.Tooltip ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
/>
|
/>
|
||||||
<ContextMenuPlugin
|
{/* Renders annotation markers*/}
|
||||||
data={alignedDataFrame}
|
|
||||||
config={config}
|
|
||||||
timeZone={timeZone}
|
|
||||||
replaceVariables={replaceVariables}
|
|
||||||
/>
|
|
||||||
{data.annotations && (
|
{data.annotations && (
|
||||||
<AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
|
<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 && (
|
{data.annotations && (
|
||||||
<ExemplarsPlugin
|
<ExemplarsPlugin
|
||||||
config={config}
|
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 { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui';
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
import { AnnotationMarker } from './AnnotationMarker';
|
import { AnnotationMarker } from './annotations/AnnotationMarker';
|
||||||
|
|
||||||
interface AnnotationsPluginProps {
|
interface AnnotationsPluginProps {
|
||||||
config: UPlotConfigBuilder;
|
config: UPlotConfigBuilder;
|
||||||
@ -40,6 +40,22 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
|||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return;
|
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++) {
|
for (let i = 0; i < annotationsRef.current.length; i++) {
|
||||||
const annotationsView = annotationsRef.current[i];
|
const annotationsView = annotationsRef.current[i];
|
||||||
for (let j = 0; j < annotationsView.length; j++) {
|
for (let j = 0; j < annotationsView.length; j++) {
|
||||||
@ -49,17 +65,23 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const xpos = u.valToPos(annotation.time, 'x', true);
|
let x0 = u.valToPos(annotation.time, 'x', true);
|
||||||
ctx.beginPath();
|
const color = theme.visualization.getColorByName(annotation.color);
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.strokeStyle = getColorForTheme(annotation.color, theme);
|
renderLine(x0, color);
|
||||||
ctx.setLineDash([5, 5]);
|
|
||||||
ctx.moveTo(xpos, u.bbox.top);
|
if (annotation.isRegion && annotation.timeEnd) {
|
||||||
ctx.lineTo(xpos, u.bbox.top + u.bbox.height);
|
let x1 = u.valToPos(annotation.timeEnd, 'x', true);
|
||||||
ctx.stroke();
|
|
||||||
ctx.closePath();
|
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;
|
return;
|
||||||
});
|
});
|
||||||
}, [config, theme]);
|
}, [config, theme]);
|
||||||
@ -72,9 +94,13 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
|||||||
if (!annotation.time || !plotInstance) {
|
if (!annotation.time || !plotInstance) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
let x = plotInstance.valToPos(annotation.time, 'x');
|
||||||
|
|
||||||
|
if (x < 0) {
|
||||||
|
x = 0;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
x: plotInstance.valToPos(annotation.time, 'x'),
|
x,
|
||||||
y: plotInstance.bbox.height / window.devicePixelRatio + 4,
|
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 { css as cssCore, Global } from '@emotion/react';
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@ -9,15 +9,23 @@ import {
|
|||||||
MenuGroup,
|
MenuGroup,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
UPlotConfigBuilder,
|
UPlotConfigBuilder,
|
||||||
|
usePlotContext,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { CartesianCoords2D, DataFrame, getFieldDisplayName, InterpolateFunction, TimeZone } from '@grafana/data';
|
import { CartesianCoords2D, DataFrame, getFieldDisplayName, InterpolateFunction, TimeZone } from '@grafana/data';
|
||||||
import { useClickAway } from 'react-use';
|
import { useClickAway } from 'react-use';
|
||||||
import { pluginLog } from '@grafana/ui/src/components/uPlot/utils';
|
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 {
|
interface ContextMenuPluginProps {
|
||||||
data: DataFrame;
|
data: DataFrame;
|
||||||
config: UPlotConfigBuilder;
|
config: UPlotConfigBuilder;
|
||||||
defaultItems?: MenuItemsGroup[];
|
defaultItems?: Array<MenuItemsGroup<ContextMenuItemClickPayload>>;
|
||||||
timeZone: TimeZone;
|
timeZone: TimeZone;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@ -27,15 +35,15 @@ interface ContextMenuPluginProps {
|
|||||||
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
||||||
data,
|
data,
|
||||||
config,
|
config,
|
||||||
defaultItems,
|
|
||||||
onClose,
|
onClose,
|
||||||
timeZone,
|
timeZone,
|
||||||
replaceVariables,
|
replaceVariables,
|
||||||
|
...otherProps
|
||||||
}) => {
|
}) => {
|
||||||
|
const plotCtx = usePlotContext();
|
||||||
const plotCanvas = useRef<HTMLDivElement>();
|
const plotCanvas = useRef<HTMLDivElement>();
|
||||||
const plotCanvasBBox = useRef<any>({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 });
|
const [coords, setCoords] = useState<ContextMenuSelectionCoords | null>(null);
|
||||||
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null);
|
const [point, setPoint] = useState<ContextMenuSelectionPoint | null>(null);
|
||||||
const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const openMenu = useCallback(() => {
|
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
|
// Add uPlot hooks to the config, or re-add when the config changed
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const onMouseCapture = (e: MouseEvent) => {
|
const onMouseCapture = (e: MouseEvent) => {
|
||||||
setCoords({
|
const bbox = plotCtx.getCanvasBoundingBox();
|
||||||
plotCanvas: {
|
let update = {
|
||||||
x: e.clientX - plotCanvasBBox.current.left,
|
|
||||||
y: e.clientY - plotCanvasBBox.current.top,
|
|
||||||
},
|
|
||||||
viewport: {
|
viewport: {
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
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) => {
|
config.addHook('init', (u) => {
|
||||||
const canvas = u.over;
|
const canvas = u.over;
|
||||||
plotCanvas.current = canvas || undefined;
|
plotCanvas.current = canvas || undefined;
|
||||||
plotCanvas.current?.addEventListener('mousedown', onMouseCapture);
|
plotCanvas.current?.addEventListener('mousedown', onMouseCapture);
|
||||||
plotCanvas.current?.addEventListener('mouseleave', () => {});
|
|
||||||
|
|
||||||
pluginLog('ContextMenuPlugin', false, 'init');
|
pluginLog('ContextMenuPlugin', false, 'init');
|
||||||
// for naive click&drag check
|
// for naive click&drag check
|
||||||
@ -79,16 +97,18 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
|||||||
// REF: https://github.com/leeoniya/uPlot/issues/239
|
// REF: https://github.com/leeoniya/uPlot/issues/239
|
||||||
let pts = Array.from(u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt'));
|
let pts = Array.from(u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt'));
|
||||||
|
|
||||||
plotCanvas.current?.addEventListener('mousedown', (e: MouseEvent) => {
|
plotCanvas.current?.addEventListener('mousedown', () => {
|
||||||
isClick = true;
|
isClick = true;
|
||||||
});
|
});
|
||||||
plotCanvas.current?.addEventListener('mousemove', (e: MouseEvent) => {
|
|
||||||
|
plotCanvas.current?.addEventListener('mousemove', () => {
|
||||||
isClick = false;
|
isClick = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: remove listeners on unmount
|
// TODO: remove listeners on unmount
|
||||||
plotCanvas.current?.addEventListener('mouseup', (e: MouseEvent) => {
|
plotCanvas.current?.addEventListener('mouseup', (e: MouseEvent) => {
|
||||||
if (!isClick) {
|
// ignore cmd+click, this is handled by annotation editor
|
||||||
|
if (!isClick || e.metaKey) {
|
||||||
setPoint(null);
|
setPoint(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -101,22 +121,46 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
|||||||
setPoint({ seriesIdx: null, dataIdx: null });
|
setPoint({ seriesIdx: null, dataIdx: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openMenu();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pts.length > 0) {
|
if (pts.length > 0) {
|
||||||
pts.forEach((pt, i) => {
|
pts.forEach((pt, i) => {
|
||||||
// TODO: remove listeners on unmount
|
// TODO: remove listeners on unmount
|
||||||
pt.addEventListener('click', (e) => {
|
pt.addEventListener('click', () => {
|
||||||
const seriesIdx = i + 1;
|
const seriesIdx = i + 1;
|
||||||
const dataIdx = u.cursor.idx;
|
const dataIdx = u.cursor.idx;
|
||||||
pluginLog('ContextMenuPlugin', false, seriesIdx, dataIdx);
|
pluginLog('ContextMenuPlugin', false, seriesIdx, dataIdx);
|
||||||
openMenu();
|
|
||||||
setPoint({ seriesIdx, dataIdx: dataIdx || null });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -169,6 +169,7 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
|
transform: translate3d(-50%, 0, 0);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
> svg {
|
> 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 {
|
interface AnnotationsDataFrameViewDTO {
|
||||||
|
id: string;
|
||||||
time: number;
|
time: number;
|
||||||
|
timeEnd: number;
|
||||||
text: string;
|
text: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
alertId?: number;
|
alertId?: number;
|
||||||
newState?: string;
|
newState?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
login?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
isRegion?: boolean;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user