mirror of https://github.com/grafana/grafana
Panels: AnnotationsPlugin2 (#79531)
Co-authored-by: Adela Almasan <adela.almasan@grafana.com>pull/80412/head
parent
d4f76c3391
commit
b53e0521d2
@ -0,0 +1,249 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; |
||||||
|
import { createPortal } from 'react-dom'; |
||||||
|
import tinycolor from 'tinycolor2'; |
||||||
|
import uPlot from 'uplot'; |
||||||
|
|
||||||
|
import { arrayToDataFrame, colorManipulator, DataFrame, DataTopic, GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { TimeZone } from '@grafana/schema'; |
||||||
|
import { DEFAULT_ANNOTATION_COLOR, UPlotConfigBuilder, useStyles2, useTheme2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { AnnotationMarker2 } from './annotations2/AnnotationMarker2'; |
||||||
|
|
||||||
|
// (copied from TooltipPlugin2)
|
||||||
|
interface TimeRange2 { |
||||||
|
from: number; |
||||||
|
to: number; |
||||||
|
} |
||||||
|
|
||||||
|
interface AnnotationsPluginProps { |
||||||
|
config: UPlotConfigBuilder; |
||||||
|
annotations: DataFrame[]; |
||||||
|
timeZone: TimeZone; |
||||||
|
newRange: TimeRange2 | null; |
||||||
|
setNewRange: (newRage: TimeRange2 | null) => void; |
||||||
|
canvasRegionRendering?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: batch by color, use Path2D objects
|
||||||
|
const renderLine = (ctx: CanvasRenderingContext2D, y0: number, y1: number, x: number, color: string) => { |
||||||
|
ctx.beginPath(); |
||||||
|
ctx.moveTo(x, y0); |
||||||
|
ctx.lineTo(x, y1); |
||||||
|
ctx.strokeStyle = color; |
||||||
|
ctx.stroke(); |
||||||
|
}; |
||||||
|
|
||||||
|
// const renderUpTriangle = (ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, color: string) => {
|
||||||
|
// ctx.beginPath();
|
||||||
|
// ctx.moveTo(x - w/2, y + h/2);
|
||||||
|
// ctx.lineTo(x + w/2, y + h/2);
|
||||||
|
// ctx.lineTo(x, y);
|
||||||
|
// ctx.closePath();
|
||||||
|
// ctx.fillStyle = color;
|
||||||
|
// ctx.fill();
|
||||||
|
// }
|
||||||
|
|
||||||
|
const DEFAULT_ANNOTATION_COLOR_HEX8 = tinycolor(DEFAULT_ANNOTATION_COLOR).toHex8String(); |
||||||
|
|
||||||
|
function getVals(frame: DataFrame) { |
||||||
|
let vals: Record<string, any[]> = {}; |
||||||
|
frame.fields.forEach((f) => { |
||||||
|
vals[f.name] = f.values; |
||||||
|
}); |
||||||
|
|
||||||
|
return vals; |
||||||
|
} |
||||||
|
|
||||||
|
export const AnnotationsPlugin2 = ({ |
||||||
|
annotations, |
||||||
|
timeZone, |
||||||
|
config, |
||||||
|
newRange, |
||||||
|
setNewRange, |
||||||
|
canvasRegionRendering = true, |
||||||
|
}: AnnotationsPluginProps) => { |
||||||
|
const [plot, setPlot] = useState<uPlot>(); |
||||||
|
|
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const getColorByName = useTheme2().visualization.getColorByName; |
||||||
|
|
||||||
|
const annos = useMemo(() => { |
||||||
|
let annos = annotations.slice(); |
||||||
|
|
||||||
|
if (newRange) { |
||||||
|
let isRegion = newRange.to > newRange.from; |
||||||
|
|
||||||
|
const wipAnnoFrame = arrayToDataFrame([ |
||||||
|
{ |
||||||
|
time: newRange.from, |
||||||
|
timeEnd: isRegion ? newRange.to : null, |
||||||
|
isRegion: isRegion, |
||||||
|
color: DEFAULT_ANNOTATION_COLOR_HEX8, |
||||||
|
}, |
||||||
|
]); |
||||||
|
|
||||||
|
wipAnnoFrame.meta = { |
||||||
|
dataTopic: DataTopic.Annotations, |
||||||
|
custom: { |
||||||
|
isWip: true, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
annos.push(wipAnnoFrame); |
||||||
|
} |
||||||
|
|
||||||
|
return annos; |
||||||
|
}, [annotations, newRange]); |
||||||
|
|
||||||
|
const exitWipEdit = useCallback(() => { |
||||||
|
setNewRange(null); |
||||||
|
}, [setNewRange]); |
||||||
|
|
||||||
|
const annoRef = useRef(annos); |
||||||
|
annoRef.current = annos; |
||||||
|
const newRangeRef = useRef(newRange); |
||||||
|
newRangeRef.current = newRange; |
||||||
|
|
||||||
|
const xAxisRef = useRef<HTMLDivElement>(); |
||||||
|
|
||||||
|
useLayoutEffect(() => { |
||||||
|
config.addHook('ready', (u) => { |
||||||
|
let xAxisEl = u.root.querySelector<HTMLDivElement>('.u-axis')!; |
||||||
|
xAxisRef.current = xAxisEl; |
||||||
|
setPlot(u); |
||||||
|
}); |
||||||
|
|
||||||
|
config.addHook('draw', (u) => { |
||||||
|
let annos = annoRef.current; |
||||||
|
|
||||||
|
const ctx = u.ctx; |
||||||
|
|
||||||
|
let y0 = u.bbox.top; |
||||||
|
let y1 = y0 + u.bbox.height; |
||||||
|
|
||||||
|
ctx.save(); |
||||||
|
|
||||||
|
ctx.beginPath(); |
||||||
|
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); |
||||||
|
ctx.clip(); |
||||||
|
|
||||||
|
ctx.lineWidth = 2; |
||||||
|
ctx.setLineDash([5, 5]); |
||||||
|
|
||||||
|
annos.forEach((frame) => { |
||||||
|
let vals = getVals(frame); |
||||||
|
|
||||||
|
for (let i = 0; i < vals.time.length; i++) { |
||||||
|
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR_HEX8); |
||||||
|
|
||||||
|
let x0 = u.valToPos(vals.time[i], 'x', true); |
||||||
|
|
||||||
|
if (!vals.isRegion?.[i]) { |
||||||
|
renderLine(ctx, y0, y1, x0, color); |
||||||
|
// renderUpTriangle(ctx, x0, y1, 8 * uPlot.pxRatio, 5 * uPlot.pxRatio, color);
|
||||||
|
} else if (canvasRegionRendering) { |
||||||
|
renderLine(ctx, y0, y1, x0, color); |
||||||
|
|
||||||
|
let x1 = u.valToPos(vals.timeEnd[i], 'x', true); |
||||||
|
|
||||||
|
renderLine(ctx, y0, y1, x1, color); |
||||||
|
|
||||||
|
ctx.fillStyle = colorManipulator.alpha(color, 0.1); |
||||||
|
ctx.fillRect(x0, y0, x1 - x0, u.bbox.height); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
ctx.restore(); |
||||||
|
}); |
||||||
|
}, [config, canvasRegionRendering, getColorByName]); |
||||||
|
|
||||||
|
// ensure annos are re-drawn whenever they change
|
||||||
|
useEffect(() => { |
||||||
|
if (plot) { |
||||||
|
plot.redraw(); |
||||||
|
} |
||||||
|
}, [annos, plot]); |
||||||
|
|
||||||
|
if (plot) { |
||||||
|
let markers = annos.flatMap((frame, frameIdx) => { |
||||||
|
let vals = getVals(frame); |
||||||
|
|
||||||
|
let markers: React.ReactNode[] = []; |
||||||
|
|
||||||
|
for (let i = 0; i < vals.time.length; i++) { |
||||||
|
let color = getColorByName(vals.color?.[i] || DEFAULT_ANNOTATION_COLOR); |
||||||
|
let left = plot.valToPos(vals.time[i], 'x'); |
||||||
|
let style: React.CSSProperties | null = null; |
||||||
|
let className = ''; |
||||||
|
let isVisible = true; |
||||||
|
|
||||||
|
if (vals.isRegion?.[i]) { |
||||||
|
let right = plot.valToPos(vals.timeEnd?.[i], 'x'); |
||||||
|
|
||||||
|
isVisible = left < plot.rect.width && right > 0; |
||||||
|
|
||||||
|
if (isVisible) { |
||||||
|
let clampedLeft = Math.max(0, left); |
||||||
|
let clampedRight = Math.min(plot.rect.width, right); |
||||||
|
|
||||||
|
style = { left: clampedLeft, background: color, width: clampedRight - clampedLeft }; |
||||||
|
className = styles.annoRegion; |
||||||
|
} |
||||||
|
} else { |
||||||
|
isVisible = left > 0 && left <= plot.rect.width; |
||||||
|
|
||||||
|
if (isVisible) { |
||||||
|
style = { left, borderBottomColor: color }; |
||||||
|
className = styles.annoMarker; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// @TODO: Reset newRange after annotation is saved
|
||||||
|
if (isVisible) { |
||||||
|
let isWip = frame.meta?.custom?.isWip; |
||||||
|
|
||||||
|
markers.push( |
||||||
|
<AnnotationMarker2 |
||||||
|
annoIdx={i} |
||||||
|
annoVals={vals} |
||||||
|
className={className} |
||||||
|
style={style} |
||||||
|
timezone={timeZone} |
||||||
|
key={`${frameIdx}:${i}`} |
||||||
|
exitWipEdit={isWip ? exitWipEdit : null} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return markers; |
||||||
|
}); |
||||||
|
|
||||||
|
return createPortal(markers, xAxisRef.current!); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
annoMarker: css({ |
||||||
|
position: 'absolute', |
||||||
|
width: 0, |
||||||
|
height: 0, |
||||||
|
borderLeft: '6px solid transparent', |
||||||
|
borderRight: '6px solid transparent', |
||||||
|
borderBottomWidth: '6px', |
||||||
|
borderBottomStyle: 'solid', |
||||||
|
transform: 'translateX(-50%)', |
||||||
|
cursor: 'pointer', |
||||||
|
zIndex: 1, |
||||||
|
}), |
||||||
|
annoRegion: css({ |
||||||
|
position: 'absolute', |
||||||
|
height: '5px', |
||||||
|
cursor: 'pointer', |
||||||
|
zIndex: 1, |
||||||
|
}), |
||||||
|
}); |
@ -0,0 +1,160 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React, { useContext, useEffect } from 'react'; |
||||||
|
import { useAsyncFn } from 'react-use'; |
||||||
|
|
||||||
|
import { AnnotationEventUIModel, GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { |
||||||
|
Button, |
||||||
|
Field, |
||||||
|
Form, |
||||||
|
HorizontalGroup, |
||||||
|
InputControl, |
||||||
|
LayoutItemContext, |
||||||
|
TextArea, |
||||||
|
usePanelContext, |
||||||
|
useStyles2, |
||||||
|
} from '@grafana/ui'; |
||||||
|
import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; |
||||||
|
import { getAnnotationTags } from 'app/features/annotations/api'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
annoVals: Record<string, any[]>; |
||||||
|
annoIdx: number; |
||||||
|
timeFormatter: (v: number) => string; |
||||||
|
dismiss: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
interface AnnotationEditFormDTO { |
||||||
|
description: string; |
||||||
|
tags: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeFormatter, ...otherProps }: Props) => { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const panelContext = usePanelContext(); |
||||||
|
|
||||||
|
const layoutCtx = useContext(LayoutItemContext); |
||||||
|
useEffect(() => layoutCtx.boostZIndex(), [layoutCtx]); |
||||||
|
|
||||||
|
const [createAnnotationState, createAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { |
||||||
|
const result = await panelContext.onAnnotationCreate!(event); |
||||||
|
dismiss(); |
||||||
|
return result; |
||||||
|
}); |
||||||
|
|
||||||
|
const [updateAnnotationState, updateAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => { |
||||||
|
const result = await panelContext.onAnnotationUpdate!(event); |
||||||
|
dismiss(); |
||||||
|
return result; |
||||||
|
}); |
||||||
|
|
||||||
|
const isUpdatingAnnotation = annoVals.id?.[annoIdx] != null; |
||||||
|
const isRegionAnnotation = annoVals.isRegion?.[annoIdx]; |
||||||
|
const operation = isUpdatingAnnotation ? updateAnnotation : createAnnotation; |
||||||
|
const stateIndicator = isUpdatingAnnotation ? updateAnnotationState : createAnnotationState; |
||||||
|
const time = isRegionAnnotation |
||||||
|
? `${timeFormatter(annoVals.time[annoIdx])} - ${timeFormatter(annoVals.timeEnd[annoIdx])}` |
||||||
|
: timeFormatter(annoVals.time[annoIdx]); |
||||||
|
|
||||||
|
const onSubmit = ({ tags, description }: AnnotationEditFormDTO) => { |
||||||
|
operation({ |
||||||
|
id: annoVals.id?.[annoIdx] ?? undefined, |
||||||
|
tags, |
||||||
|
description, |
||||||
|
from: Math.round(annoVals.time[annoIdx]!), |
||||||
|
to: Math.round(annoVals.timeEnd?.[annoIdx] ?? annoVals.time[annoIdx]!), |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
// Annotation editor
|
||||||
|
return ( |
||||||
|
<div className={styles.editor} {...otherProps}> |
||||||
|
<div className={styles.header}> |
||||||
|
<HorizontalGroup justify={'space-between'} align={'center'}> |
||||||
|
<div>{isUpdatingAnnotation ? 'Edit annotation' : 'Add annotation'}</div> |
||||||
|
<div>{time}</div> |
||||||
|
</HorizontalGroup> |
||||||
|
</div> |
||||||
|
<Form<AnnotationEditFormDTO> |
||||||
|
onSubmit={onSubmit} |
||||||
|
defaultValues={{ description: annoVals.text?.[annoIdx], tags: annoVals.tags?.[annoIdx] || [] }} |
||||||
|
> |
||||||
|
{({ register, errors, control }) => { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<div className={styles.content}> |
||||||
|
<Field label={'Description'} invalid={!!errors.description} error={errors?.description?.message}> |
||||||
|
<TextArea |
||||||
|
className={styles.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> |
||||||
|
</div> |
||||||
|
<div className={styles.footer}> |
||||||
|
<HorizontalGroup justify={'flex-end'}> |
||||||
|
<Button size={'sm'} variant="secondary" onClick={dismiss} fill="outline"> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button size={'sm'} type={'submit'} disabled={stateIndicator?.loading}> |
||||||
|
{stateIndicator?.loading ? 'Saving' : 'Save'} |
||||||
|
</Button> |
||||||
|
</HorizontalGroup> |
||||||
|
</div> |
||||||
|
</> |
||||||
|
); |
||||||
|
}} |
||||||
|
</Form> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
editor: css({ |
||||||
|
// zIndex: theme.zIndex.tooltip,
|
||||||
|
background: theme.colors.background.primary, |
||||||
|
border: `1px solid ${theme.colors.border.weak}`, |
||||||
|
borderRadius: theme.shape.radius.default, |
||||||
|
boxShadow: theme.shadows.z3, |
||||||
|
userSelect: 'text', |
||||||
|
width: '460px', |
||||||
|
}), |
||||||
|
content: css({ |
||||||
|
padding: theme.spacing(1), |
||||||
|
}), |
||||||
|
header: css({ |
||||||
|
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||||
|
padding: theme.spacing(0.5, 1), |
||||||
|
fontWeight: theme.typography.fontWeightBold, |
||||||
|
fontSize: theme.typography.fontSize, |
||||||
|
color: theme.colors.text.primary, |
||||||
|
}), |
||||||
|
footer: css({ |
||||||
|
borderTop: `1px solid ${theme.colors.border.weak}`, |
||||||
|
padding: theme.spacing(1, 1), |
||||||
|
}), |
||||||
|
textarea: css({ |
||||||
|
color: theme.colors.text.secondary, |
||||||
|
fontSize: theme.typography.bodySmall.fontSize, |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
@ -0,0 +1,144 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; |
||||||
|
import useClickAway from 'react-use/lib/useClickAway'; |
||||||
|
|
||||||
|
import { dateTimeFormat, GrafanaTheme2, systemDateFormats } from '@grafana/data'; |
||||||
|
import { TimeZone } from '@grafana/schema'; |
||||||
|
import { usePanelContext, useStyles2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { AnnotationEditor2 } from './AnnotationEditor2'; |
||||||
|
import { AnnotationTooltip2 } from './AnnotationTooltip2'; |
||||||
|
|
||||||
|
interface AnnoBoxProps { |
||||||
|
annoVals: Record<string, any[]>; |
||||||
|
annoIdx: number; |
||||||
|
style: React.CSSProperties | null; |
||||||
|
className: string; |
||||||
|
timezone: TimeZone; |
||||||
|
exitWipEdit?: null | (() => void); |
||||||
|
} |
||||||
|
|
||||||
|
const STATE_DEFAULT = 0; |
||||||
|
const STATE_EDITING = 1; |
||||||
|
const STATE_HOVERED = 2; |
||||||
|
|
||||||
|
export const AnnotationMarker2 = ({ annoVals, annoIdx, className, style, exitWipEdit, timezone }: AnnoBoxProps) => { |
||||||
|
const { canEditAnnotations, canDeleteAnnotations, ...panelCtx } = usePanelContext(); |
||||||
|
|
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
const [state, setState] = useState(STATE_DEFAULT); |
||||||
|
|
||||||
|
const clickAwayRef = useRef(null); |
||||||
|
|
||||||
|
useClickAway(clickAwayRef, () => { |
||||||
|
if (state === STATE_EDITING) { |
||||||
|
setIsEditingWrap(false); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
const domRef = React.createRef<HTMLDivElement>(); |
||||||
|
|
||||||
|
// similar to TooltipPlugin2, when editing annotation (pinned), it should boost z-index
|
||||||
|
const setIsEditingWrap = useCallback( |
||||||
|
(isEditing: boolean) => { |
||||||
|
setState(isEditing ? STATE_EDITING : STATE_DEFAULT); |
||||||
|
if (!isEditing && exitWipEdit != null) { |
||||||
|
exitWipEdit(); |
||||||
|
} |
||||||
|
}, |
||||||
|
[exitWipEdit] |
||||||
|
); |
||||||
|
|
||||||
|
const onAnnotationEdit = useCallback(() => { |
||||||
|
setIsEditingWrap(true); |
||||||
|
}, [setIsEditingWrap]); |
||||||
|
|
||||||
|
const onAnnotationDelete = useCallback(() => { |
||||||
|
if (panelCtx.onAnnotationDelete) { |
||||||
|
panelCtx.onAnnotationDelete(annoVals.id?.[annoIdx]); |
||||||
|
} |
||||||
|
}, [annoIdx, annoVals.id, panelCtx]); |
||||||
|
|
||||||
|
const timeFormatter = useCallback( |
||||||
|
(value: number) => { |
||||||
|
return dateTimeFormat(value, { |
||||||
|
format: systemDateFormats.fullDate, |
||||||
|
timeZone: timezone, |
||||||
|
}); |
||||||
|
}, |
||||||
|
[timezone] |
||||||
|
); |
||||||
|
|
||||||
|
useLayoutEffect( |
||||||
|
() => { |
||||||
|
if (exitWipEdit != null) { |
||||||
|
setIsEditingWrap(true); |
||||||
|
} |
||||||
|
}, |
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[] |
||||||
|
); |
||||||
|
|
||||||
|
const renderAnnotationTooltip = useCallback(() => { |
||||||
|
let dashboardUID = annoVals.dashboardUID?.[annoIdx]; |
||||||
|
|
||||||
|
return ( |
||||||
|
<AnnotationTooltip2 |
||||||
|
timeFormatter={timeFormatter} |
||||||
|
onEdit={onAnnotationEdit} |
||||||
|
onDelete={onAnnotationDelete} |
||||||
|
canEdit={canEditAnnotations ? canEditAnnotations(dashboardUID) : false} |
||||||
|
canDelete={canDeleteAnnotations ? canDeleteAnnotations(dashboardUID) : false} |
||||||
|
annoIdx={annoIdx} |
||||||
|
annoVals={annoVals} |
||||||
|
/> |
||||||
|
); |
||||||
|
}, [ |
||||||
|
timeFormatter, |
||||||
|
onAnnotationEdit, |
||||||
|
onAnnotationDelete, |
||||||
|
canEditAnnotations, |
||||||
|
annoVals, |
||||||
|
annoIdx, |
||||||
|
canDeleteAnnotations, |
||||||
|
]); |
||||||
|
|
||||||
|
const renderAnnotationEditor = useCallback(() => { |
||||||
|
return ( |
||||||
|
<AnnotationEditor2 |
||||||
|
dismiss={() => setIsEditingWrap(false)} |
||||||
|
timeFormatter={timeFormatter} |
||||||
|
annoIdx={annoIdx} |
||||||
|
annoVals={annoVals} |
||||||
|
/> |
||||||
|
); |
||||||
|
}, [annoIdx, annoVals, timeFormatter, setIsEditingWrap]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
ref={domRef} |
||||||
|
className={className} |
||||||
|
style={style!} |
||||||
|
onMouseEnter={() => state !== STATE_EDITING && setState(STATE_HOVERED)} |
||||||
|
onMouseLeave={() => state !== STATE_EDITING && setState(STATE_DEFAULT)} |
||||||
|
> |
||||||
|
<div className={styles.annoInfo} ref={clickAwayRef}> |
||||||
|
{state === STATE_HOVERED && renderAnnotationTooltip()} |
||||||
|
{state === STATE_EDITING && renderAnnotationEditor()} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
annoInfo: css({ |
||||||
|
background: theme.colors.background.secondary, |
||||||
|
minWidth: '300px', |
||||||
|
// maxWidth: '400px',
|
||||||
|
position: 'absolute', |
||||||
|
top: '5px', |
||||||
|
left: '50%', |
||||||
|
transform: 'translateX(-50%)', |
||||||
|
}), |
||||||
|
}); |
@ -0,0 +1,157 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React, { useContext, useEffect } from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2, textUtil } from '@grafana/data'; |
||||||
|
import { HorizontalGroup, IconButton, LayoutItemContext, Tag, useStyles2 } from '@grafana/ui'; |
||||||
|
import alertDef from 'app/features/alerting/state/alertDef'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
annoVals: Record<string, any[]>; |
||||||
|
annoIdx: number; |
||||||
|
timeFormatter: (v: number) => string; |
||||||
|
canEdit: boolean; |
||||||
|
canDelete: boolean; |
||||||
|
onEdit: () => void; |
||||||
|
onDelete: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
export const AnnotationTooltip2 = ({ |
||||||
|
annoVals, |
||||||
|
annoIdx, |
||||||
|
timeFormatter, |
||||||
|
canEdit, |
||||||
|
canDelete, |
||||||
|
onEdit, |
||||||
|
onDelete, |
||||||
|
}: Props) => { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
const layoutCtx = useContext(LayoutItemContext); |
||||||
|
useEffect(() => layoutCtx.boostZIndex(), [layoutCtx]); |
||||||
|
|
||||||
|
let time = timeFormatter(annoVals.time[annoIdx]); |
||||||
|
let text = annoVals.text[annoIdx]; |
||||||
|
|
||||||
|
if (annoVals.isRegion?.[annoIdx]) { |
||||||
|
time += ' - ' + timeFormatter(annoVals.timeEnd[annoIdx]); |
||||||
|
} |
||||||
|
|
||||||
|
let avatar; |
||||||
|
if (annoVals.login?.[annoIdx] && annoVals.avatarUrl?.[annoIdx]) { |
||||||
|
avatar = <img className={styles.avatar} alt="Annotation avatar" src={annoVals.avatarUrl[annoIdx]} />; |
||||||
|
} |
||||||
|
|
||||||
|
let state: React.ReactNode | null = null; |
||||||
|
let alertText = ''; |
||||||
|
|
||||||
|
if (annoVals.alertId?.[annoIdx] !== undefined && annoVals.newState?.[annoIdx]) { |
||||||
|
const stateModel = alertDef.getStateDisplayModel(annoVals.newState[annoIdx]); |
||||||
|
state = ( |
||||||
|
<div className={styles.alertState}> |
||||||
|
<i className={stateModel.stateClass}>{stateModel.text}</i> |
||||||
|
</div> |
||||||
|
); |
||||||
|
|
||||||
|
// alertText = alertDef.getAlertAnnotationInfo(annotation); // @TODO ??
|
||||||
|
} else if (annoVals.title?.[annoIdx]) { |
||||||
|
text = annoVals.title[annoIdx] + '<br />' + (typeof text === 'string' ? text : ''); |
||||||
|
} |
||||||
|
|
||||||
|
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> |
||||||
|
{time} |
||||||
|
</div> |
||||||
|
{(canEdit || canDelete) && ( |
||||||
|
<div className={styles.editControls}> |
||||||
|
{canEdit && <IconButton name={'pen'} size={'sm'} onClick={onEdit} tooltip="Edit" />} |
||||||
|
{canDelete && ( |
||||||
|
<IconButton |
||||||
|
name={'trash-alt'} |
||||||
|
size={'sm'} |
||||||
|
onClick={onDelete} |
||||||
|
tooltip="Delete" |
||||||
|
disabled={!annoVals.id?.[annoIdx]} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</HorizontalGroup> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className={styles.body}> |
||||||
|
{text && <div className={styles.text} dangerouslySetInnerHTML={{ __html: textUtil.sanitize(text) }} />} |
||||||
|
{alertText} |
||||||
|
<div> |
||||||
|
<HorizontalGroup spacing="xs" wrap> |
||||||
|
{annoVals.tags?.[annoIdx]?.map((t: string, i: number) => <Tag name={t} key={`${t}-${i}`} />)} |
||||||
|
</HorizontalGroup> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
wrapper: css({ |
||||||
|
zIndex: theme.zIndex.tooltip, |
||||||
|
whiteSpace: 'initial', |
||||||
|
borderRadius: theme.shape.radius.default, |
||||||
|
background: theme.colors.background.primary, |
||||||
|
border: `1px solid ${theme.colors.border.weak}`, |
||||||
|
boxShadow: theme.shadows.z2, |
||||||
|
userSelect: 'text', |
||||||
|
}), |
||||||
|
header: css({ |
||||||
|
padding: theme.spacing(0.5, 1), |
||||||
|
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||||
|
fontWeight: theme.typography.fontWeightBold, |
||||||
|
fontSize: theme.typography.fontSize, |
||||||
|
color: theme.colors.text.primary, |
||||||
|
display: 'flex', |
||||||
|
}), |
||||||
|
meta: css({ |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'space-between', |
||||||
|
color: theme.colors.text.primary, |
||||||
|
fontWeight: 400, |
||||||
|
}), |
||||||
|
editControls: css({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
'> :last-child': { |
||||||
|
marginLeft: 0, |
||||||
|
}, |
||||||
|
}), |
||||||
|
body: css({ |
||||||
|
padding: theme.spacing(1), |
||||||
|
fontSize: theme.typography.bodySmall.fontSize, |
||||||
|
color: theme.colors.text.secondary, |
||||||
|
fontWeight: 400, |
||||||
|
a: { |
||||||
|
color: theme.colors.text.link, |
||||||
|
'&:hover': { |
||||||
|
textDecoration: 'underline', |
||||||
|
}, |
||||||
|
}, |
||||||
|
}), |
||||||
|
text: css({ |
||||||
|
paddingBottom: theme.spacing(1), |
||||||
|
}), |
||||||
|
avatar: css({ |
||||||
|
borderRadius: theme.shape.radius.circle, |
||||||
|
width: 16, |
||||||
|
height: 16, |
||||||
|
marginRight: theme.spacing(1), |
||||||
|
}), |
||||||
|
alertState: css({ |
||||||
|
paddingRight: theme.spacing(1), |
||||||
|
fontWeight: theme.typography.fontWeightMedium, |
||||||
|
}), |
||||||
|
}); |
Loading…
Reference in new issue