From 1ce2ae427fdf11159d37a93be6e09b13735df2e7 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 15 Mar 2024 17:37:11 +0100 Subject: [PATCH] Alerting: Query and conditions improvements (#83426) --- .betterer.results | 13 +- .../unified/GrafanaRuleQueryViewer.test.tsx | 15 +- .../unified/GrafanaRuleQueryViewer.tsx | 223 +++++++++++------- .../rule-viewer/RuleViewerVisualization.tsx | 131 +--------- .../components/rule-viewer/tabs/Query.tsx | 48 +--- .../tabs/Query/DataSourceModelPreview.tsx | 40 ++++ .../tabs/Query/LokiQueryPreview.tsx | 14 ++ .../tabs/Query/PrometheusQueryPreview.tsx | 14 ++ .../tabs/Query/SQLQueryPreview.tsx | 39 +++ .../unified/components/rules/RuleDetails.tsx | 2 +- .../rules/RuleDetailsActionButtons.tsx | 91 +------ .../features/alerting/unified/utils/query.ts | 43 +++- 12 files changed, 319 insertions(+), 354 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx diff --git a/.betterer.results b/.betterer.results index ff95c163d1e..fe2cc870b0c 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1480,11 +1480,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "11"], [0, 0, 0, "Styles should be written using objects.", "12"], [0, 0, 0, "Styles should be written using objects.", "13"], - [0, 0, 0, "Styles should be written using objects.", "14"], - [0, 0, 0, "Styles should be written using objects.", "15"], - [0, 0, 0, "Styles should be written using objects.", "16"], - [0, 0, 0, "Styles should be written using objects.", "17"], - [0, 0, 0, "Styles should be written using objects.", "18"] + [0, 0, 0, "Styles should be written using objects.", "14"] ], "public/app/features/alerting/unified/NotificationPolicies.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] @@ -2053,13 +2049,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"] - ], "public/app/features/alerting/unified/components/rules/ActionButton.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], diff --git a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx index 6539c35b677..b77aba3e2e3 100644 --- a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx +++ b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx @@ -1,14 +1,17 @@ -import { render } from '@testing-library/react'; -import { noop } from 'lodash'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; import { DataSourceRef } from '@grafana/schema'; import { AlertQuery } from 'app/types/unified-alerting-dto'; import { GrafanaRuleQueryViewer } from './GrafanaRuleQueryViewer'; +import { mockCombinedRule } from './mocks'; describe('GrafanaRuleQueryViewer', () => { - it('renders without crashing', () => { + it('renders without crashing', async () => { + const rule = mockCombinedRule(); + const getDataSourceQuery = (refId: string) => { const query: AlertQuery = { refId: refId, @@ -72,9 +75,11 @@ describe('GrafanaRuleQueryViewer', () => { getExpression('D', { type: '' }), ]; const { getByTestId } = render( - + , + { wrapper: TestProvider } ); - expect(getByTestId('queries-container')).toHaveStyle('flex-wrap: wrap'); + + await waitFor(() => expect(getByTestId('queries-container')).toHaveStyle('flex-wrap: wrap')); expect(getByTestId('expressions-container')).toHaveStyle('flex-wrap: wrap'); }); }); diff --git a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx index ff0a2c2fc9c..83cf88bfe37 100644 --- a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx +++ b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx @@ -1,15 +1,15 @@ import { css, cx } from '@emotion/css'; -import { dump } from 'js-yaml'; import { keyBy, startCase } from 'lodash'; import React from 'react'; -import { DataSourceInstanceSettings, GrafanaTheme2, PanelData, RelativeTimeRange } from '@grafana/data'; +import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2, PanelData, urlUtil } from '@grafana/data'; +import { secondsToHms } from '@grafana/data/src/datetime/rangeutil'; import { config } from '@grafana/runtime'; import { Preview } from '@grafana/sql/src/components/visual-query-builder/Preview'; -import { Badge, Stack, useStyles2 } from '@grafana/ui'; -import { mapRelativeTimeRangeToOption } from '@grafana/ui/src/components/DateTimePickers/RelativeTimeRangePicker/utils'; +import { Badge, ErrorBoundaryAlert, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; +import { CombinedRule } from 'app/types/unified-alerting'; -import { AlertQuery } from '../../../types/unified-alerting-dto'; +import { AlertDataQuery, AlertQuery } from '../../../types/unified-alerting-dto'; import { isExpressionQuery } from '../../expressions/guards'; import { downsamplingTypes, @@ -23,25 +23,22 @@ import { } from '../../expressions/types'; import alertDef, { EvalFunction } from '../state/alertDef'; +import { Spacer } from './components/Spacer'; +import { WithReturnButton } from './components/WithReturnButton'; import { ExpressionResult } from './components/expressions/Expression'; import { getThresholdsForQueries, ThresholdDefinition } from './components/rule-editor/util'; import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization'; +import { DatasourceModelPreview } from './components/rule-viewer/tabs/Query/DataSourceModelPreview'; +import { AlertRuleAction, useAlertRuleAbility } from './hooks/useAbilities'; interface GrafanaRuleViewerProps { + rule: CombinedRule; queries: AlertQuery[]; condition: string; evalDataByQuery?: Record; - evalTimeRanges?: Record; - onTimeRangeChange: (queryRef: string, timeRange: RelativeTimeRange) => void; } -export function GrafanaRuleQueryViewer({ - queries, - condition, - evalDataByQuery = {}, - evalTimeRanges = {}, - onTimeRangeChange, -}: GrafanaRuleViewerProps) { +export function GrafanaRuleQueryViewer({ rule, queries, condition, evalDataByQuery = {} }: GrafanaRuleViewerProps) { const dsByUid = keyBy(Object.values(config.datasources), (ds) => ds.uid); const dataQueries = queries.filter((q) => !isExpressionQuery(q.model)); const expressions = queries.filter((q) => isExpressionQuery(q.model)); @@ -50,24 +47,23 @@ export function GrafanaRuleQueryViewer({ const thresholds = getThresholdsForQueries(queries, condition); return ( - +
- + {dataQueries.map(({ model, relativeTimeRange, refId, datasourceUid }, index) => { const dataSource = dsByUid[datasourceUid]; return ( onTimeRangeChange(refId, timeRange)} /> ); })} @@ -98,56 +94,100 @@ export function GrafanaRuleQueryViewer({ } interface QueryPreviewProps extends Pick { + rule: CombinedRule; isAlertCondition: boolean; dataSource?: DataSourceInstanceSettings; queryData?: PanelData; thresholds?: ThresholdDefinition; - evalTimeRange?: RelativeTimeRange; - onEvalTimeRangeChange: (timeRange: RelativeTimeRange) => void; } export function QueryPreview({ refId, - relativeTimeRange, + rule, thresholds, model, dataSource, queryData, - evalTimeRange, - onEvalTimeRangeChange, + relativeTimeRange, }: QueryPreviewProps) { const styles = useStyles2(getQueryPreviewStyles); + const isExpression = isExpressionQuery(model); + const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore); + const canExplore = exploreSupported && exploreAllowed; + + const headerItems: React.ReactNode[] = []; + + if (dataSource) { + const dataSourceName = dataSource.name ?? '[[Data source not found]]'; + const dataSourceImgUrl = dataSource.meta.info.logos.small; + + headerItems.push(); + } - // relativeTimeRange is what is defined for a query - // evalTimeRange is temporary value which the user can change - const headerItems = [dataSource?.name ?? '[[Data source not found]]']; if (relativeTimeRange) { - headerItems.push(mapRelativeTimeRangeToOption(relativeTimeRange).display); + headerItems.push( + + {secondsToHms(relativeTimeRange.from)} to now + + ); + } + + let exploreLink: string | undefined = undefined; + if (!isExpression && canExplore) { + exploreLink = dataSource && createExploreLink(dataSource, model); } return ( - -
-        {dump(model)}
-      
- {dataSource && ( - - )} -
+ <> + +
+ + {model && dataSource && } + +
+
+ {dataSource && } + + ); +} + +function createExploreLink(settings: DataSourceRef, model: AlertDataQuery): string { + const { uid, type } = settings; + const { refId, ...rest } = model; + + /* + In my testing I've found some alerts that don't have a data source embedded inside the model. + At this moment in time it is unclear to me why some alert definitions not have a data source embedded in the model. + + I don't think that should happen here, the fact that the datasource ref is sometimes missing here is a symptom of another cause. (Gilles) + */ + return urlUtil.renderUrl(`${config.appSubUrl}/explore`, { + left: JSON.stringify({ + datasource: settings.uid, + queries: [{ refId: 'A', ...rest, datasource: { type, uid } }], + range: { from: 'now-1h', to: 'now' }, + }), + }); +} + +interface DataSourceBadgeProps { + name: string; + imgUrl: string; +} + +function DataSourceBadge({ name, imgUrl }: DataSourceBadgeProps) { + const styles = useStyles2(getQueryPreviewStyles); + + return ( +
+ {name} + {name} +
); } const getQueryPreviewStyles = (theme: GrafanaTheme2) => ({ - code: css` + queryPreviewWrapper: css` margin: ${theme.spacing(1)}; `, contentBox: css` @@ -156,6 +196,14 @@ const getQueryPreviewStyles = (theme: GrafanaTheme2) => ({ visualization: css` padding: ${theme.spacing(1)}; `, + dataSource: css({ + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + padding: theme.spacing(0.5, 1), + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), }); interface ExpressionPreviewProps extends Pick { @@ -192,8 +240,17 @@ function ExpressionPreview({ refId, model, evalData, isAlertCondition }: Express } return ( - + + {startCase(model.type)} + , + ]} + isAlertCondition={isAlertCondition} + > {renderPreview()} + {evalData && } ); @@ -201,27 +258,29 @@ function ExpressionPreview({ refId, model, evalData, isAlertCondition }: Express interface QueryBoxProps extends React.PropsWithChildren { refId: string; - headerItems?: string[]; + headerItems?: React.ReactNode; isAlertCondition?: boolean; - className?: string; + exploreLink?: string; } -function QueryBox({ refId, headerItems = [], children, isAlertCondition, className }: QueryBoxProps) { +function QueryBox({ refId, headerItems = [], children, isAlertCondition, exploreLink }: QueryBoxProps) { const styles = useStyles2(getQueryBoxStyles); return ( -
+
{refId} - {headerItems.map((item, index) => ( - - {item} - - ))} - {isAlertCondition && ( -
- -
+ {headerItems} + + {isAlertCondition && } + {exploreLink && ( + + View in Explore + + } + /> )}
{children} @@ -230,11 +289,14 @@ function QueryBox({ refId, headerItems = [], children, isAlertCondition, classNa } const getQueryBoxStyles = (theme: GrafanaTheme2) => ({ - container: css` - flex: 1 0 25%; - border: 1px solid ${theme.colors.border.strong}; - max-width: 100%; - `, + container: css({ + flex: '1 0 25%', + border: `1px solid ${theme.colors.border.weak}`, + maxWidth: '100%', + borderRadius: theme.shape.radius.default, + display: 'flex', + flexDirection: 'column', + }), header: css` display: flex; align-items: center; @@ -242,19 +304,18 @@ const getQueryBoxStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(1)}; background-color: ${theme.colors.background.secondary}; `, - textBlock: css` - border: 1px solid ${theme.colors.border.weak}; - padding: ${theme.spacing(0.5, 1)}; - background-color: ${theme.colors.background.primary}; - `, - refId: css` - color: ${theme.colors.text.link}; - padding: ${theme.spacing(0.5, 1)}; - border: 1px solid ${theme.colors.border.weak}; - `, - conditionIndicator: css` - margin-left: auto; - `, + textBlock: css({ + border: `1px solid ${theme.colors.border.weak}`, + padding: theme.spacing(0.5, 1), + backgroundColor: theme.colors.background.primary, + borderRadius: theme.shape.radius.default, + }), + refId: css({ + color: theme.colors.text.link, + padding: theme.spacing(0.5, 1), + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + }), }); function ClassicConditionViewer({ model }: { model: ExpressionQuery }) { @@ -325,7 +386,7 @@ const getReduceConditionViewerStyles = (theme: GrafanaTheme2) => ({ container: css` padding: ${theme.spacing(1)}; display: grid; - gap: ${theme.spacing(1)}; + gap: ${theme.spacing(0.5)}; grid-template-rows: 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr; @@ -364,7 +425,7 @@ const getResampleExpressionViewerStyles = (theme: GrafanaTheme2) => ({ container: css` padding: ${theme.spacing(1)}; display: grid; - gap: ${theme.spacing(1)}; + gap: ${theme.spacing(0.5)}; grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; `, @@ -433,7 +494,7 @@ const getExpressionViewerStyles = (theme: GrafanaTheme2) => { container: css` padding: ${theme.spacing(1)}; display: flex; - gap: ${theme.spacing(1)}; + gap: ${theme.spacing(0.5)}; `, blue: css` ${blue}; @@ -474,10 +535,12 @@ const getCommonQueryStyles = (theme: GrafanaTheme2) => ({ font-size: ${theme.typography.bodySmall.fontSize}; line-height: ${theme.typography.bodySmall.lineHeight}; font-weight: ${theme.typography.fontWeightBold}; + border-radius: ${theme.shape.radius.default}; `, value: css` padding: ${theme.spacing(0.5, 1)}; border: 1px solid ${theme.colors.border.weak}; + border-radius: ${theme.shape.radius.default}; `, }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index aa9b8614177..7cba1c7386d 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -1,140 +1,19 @@ -import { css } from '@emotion/css'; -import React, { useCallback } from 'react'; +import React from 'react'; -import { - DataSourceInstanceSettings, - DataSourceJsonData, - DateTime, - dateTime, - GrafanaTheme2, - PanelData, - RelativeTimeRange, - urlUtil, -} from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { DataSourceRef } from '@grafana/schema'; -import { DateTimePicker, LinkButton, useStyles2 } from '@grafana/ui'; -import { contextSrv } from 'app/core/core'; -import { isExpressionQuery } from 'app/features/expressions/guards'; -import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; +import { PanelData } from '@grafana/data'; -import { WithReturnButton } from '../WithReturnButton'; import { VizWrapper } from '../rule-editor/VizWrapper'; import { ThresholdDefinition } from '../rule-editor/util'; -interface RuleViewerVisualizationProps extends Pick { - dsSettings: DataSourceInstanceSettings; +interface RuleViewerVisualizationProps { data?: PanelData; thresholds?: ThresholdDefinition; - onTimeRangeChange: (range: RelativeTimeRange) => void; - className?: string; } -const headerHeight = 4; - -export function RuleViewerVisualization({ - data, - model, - thresholds, - dsSettings, - relativeTimeRange, - onTimeRangeChange, - className, -}: RuleViewerVisualizationProps): JSX.Element | null { - const styles = useStyles2(getStyles); - const isExpression = isExpressionQuery(model); - - const onTimeChange = useCallback( - (newDateTime: DateTime) => { - const now = dateTime().unix() - newDateTime.unix(); - - if (relativeTimeRange) { - const interval = relativeTimeRange.from - relativeTimeRange.to; - onTimeRangeChange({ from: now + interval, to: now }); - } - }, - [onTimeRangeChange, relativeTimeRange] - ); - - const setDateTime = useCallback((relativeTimeRangeTo: number) => { - return relativeTimeRangeTo === 0 ? dateTime() : dateTime().subtract(relativeTimeRangeTo, 'seconds'); - }, []); - +export function RuleViewerVisualization({ data, thresholds }: RuleViewerVisualizationProps): JSX.Element | null { if (!data) { return null; } - const allowedToExploreDataSources = contextSrv.hasAccessToExplore(); - - return ( -
-
-
- {!isExpression && relativeTimeRange ? ( - - ) : null} - - {allowedToExploreDataSources && !isExpression && ( - - View in Explore - - } - /> - )} -
-
- -
- ); + return ; } - -function createExploreLink(settings: DataSourceRef, model: AlertDataQuery): string { - const { uid, type } = settings; - const { refId, ...rest } = model; - - /* - In my testing I've found some alerts that don't have a data source embedded inside the model. - At this moment in time it is unclear to me why some alert definitions not have a data source embedded in the model. - - I don't think that should happen here, the fact that the datasource ref is sometimes missing here is a symptom of another cause. (Gilles) - */ - return urlUtil.renderUrl(`${config.appSubUrl}/explore`, { - left: JSON.stringify({ - datasource: settings.uid, - queries: [{ refId: 'A', ...rest, datasource: { type, uid } }], - range: { from: 'now-1h', to: 'now' }, - }), - }); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - header: css` - height: ${theme.spacing(headerHeight)}; - display: flex; - align-items: center; - justify-content: flex-end; - white-space: nowrap; - margin-bottom: ${theme.spacing(2)}; - `, - refId: css` - font-weight: ${theme.typography.fontWeightMedium}; - color: ${theme.colors.text.link}; - overflow: hidden; - `, - dataSource: css` - margin-left: ${theme.spacing(1)}; - font-style: italic; - color: ${theme.colors.text.secondary}; - `, - actions: css` - display: flex; - align-items: center; - `, - errorMessage: css` - white-space: pre-wrap; - `, - }; -}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx index 6fa54b32874..8988d3a6d99 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx @@ -1,12 +1,10 @@ -import { produce } from 'immer'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { useObservable } from 'react-use'; -import { LoadingState, PanelData, RelativeTimeRange } from '@grafana/data'; +import { LoadingState, PanelData } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Alert } from '@grafana/ui'; +import { Alert, Stack } from '@grafana/ui'; import { CombinedRule } from 'app/types/unified-alerting'; -import { AlertQuery } from 'app/types/unified-alerting-dto'; import { GrafanaRuleQueryViewer, QueryPreview } from '../../../GrafanaRuleQueryViewer'; import { useAlertQueriesStatus } from '../../../hooks/useAlertQueriesStatus'; @@ -19,8 +17,6 @@ interface Props { } const QueryResults = ({ rule }: Props) => { - const [evaluationTimeRanges, setEvaluationTimeRanges] = useState>({}); - const runner = useMemo(() => new AlertingQueryRunner(), []); const data = useObservable(runner.get()); const loadingData = isLoading(data); @@ -31,27 +27,13 @@ const QueryResults = ({ rule }: Props) => { const onRunQueries = useCallback(() => { if (queries.length > 0 && allDataSourcesAvailable) { - const evalCustomizedQueries = queries.map((q) => ({ - ...q, - relativeTimeRange: evaluationTimeRanges[q.refId] ?? q.relativeTimeRange, - })); - let condition; if (rule && isGrafanaRulerRule(rule.rulerRule)) { condition = rule.rulerRule.grafana_alert.condition; } - runner.run(evalCustomizedQueries, condition ?? 'A'); + runner.run(queries, condition ?? 'A'); } - }, [queries, allDataSourcesAvailable, rule, runner, evaluationTimeRanges]); - - useEffect(() => { - const alertQueries = alertRuleToQueries(rule); - const defaultEvalTimeRanges = Object.fromEntries( - alertQueries.map((q) => [q.refId, q.relativeTimeRange ?? { from: 0, to: 0 }]) - ); - - setEvaluationTimeRanges(defaultEvalTimeRanges); - }, [rule]); + }, [queries, allDataSourcesAvailable, rule, runner]); useEffect(() => { if (allDataSourcesAvailable) { @@ -63,16 +45,6 @@ const QueryResults = ({ rule }: Props) => { return () => runner.destroy(); }, [runner]); - const onQueryTimeRangeChange = useCallback( - (refId: string, timeRange: RelativeTimeRange) => { - const newEvalTimeRanges = produce(evaluationTimeRanges, (draft) => { - draft[refId] = timeRange; - }); - setEvaluationTimeRanges(newEvalTimeRanges); - }, - [evaluationTimeRanges, setEvaluationTimeRanges] - ); - const isFederatedRule = isFederatedRuleGroup(rule.group); return ( @@ -83,32 +55,30 @@ const QueryResults = ({ rule }: Props) => { <> {isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && ( )} {!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && ( -
+ {queries.map((query) => { return ( ds.uid === query.datasourceUid)} queryData={data[query.refId]} relativeTimeRange={query.relativeTimeRange} - evalTimeRange={evaluationTimeRanges[query.refId]} - onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)} isAlertCondition={false} /> ); })} -
+ )} {!isFederatedRule && !allDataSourcesAvailable && ( diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx new file mode 100644 index 00000000000..c0349bb88dc --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx @@ -0,0 +1,40 @@ +import { dump } from 'js-yaml'; +import React from 'react'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { AlertDataQuery } from 'app/types/unified-alerting-dto'; + +import { DataSourceType } from '../../../../utils/datasource'; +import { isPromOrLokiQuery } from '../../../../utils/rule-form'; + +import { isSQLLikeQuery, SQLQueryPreview } from './SQLQueryPreview'; + +const PrometheusQueryPreview = React.lazy(() => import('./PrometheusQueryPreview')); +const LokiQueryPreview = React.lazy(() => import('./LokiQueryPreview')); + +interface DatasourceModelPreviewProps { + model: AlertDataQuery; + dataSource: DataSourceInstanceSettings; +} + +function DatasourceModelPreview({ model, dataSource: datasource }: DatasourceModelPreviewProps): React.ReactNode { + if (datasource.type === DataSourceType.Prometheus && isPromOrLokiQuery(model)) { + return ; + } + + if (datasource.type === DataSourceType.Loki && isPromOrLokiQuery(model)) { + return ; + } + + if (isSQLLikeQuery(model)) { + return ; + } + + return ( +
+      {dump(model)}
+    
+ ); +} + +export { DatasourceModelPreview }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx new file mode 100644 index 00000000000..7bffee7b3d1 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { RawQuery } from '@grafana/experimental'; +import lokiGrammar from 'app/plugins/datasource/loki/syntax'; + +interface Props { + query: string; +} + +const LokiQueryPreview = ({ query }: Props) => { + return ; +}; + +export default LokiQueryPreview; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx new file mode 100644 index 00000000000..2d9323ef564 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { RawQuery } from '@grafana/experimental'; +import { promqlGrammar } from '@grafana/prometheus'; + +interface Props { + query: string; +} + +const PrometheusQueryPreview = ({ query }: Props) => { + return ; +}; + +export default PrometheusQueryPreview; diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx new file mode 100644 index 00000000000..d491690980e --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { ReactMonacoEditor } from '@grafana/ui'; +import { AlertDataQuery } from 'app/types/unified-alerting-dto'; + +interface Props { + expression: string; +} + +export const SQLQueryPreview = ({ expression }: Props) => ( + +); + +export interface SQLLike { + refId: string; + rawSql: string; +} + +export function isSQLLikeQuery(model: AlertDataQuery): model is SQLLike { + return 'rawSql' in model; +} + +export default SQLQueryPreview; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index cf6800f43d0..3e42e25f146 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -37,7 +37,7 @@ export const RuleDetails = ({ rule }: Props) => { return (
- +
{} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 0051f562af8..a9a6014380f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -1,22 +1,10 @@ import { css } from '@emotion/css'; import { uniqueId } from 'lodash'; import React, { Fragment, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { GrafanaTheme2, textUtil, urlUtil } from '@grafana/data'; +import { GrafanaTheme2, textUtil } from '@grafana/data'; import { config, useReturnToPrevious } from '@grafana/runtime'; -import { - Button, - ClipboardButton, - ConfirmModal, - Dropdown, - HorizontalGroup, - Icon, - LinkButton, - Menu, - useStyles2, -} from '@grafana/ui'; -import { useAppNotification } from 'app/core/copy/appNotification'; +import { Button, ConfirmModal, Dropdown, HorizontalGroup, Icon, LinkButton, Menu, useStyles2 } from '@grafana/ui'; import { useDispatch } from 'app/types'; import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; @@ -36,7 +24,6 @@ import { } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; -import { createUrl } from '../../utils/url'; import { DeclareIncidentButton } from '../bridges/DeclareIncidentButton'; import { RedirectToCloneRule } from './CloneRule'; @@ -44,16 +31,13 @@ import { RedirectToCloneRule } from './CloneRule'; interface Props { rule: CombinedRule; rulesSource: RulesSource; - isViewMode: boolean; } -export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Props) => { +export const RuleDetailsActionButtons = ({ rule, rulesSource }: Props) => { const style = useStyles2(getStyles); - const { namespace, group, rulerRule } = rule; + const { group } = rule; const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(); const dispatch = useDispatch(); - const location = useLocation(); - const notifyApp = useAppNotification(); const setReturnToPrevious = useReturnToPrevious(); @@ -66,11 +50,8 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop ? rulesSource : getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name; - const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence); const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore); - const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); - const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); const buttons: JSX.Element[] = []; const rightButtons: JSX.Element[] = []; @@ -85,24 +66,19 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop ruleToDelete.rulerRule ); - dispatch(deleteRuleAction(identifier, { navigateTo: isViewMode ? '/alerting/list' : undefined })); + dispatch(deleteRuleAction(identifier, { navigateTo: undefined })); setRuleToDelete(undefined); } }; const isFederated = isFederatedRuleGroup(group); - const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isFiringRule = isAlertingRule(rule.promRule) && rule.promRule.state === PromAlertingRuleState.Firing; - const canDelete = deleteSupported && deleteAllowed; - const canEdit = editSupported && editAllowed; const canSilence = silenceSupported && silenceAllowed && alertmanagerSourceName; - const canDuplicateRule = duplicateSupported && duplicateAllowed && !isFederated; const buildShareUrl = () => createShareLink(rulesSource, rule); - const returnTo = location.pathname + location.search; // explore does not support grafana rule queries atm // neither do "federated rules" if (isCloudRulesSource(rulesSource) && exploreSupported && exploreAllowed && !isFederated) { @@ -210,63 +186,6 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop ); } - if (isViewMode && rulerRule) { - const sourceName = getRulesSourceName(rulesSource); - const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); - - if (canEdit) { - rightButtons.push( - { - notifyApp.error('Error while copying URL', copiedText); - }} - size="sm" - getText={buildShareUrl} - > - Copy link to rule - - ); - - if (!isProvisioned) { - const editURL = urlUtil.renderUrl( - `${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, - { - returnTo, - } - ); - - rightButtons.push( - - Edit - - ); - } - } - - if (isGrafanaRulerRule(rulerRule)) { - const modifyUrl = createUrl( - `/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export` - ); - - moreActionsButtons.push(); - } - - if (canDuplicateRule) { - moreActionsButtons.push( - setRedirectToClone({ identifier, isProvisioned })} /> - ); - } - - if (canDelete) { - moreActionsButtons.push(); - moreActionsButtons.push( - setRuleToDelete(rule)} /> - ); - } - } - if (buttons.length || rightButtons.length || moreActionsButtons.length) { return ( <> diff --git a/public/app/features/alerting/unified/utils/query.ts b/public/app/features/alerting/unified/utils/query.ts index 0f5648638a6..bde53876d92 100644 --- a/public/app/features/alerting/unified/utils/query.ts +++ b/public/app/features/alerting/unified/utils/query.ts @@ -1,3 +1,5 @@ +import { produce } from 'immer'; + import { DataSourceInstanceSettings } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; @@ -5,8 +7,9 @@ import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { CombinedRule } from 'app/types/unified-alerting'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -import { isCloudRulesSource, isGrafanaRulesSource } from './datasource'; +import { isCloudRulesSource } from './datasource'; import { isGrafanaRulerRule } from './rules'; +import { safeParseDurationstr } from './time'; export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null): AlertQuery[] { if (!combinedRule) { @@ -15,10 +18,9 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null const { namespace, rulerRule } = combinedRule; const { rulesSource } = namespace; - if (isGrafanaRulesSource(rulesSource)) { - if (isGrafanaRulerRule(rulerRule)) { - return rulerRule.grafana_alert.data; - } + if (isGrafanaRulerRule(rulerRule)) { + const query = rulerRule.grafana_alert.data; + return widenRelativeTimeRanges(query, rulerRule.for, combinedRule.group.interval); } if (isCloudRulesSource(rulesSource)) { @@ -30,6 +32,37 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null return []; } +/** + * This function will figure out how large the time range for visualizing the alert rule detail view should be + * We try to show as much data as is relevant for triaging / root cause analysis + * + * The function for it is; + * + * Math.max(3 * pending period, query range + (2 * pending period)) + * + * We can safely ignore the evaluation interval because the pending period is guaranteed to be largen than or equal that + */ +export function widenRelativeTimeRanges(queries: AlertQuery[], pendingPeriod: string, groupInterval?: string) { + // if pending period is zero that means inherit from group interval, if that is empty then assume 1m + const pendingPeriodDurationMillis = + safeParseDurationstr(pendingPeriod) ?? safeParseDurationstr(groupInterval ?? '1m'); + const pendingPeriodDuration = Math.floor(pendingPeriodDurationMillis / 1000); + + return queries.map((query) => + produce(query, (draft) => { + const fromQueryRange = draft.relativeTimeRange?.from ?? 0; + + // use whichever has the largest time range + const from = Math.max(pendingPeriodDuration * 3, fromQueryRange + pendingPeriodDuration * 2); + + draft.relativeTimeRange = { + from, + to: 0, + }; + }) + ); +} + export function dataQueryToAlertQuery(dataQuery: DataQuery, dataSourceUid: string): AlertQuery { return { refId: dataQuery.refId,