mirror of https://github.com/grafana/grafana
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>pull/35798/head^2
parent
a0dac9c6d9
commit
7df0010412
@ -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>
|
||||
// );
|
||||
// };
|
@ -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)}; |
||||
`,
|
||||
}; |
||||
}; |
@ -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)}; |
||||
`,
|
||||
}; |
||||
}; |
@ -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