From f2e4f41f69c141badc4973074c8bc2cc57c11ec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 3 May 2021 08:52:05 +0200 Subject: [PATCH] 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 --- .../grafana-data/src/types/annotations.ts | 2 + packages/grafana-data/src/types/data.ts | 2 +- public/app/features/alerting/AlertTab.tsx | 35 ++++-- .../components/PanelEditor/state/selectors.ts | 2 +- .../dashboard/dashgrid/PanelChrome.tsx | 6 +- .../dashboard/dashgrid/PanelChromeAngular.tsx | 27 +---- .../features/dashboard/state/PanelModel.ts | 6 +- .../query/state/DashboardQueryRunner/utils.ts | 15 +++ .../query/state/PanelQueryRunner.test.ts | 4 + .../features/query/state/PanelQueryRunner.ts | 7 +- public/app/features/sandbox/TestStuffPage.tsx | 1 + .../app/plugins/panel/timeseries/module.tsx | 2 +- .../timeseries/plugins/AnnotationMarker.tsx | 106 ++++++++++-------- .../timeseries/plugins/AnnotationsPlugin.tsx | 24 +--- .../plugins/panel/timeseries/plugins/types.ts | 9 ++ public/sass/components/_panel_graph.scss | 3 +- 16 files changed, 141 insertions(+), 110 deletions(-) create mode 100644 public/app/plugins/panel/timeseries/plugins/types.ts diff --git a/packages/grafana-data/src/types/annotations.ts b/packages/grafana-data/src/types/annotations.ts index 369f4d36b13..b5397da44ee 100644 --- a/packages/grafana-data/src/types/annotations.ts +++ b/packages/grafana-data/src/types/annotations.ts @@ -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' diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index 6f8c555985f..ae88e3baef6 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -169,7 +169,7 @@ export enum NullValueMode { */ export interface DataConfigSource { configRev?: number; - dataSupport?: PanelPluginDataSupport; + getDataSupport: () => PanelPluginDataSupport; getTransformations: () => DataTransformerConfig[] | undefined; getFieldOverrideOptions: () => ApplyFieldOverrideOptions | undefined; } diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx index 9ecf2858ba3..0b7ae64b828 100644 --- a/public/app/features/alerting/AlertTab.tsx +++ b/public/app/features/alerting/AlertTab.tsx @@ -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 { 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 = ''; - const scopeProps = { ctrl: this.panelCtrl }; this.component = loader.load(this.element, scopeProps, template); @@ -110,6 +115,16 @@ class UnConnectedAlertTab extends PureComponent { } } + getReactAlertPanelCtrl() { + return { + panel: this.props.panel, + events: new EventBusSrv(), + render: () => { + this.props.panel.render(); + }, + } as any; + } + onAddAlert = () => { this.panelCtrl?._enableAlert(); this.component?.digest(); diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts index 9b47b64b88b..8c9f002694c 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts @@ -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', diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 9c4bf9324e0..984216ca447 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -223,7 +223,7 @@ export class PanelChrome extends Component { 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 { 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 { error={errorMessage} isEditing={isEditing} isViewing={isViewing} + alertState={alertState} data={data} /> diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx index b2ebd168efe..08bac0b99d9 100644 --- a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -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 { 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 { 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, diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index d54c33af9d9..ebdd84ab2b5 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -173,7 +173,6 @@ export class PanelModel implements DataConfigSource { cachedPluginOptions: Record = {}; 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); diff --git a/public/app/features/query/state/DashboardQueryRunner/utils.ts b/public/app/features/query/state/DashboardQueryRunner/utils.ts index bc350ae9383..0861bb8f383 100644 --- a/public/app/features/query/state/DashboardQueryRunner/utils.ts +++ b/public/app/features/query/state/DashboardQueryRunner/utils.ts @@ -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; diff --git a/public/app/features/query/state/PanelQueryRunner.test.ts b/public/app/features/query/state/PanelQueryRunner.test.ts index 9ddec31a06f..03b196fc517 100644 --- a/public/app/features/query/state/PanelQueryRunner.test.ts +++ b/public/app/features/query/state/PanelQueryRunner.test.ts @@ -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 }), } ); diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index 6534f5cd5a0..f22d287a6d6 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -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)); } diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index fd195646e40..f08add04f7c 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -109,6 +109,7 @@ export function getDefaultState(): State { const dataConfig = { getTransformations: () => [] as DataTransformerConfig[], getFieldOverrideOptions: () => options, + getDataSupport: () => ({ annotations: false, alertStates: false }), }; return { diff --git a/public/app/plugins/panel/timeseries/module.tsx b/public/app/plugins/panel/timeseries/module.tsx index 3e81bfc78bf..02b15d59b78 100644 --- a/public/app/plugins/panel/timeseries/module.tsx +++ b/public/app/plugins/panel/timeseries/module.tsx @@ -26,4 +26,4 @@ export const plugin = new PanelPlugin(TimeSeriesPanel addLegendOptions(builder); }) - .setDataSupport({ annotations: true }); + .setDataSupport({ annotations: true, alertStates: true }); diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx index 9805c0911fd..dc36c6f62b3 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationMarker.tsx @@ -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 = ({ 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(null); const annotationPopoverRef = useRef(null); @@ -29,6 +30,25 @@ export const AnnotationMarker: React.FC = ({ 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 = ({ 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 = ( +
+ {stateModel.text} +
+ ); + + alertText = alertDef.getAlertAnnotationInfo(annotation); + } else if (annotation.title) { + text = annotation.title + '
' + (typeof text === 'string' ? text : ''); + } return ( = ({ time, text, >
- {/*{annotationEvent.title}*/} + {state} {time && {time}}
- {text &&
} + {text &&
} + {alertText} <> {tags?.map((t, i) => ( @@ -63,62 +102,40 @@ export const AnnotationMarker: React.FC = ({ time, text,
); - }, [onMouseEnter, onMouseLeave, styles, time, text, tags]); + }, [onMouseEnter, onMouseLeave, timeFormatter, styles, annotation]); return ( <>
-
+
{isOpen && {renderMarker()}} ); -}; - -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)}; `, }; }; diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx index a8dbf918583..139fa78d9f7 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx @@ -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 = ({ annotations, timeZone, config }) => { const theme = useTheme(); const plotCtx = usePlotContext(); const annotationsRef = useRef>>(); - const timeFormatter = useCallback( - (value: number) => { - return dateTimeFormat(value, { - format: systemDateFormats.fullDate, - timeZone, - }); - }, - [timeZone] - ); - // Update annotations views when new annotations came useEffect(() => { const views: Array> = []; @@ -68,7 +52,7 @@ export const AnnotationsPlugin: React.FC = ({ 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 = ({ annotation (frame: DataFrame, index: number) => { const view = new DataFrameView(frame); const annotation = view.get(index); - return ; + return ; }, - [timeFormatter] + [timeZone] ); return ( diff --git a/public/app/plugins/panel/timeseries/plugins/types.ts b/public/app/plugins/panel/timeseries/plugins/types.ts new file mode 100644 index 00000000000..b8f6d120765 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/types.ts @@ -0,0 +1,9 @@ +interface AnnotationsDataFrameViewDTO { + time: number; + text: string; + tags: string[]; + alertId?: number; + newState?: string; + title?: string; + color: string; +} diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index 2051e84080d..2362c8364a9 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -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 {