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