Alerting: Support for alerting for react panels, and lots of fixes to alert annotations for both react and angular (#33608)

* Alerting: Support for alerting for react panels, and lots of fixes to alert annotations for both react and angular

* Fix showing annotations in panel edit
This commit is contained in:
Torkel Ödegaard 2021-05-03 08:52:05 +02:00 committed by GitHub
parent 567b852072
commit f2e4f41f69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 141 additions and 110 deletions

View File

@ -44,6 +44,8 @@ export interface AnnotationEvent {
type?: string;
tags?: string[];
color?: string;
alertId?: number;
newState?: string;
// Currently used to merge annotations from alerts and dashboard
source?: any; // source.type === 'dashboard'

View File

@ -169,7 +169,7 @@ export enum NullValueMode {
*/
export interface DataConfigSource {
configRev?: number;
dataSupport?: PanelPluginDataSupport;
getDataSupport: () => PanelPluginDataSupport;
getTransformations: () => DataTransformerConfig[] | undefined;
getFieldOverrideOptions: () => ApplyFieldOverrideOptions | undefined;
}

View File

@ -15,6 +15,7 @@ import { TestRuleResult } from './TestRuleResult';
import { AppNotificationSeverity, StoreState } from 'app/types';
import { PanelNotSupported } from '../dashboard/components/PanelEditor/PanelNotSupported';
import { AlertState } from '../../plugins/datasource/alertmanager/types';
import { EventBusSrv } from '@grafana/data';
interface AngularPanelController {
_enableAlert: () => void;
@ -76,24 +77,28 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
async loadAlertTab() {
const { panel, angularPanelComponent } = this.props;
if (!this.element || !angularPanelComponent || this.component) {
if (!this.element || this.component) {
return;
}
const scope = angularPanelComponent.getScope();
if (angularPanelComponent) {
const scope = angularPanelComponent.getScope();
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) {
setTimeout(() => {
this.forceUpdate();
});
return;
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
if (!scope.$$childHead) {
setTimeout(() => {
this.forceUpdate();
});
return;
}
this.panelCtrl = scope.$$childHead.ctrl;
} else {
this.panelCtrl = this.getReactAlertPanelCtrl();
}
this.panelCtrl = scope.$$childHead.ctrl;
const loader = getAngularLoader();
const template = '<alert-tab />';
const scopeProps = { ctrl: this.panelCtrl };
this.component = loader.load(this.element, scopeProps, template);
@ -110,6 +115,16 @@ class UnConnectedAlertTab extends PureComponent<Props, State> {
}
}
getReactAlertPanelCtrl() {
return {
panel: this.props.panel,
events: new EventBusSrv(),
render: () => {
this.props.panel.render();
},
} as any;
}
onAddAlert = () => {
this.panelCtrl?._enableAlert();
this.component?.digest();

View File

@ -34,7 +34,7 @@ export const getPanelEditorTabs = memoizeOne((tab?: string, plugin?: PanelPlugin
});
}
if (getConfig().alertingEnabled && plugin.meta.id === 'graph') {
if ((getConfig().alertingEnabled && plugin.meta.id === 'graph') || plugin.meta.id === 'timeseries') {
tabs.push({
id: PanelEditorTabId.Alert,
text: 'Alert',

View File

@ -223,7 +223,7 @@ export class PanelChrome extends Component<Props, State> {
panel.getQueryRunner().run({
datasource: panel.datasource,
queries: panel.targets,
panelId: panel.id,
panelId: panel.editSourceId || panel.id,
dashboardId: this.props.dashboard.id,
timezone: this.props.dashboard.getTimezone(),
timeRange: timeData.timeRange,
@ -364,11 +364,14 @@ export class PanelChrome extends Component<Props, State> {
const { errorMessage, data } = this.state;
const { transparent } = panel;
let alertState = data.alertState?.state;
const containerClassNames = classNames({
'panel-container': true,
'panel-container--absolute': true,
'panel-container--transparent': transparent,
'panel-container--no-title': this.hasOverlayHeader(),
[`panel-alert-state--${alertState}`]: alertState !== undefined,
});
return (
@ -382,6 +385,7 @@ export class PanelChrome extends Component<Props, State> {
error={errorMessage}
isEditing={isEditing}
isViewing={isViewing}
alertState={alertState}
data={data}
/>
<ErrorBoundary>

View File

@ -16,7 +16,6 @@ import { StoreState } from 'app/types';
import { getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data';
import { PANEL_BORDER } from 'app/core/constants';
import { selectors } from '@grafana/e2e-selectors';
import { RenderEvent } from 'app/types/events';
interface OwnProps {
panel: PanelModel;
@ -42,7 +41,6 @@ export type Props = OwnProps & ConnectedProps & DispatchProps;
export interface State {
data: PanelData;
errorMessage?: string;
alertState?: string;
}
interface AngularScopeProps {
@ -84,29 +82,8 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
next: (data: PanelData) => this.onPanelDataUpdate(data),
})
);
this.subs.add(panel.events.subscribe(RenderEvent, this.onPanelRenderEvent));
}
onPanelRenderEvent = (event: RenderEvent) => {
const { alertState } = this.state;
// graph sends these old render events with payloads
const payload = event.payload;
if (payload && payload.alertState && this.props.panel.alert) {
this.setState({ alertState: payload.alertState });
} else if (payload && payload.alertState && !this.props.panel.alert) {
// when user deletes alert in panel editor the source panel needs to refresh as this is in the mutable state and
// will not automatically re render
this.setState({ alertState: undefined });
} else if (payload && alertState) {
this.setState({ alertState: undefined });
} else {
// only needed for detecting title updates right now fix before 7.0
this.forceUpdate();
}
};
onPanelDataUpdate(data: PanelData) {
let errorMessage: string | undefined;
@ -213,9 +190,11 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
render() {
const { dashboard, panel, isViewing, isEditing, plugin } = this.props;
const { errorMessage, data, alertState } = this.state;
const { errorMessage, data } = this.state;
const { transparent } = panel;
let alertState = data.alertState?.state;
const containerClassNames = classNames({
'panel-container': true,
'panel-container--absolute': true,

View File

@ -173,7 +173,6 @@ export class PanelModel implements DataConfigSource {
cachedPluginOptions: Record<string, PanelOptionsCache> = {};
legend?: { show: boolean; sort?: string; sortDesc?: boolean };
plugin?: PanelPlugin;
dataSupport?: PanelPluginDataSupport;
/**
* The PanelModel event bus only used for internal and legacy angular support.
@ -355,7 +354,6 @@ export class PanelModel implements DataConfigSource {
}
}
this.dataSupport = plugin.dataSupport;
this.applyPluginOptionDefaults(plugin, false);
this.resendLastResult();
}
@ -484,6 +482,10 @@ export class PanelModel implements DataConfigSource {
};
}
getDataSupport(): PanelPluginDataSupport {
return this.plugin?.dataSupport ?? { annotations: false, alertStates: false };
}
getQueryRunner(): PanelQueryRunner {
if (!this.queryRunner) {
this.queryRunner = new PanelQueryRunner(this);

View File

@ -58,6 +58,21 @@ export function translateQueryResult(annotation: AnnotationQuery, results: Annot
item.color = annotation.iconColor;
item.type = annotation.name;
item.isRegion = Boolean(item.timeEnd && item.time !== item.timeEnd);
switch (item.newState) {
case 'pending':
item.color = 'gray';
break;
case 'alerting':
item.color = 'red';
break;
case 'ok':
item.color = 'green';
break;
case 'no_data':
item.color = 'gray';
break;
}
}
return results;

View File

@ -67,6 +67,7 @@ function describeQueryRunnerScenario(
const defaultPanelConfig: grafanaData.DataConfigSource = {
getFieldOverrideOptions: () => undefined,
getTransformations: () => undefined,
getDataSupport: () => ({ annotations: false, alertStates: false }),
};
const ctx: ScenarioContext = {
maxDataPoints: 200,
@ -251,6 +252,7 @@ describe('PanelQueryRunner', () => {
theme: grafanaData.createTheme(),
}),
getTransformations: () => undefined,
getDataSupport: () => ({ annotations: false, alertStates: false }),
}
);
@ -274,6 +276,7 @@ describe('PanelQueryRunner', () => {
getFieldOverrideOptions: () => undefined,
// @ts-ignore
getTransformations: () => [({} as unknown) as grafanaData.DataTransformerConfig],
getDataSupport: () => ({ annotations: false, alertStates: false }),
}
);
@ -316,6 +319,7 @@ describe('PanelQueryRunner', () => {
}),
// @ts-ignore
getTransformations: () => [({} as unknown) as grafanaData.DataTransformerConfig],
getDataSupport: () => ({ annotations: false, alertStates: false }),
}
);

View File

@ -24,7 +24,6 @@ import {
DataTransformerConfig,
LoadingState,
PanelData,
PanelPluginDataSupport,
rangeUtil,
ScopedVars,
TimeRange,
@ -67,12 +66,10 @@ export class PanelQueryRunner {
private subscription?: Unsubscribable;
private lastResult?: PanelData;
private dataConfigSource: DataConfigSource;
private dataSupport?: PanelPluginDataSupport;
constructor(dataConfigSource: DataConfigSource) {
this.subject = new ReplaySubject(1);
this.dataConfigSource = dataConfigSource;
this.dataSupport = this.dataConfigSource.dataSupport;
}
/**
@ -249,7 +246,9 @@ export class PanelQueryRunner {
}
let panelData = observable;
if (this.dataSupport?.alertStates || this.dataSupport?.annotations) {
const dataSupport = this.dataConfigSource.getDataSupport();
if (dataSupport.alertStates || dataSupport.annotations) {
panelData = mergePanelAndDashData(observable, getDashboardQueryRunner().getResult(panelId));
}

View File

@ -109,6 +109,7 @@ export function getDefaultState(): State {
const dataConfig = {
getTransformations: () => [] as DataTransformerConfig[],
getFieldOverrideOptions: () => options,
getDataSupport: () => ({ annotations: false, alertStates: false }),
};
return {

View File

@ -26,4 +26,4 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(TimeSeriesPanel
addLegendOptions(builder);
})
.setDataSupport({ annotations: true });
.setDataSupport({ annotations: true, alertStates: true });

View File

@ -1,16 +1,17 @@
import React, { useCallback, useRef, useState } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { HorizontalGroup, Portal, Tag, VizTooltipContainer, useStyles } from '@grafana/ui';
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { GrafanaThemeV2, 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 AnnotationMarkerProps {
time: string;
text: string;
tags: string[];
interface Props {
timeZone: TimeZone;
annotation: AnnotationsDataFrameViewDTO;
}
export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ time, text, tags }) => {
const styles = useStyles(getAnnotationMarkerStyles);
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);
@ -29,6 +30,25 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ time, text,
}, 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;
@ -36,6 +56,24 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ time, text,
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
@ -47,11 +85,12 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ time, text,
>
<div ref={annotationPopoverRef} className={styles.wrapper}>
<div className={styles.header}>
{/*<span className={styles.title}>{annotationEvent.title}</span>*/}
{state}
{time && <span className={styles.time}>{time}</span>}
</div>
<div className={styles.body}>
{text && <div dangerouslySetInnerHTML={{ __html: text }} />}
{text && <div dangerouslySetInnerHTML={{ __html: textUtil.sanitize(text) }} />}
{alertText}
<>
<HorizontalGroup spacing="xs" wrap>
{tags?.map((t, i) => (
@ -63,62 +102,40 @@ export const AnnotationMarker: React.FC<AnnotationMarkerProps> = ({ time, text,
</div>
</VizTooltipContainer>
);
}, [onMouseEnter, onMouseLeave, styles, time, text, tags]);
}, [onMouseEnter, onMouseLeave, timeFormatter, styles, annotation]);
return (
<>
<div ref={markerRef} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={styles.markerWrapper}>
<div className={styles.marker} />
<div style={markerStyles} />
</div>
{isOpen && <Portal>{renderMarker()}</Portal>}
</>
);
};
const getAnnotationMarkerStyles = (theme: GrafanaTheme) => {
const bg = theme.isDark ? theme.palette.dark2 : theme.palette.white;
const headerBg = theme.isDark ? theme.palette.dark9 : theme.palette.gray5;
const shadowColor = theme.isDark ? theme.palette.black : theme.palette.white;
}
const getAnnotationMarkerStyles = (theme: GrafanaThemeV2) => {
return {
markerWrapper: css`
padding: 0 4px 4px 4px;
`,
marker: css`
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid ${theme.palette.red};
pointer-events: none;
`,
wrapper: css`
background: ${bg};
border: 1px solid ${headerBg};
border-radius: ${theme.border.radius.md};
max-width: 400px;
box-shadow: 0 0 20px ${shadowColor};
`,
tooltip: css`
background: none;
padding: 0;
`,
header: css`
background: ${headerBg};
padding: 6px 10px;
padding: ${theme.spacing(0.5, 1)};
font-size: ${theme.typography.bodySmall.fontSize};
display: flex;
`,
title: css`
font-weight: ${theme.typography.weight.semibold};
padding-right: ${theme.spacing.md};
overflow: hidden;
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 1;
alertState: css`
padding-right: ${theme.spacing(1)};
font-weight: ${theme.typography.fontWeightMedium};
`,
time: css`
color: ${theme.colors.textWeak};
color: ${theme.colors.text.secondary};
font-style: italic;
font-weight: normal;
display: inline-block;
@ -126,8 +143,7 @@ const getAnnotationMarkerStyles = (theme: GrafanaTheme) => {
top: 1px;
`,
body: css`
padding: ${theme.spacing.sm};
font-weight: ${theme.typography.weight.semibold};
padding: ${theme.spacing(1)};
`,
};
};

View File

@ -1,4 +1,4 @@
import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
import { DataFrame, DataFrameView, getColorForTheme, TimeZone } from '@grafana/data';
import { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui';
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { AnnotationMarker } from './AnnotationMarker';
@ -9,28 +9,12 @@ interface AnnotationsPluginProps {
timeZone: TimeZone;
}
interface AnnotationsDataFrameViewDTO {
time: number;
text: string;
tags: string[];
}
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone, config }) => {
const theme = useTheme();
const plotCtx = usePlotContext();
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
const timeFormatter = useCallback(
(value: number) => {
return dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone,
});
},
[timeZone]
);
// Update annotations views when new annotations came
useEffect(() => {
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
@ -68,7 +52,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
const xpos = u.valToPos(annotation.time, 'x', true);
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = theme.palette.red;
ctx.strokeStyle = getColorForTheme(annotation.color, theme);
ctx.setLineDash([5, 5]);
ctx.moveTo(xpos, u.bbox.top);
ctx.lineTo(xpos, u.bbox.top + u.bbox.height);
@ -101,9 +85,9 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
(frame: DataFrame, index: number) => {
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(index);
return <AnnotationMarker time={timeFormatter(annotation.time)} text={annotation.text} tags={annotation.tags} />;
return <AnnotationMarker annotation={annotation} timeZone={timeZone} />;
},
[timeFormatter]
[timeZone]
);
return (

View File

@ -0,0 +1,9 @@
interface AnnotationsDataFrameViewDTO {
time: number;
text: string;
tags: string[];
alertId?: number;
newState?: string;
title?: string;
color: string;
}

View File

@ -273,8 +273,9 @@
.graph-annotation__header {
background: $popover-header-bg;
padding: 6px 10px;
padding: 4px 8px;
display: flex;
flex-wrap: nowrap;
}
.graph-annotation__title {