Alerting: Query and conditions improvements (#83426)

pull/84611/head
Gilles De Mey 1 year ago committed by GitHub
parent f273681956
commit 1ce2ae427f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      .betterer.results
  2. 15
      public/app/features/alerting/unified/GrafanaRuleQueryViewer.test.tsx
  3. 223
      public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx
  4. 131
      public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx
  5. 48
      public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx
  6. 40
      public/app/features/alerting/unified/components/rule-viewer/tabs/Query/DataSourceModelPreview.tsx
  7. 14
      public/app/features/alerting/unified/components/rule-viewer/tabs/Query/LokiQueryPreview.tsx
  8. 14
      public/app/features/alerting/unified/components/rule-viewer/tabs/Query/PrometheusQueryPreview.tsx
  9. 39
      public/app/features/alerting/unified/components/rule-viewer/tabs/Query/SQLQueryPreview.tsx
  10. 2
      public/app/features/alerting/unified/components/rules/RuleDetails.tsx
  11. 91
      public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
  12. 43
      public/app/features/alerting/unified/utils/query.ts

@ -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"]
],

@ -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(
<GrafanaRuleQueryViewer queries={[...queries, ...expressions]} condition="A" onTimeRangeChange={noop} />
<GrafanaRuleQueryViewer queries={[...queries, ...expressions]} condition="A" rule={rule} />,
{ 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');
});
});

@ -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<string, PanelData>;
evalTimeRanges?: Record<string, RelativeTimeRange>;
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 (
<Stack gap={2} direction="column">
<Stack gap={1} direction="column" flex={'1 1 320px'}>
<div className={styles.maxWidthContainer}>
<Stack gap={2} wrap="wrap" data-testid="queries-container">
<Stack gap={1} wrap="wrap" data-testid="queries-container">
{dataQueries.map(({ model, relativeTimeRange, refId, datasourceUid }, index) => {
const dataSource = dsByUid[datasourceUid];
return (
<QueryPreview
rule={rule}
key={index}
refId={refId}
isAlertCondition={condition === refId}
model={model}
relativeTimeRange={relativeTimeRange}
evalTimeRange={evalTimeRanges[refId]}
dataSource={dataSource}
thresholds={thresholds[refId]}
queryData={evalDataByQuery[refId]}
onEvalTimeRangeChange={(timeRange) => onTimeRangeChange(refId, timeRange)}
/>
);
})}
@ -98,56 +94,100 @@ export function GrafanaRuleQueryViewer({
}
interface QueryPreviewProps extends Pick<AlertQuery, 'refId' | 'relativeTimeRange' | 'model'> {
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(<DataSourceBadge name={dataSourceName} imgUrl={dataSourceImgUrl} key="datasource" />);
}
// 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(
<Text color="secondary" key="timerange">
{secondsToHms(relativeTimeRange.from)} to now
</Text>
);
}
let exploreLink: string | undefined = undefined;
if (!isExpression && canExplore) {
exploreLink = dataSource && createExploreLink(dataSource, model);
}
return (
<QueryBox refId={refId} headerItems={headerItems} className={styles.contentBox}>
<pre className={styles.code}>
<code>{dump(model)}</code>
</pre>
{dataSource && (
<RuleViewerVisualization
refId={refId}
dsSettings={dataSource}
model={model}
data={queryData}
thresholds={thresholds}
relativeTimeRange={evalTimeRange}
onTimeRangeChange={onEvalTimeRangeChange}
className={styles.visualization}
/>
)}
</QueryBox>
<>
<QueryBox refId={refId} headerItems={headerItems} exploreLink={exploreLink}>
<div className={styles.queryPreviewWrapper}>
<ErrorBoundaryAlert>
{model && dataSource && <DatasourceModelPreview model={model} dataSource={dataSource} />}
</ErrorBoundaryAlert>
</div>
</QueryBox>
{dataSource && <RuleViewerVisualization data={queryData} thresholds={thresholds} />}
</>
);
}
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 (
<div className={styles.dataSource} key="datasource">
<img src={imgUrl} width={16} alt={name} />
{name}
</div>
);
}
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<AlertQuery, 'refId'> {
@ -192,8 +240,17 @@ function ExpressionPreview({ refId, model, evalData, isAlertCondition }: Express
}
return (
<QueryBox refId={refId} headerItems={[startCase(model.type)]} isAlertCondition={isAlertCondition}>
<QueryBox
refId={refId}
headerItems={[
<Text color="secondary" key="expression-type">
{startCase(model.type)}
</Text>,
]}
isAlertCondition={isAlertCondition}
>
{renderPreview()}
<Spacer />
{evalData && <ExpressionResult series={evalData.series} isAlertCondition={isAlertCondition} />}
</QueryBox>
);
@ -201,27 +258,29 @@ function ExpressionPreview({ refId, model, evalData, isAlertCondition }: Express
interface QueryBoxProps extends React.PropsWithChildren<unknown> {
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 (
<div className={cx(styles.container, className)}>
<div className={cx(styles.container)}>
<header className={styles.header}>
<span className={styles.refId}>{refId}</span>
{headerItems.map((item, index) => (
<span key={index} className={styles.textBlock}>
{item}
</span>
))}
{isAlertCondition && (
<div className={styles.conditionIndicator}>
<Badge color="green" icon="check" text="Alert condition" />
</div>
{headerItems}
<Spacer />
{isAlertCondition && <Badge color="green" icon="check" text="Alert condition" />}
{exploreLink && (
<WithReturnButton
component={
<LinkButton size="md" variant="secondary" icon="compass" href={exploreLink}>
View in Explore
</LinkButton>
}
/>
)}
</header>
{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};
`,
});

@ -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<AlertQuery, 'refId' | 'model' | 'relativeTimeRange'> {
dsSettings: DataSourceInstanceSettings<DataSourceJsonData>;
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 (
<div className={className}>
<div className={styles.header}>
<div className={styles.actions}>
{!isExpression && relativeTimeRange ? (
<DateTimePicker date={setDateTime(relativeTimeRange.to)} onChange={onTimeChange} maxDate={new Date()} />
) : null}
{allowedToExploreDataSources && !isExpression && (
<WithReturnButton
component={
<LinkButton size="md" variant="secondary" icon="compass" href={createExploreLink(dsSettings, model)}>
View in Explore
</LinkButton>
}
/>
)}
</div>
</div>
<VizWrapper data={data} thresholds={thresholds?.config} thresholdsType={thresholds?.mode} />
</div>
);
}
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' },
}),
});
return <VizWrapper data={data} thresholds={thresholds?.config} thresholdsType={thresholds?.mode} />;
}
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;
`,
};
};

@ -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<Record<string, RelativeTimeRange>>({});
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<AlertQuery>((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 && (
<GrafanaRuleQueryViewer
rule={rule}
condition={rule.rulerRule.grafana_alert.condition}
queries={queries}
evalDataByQuery={data}
evalTimeRanges={evaluationTimeRanges}
onTimeRangeChange={onQueryTimeRangeChange}
/>
)}
{!isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && data && Object.keys(data).length > 0 && (
<div>
<Stack direction="column" gap={1}>
{queries.map((query) => {
return (
<QueryPreview
key={query.refId}
rule={rule}
refId={query.refId}
model={query.model}
dataSource={Object.values(config.datasources).find((ds) => ds.uid === query.datasourceUid)}
queryData={data[query.refId]}
relativeTimeRange={query.relativeTimeRange}
evalTimeRange={evaluationTimeRanges[query.refId]}
onEvalTimeRangeChange={(timeRange) => onQueryTimeRangeChange(query.refId, timeRange)}
isAlertCondition={false}
/>
);
})}
</div>
</Stack>
)}
{!isFederatedRule && !allDataSourcesAvailable && (
<Alert title="Query not available" severity="warning">

@ -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 <PrometheusQueryPreview query={model.expr} />;
}
if (datasource.type === DataSourceType.Loki && isPromOrLokiQuery(model)) {
return <LokiQueryPreview query={model.expr ?? ''} />;
}
if (isSQLLikeQuery(model)) {
return <SQLQueryPreview expression={model.rawSql} />;
}
return (
<pre>
<code>{dump(model)}</code>
</pre>
);
}
export { DatasourceModelPreview };

@ -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 <RawQuery query={query} language={{ grammar: lokiGrammar, name: 'promql' }} />;
};
export default LokiQueryPreview;

@ -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 <RawQuery query={query} language={{ grammar: promqlGrammar, name: 'promql' }} />;
};
export default PrometheusQueryPreview;

@ -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) => (
<ReactMonacoEditor
options={{
readOnly: true,
minimap: {
enabled: false,
},
scrollBeyondLastColumn: 0,
scrollBeyondLastLine: false,
lineNumbers: 'off',
cursorWidth: 0,
overviewRulerLanes: 0,
}}
defaultLanguage="sql"
height={80}
defaultValue={expression}
width="100%"
/>
);
export interface SQLLike {
refId: string;
rawSql: string;
}
export function isSQLLikeQuery(model: AlertDataQuery): model is SQLLike {
return 'rawSql' in model;
}
export default SQLQueryPreview;

@ -37,7 +37,7 @@ export const RuleDetails = ({ rule }: Props) => {
return (
<div>
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={false} />
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
<div className={styles.wrapper}>
<div className={styles.leftSide}>
{<EvaluationBehaviorSummary rule={rule} />}

@ -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(
<ClipboardButton
key="copy"
icon="copy"
onClipboardError={(copiedText) => {
notifyApp.error('Error while copying URL', copiedText);
}}
size="sm"
getText={buildShareUrl}
>
Copy link to rule
</ClipboardButton>
);
if (!isProvisioned) {
const editURL = urlUtil.renderUrl(
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`,
{
returnTo,
}
);
rightButtons.push(
<LinkButton size="sm" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>
);
}
}
if (isGrafanaRulerRule(rulerRule)) {
const modifyUrl = createUrl(
`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`
);
moreActionsButtons.push(<Menu.Item label="Modify export" icon="edit" url={modifyUrl} />);
}
if (canDuplicateRule) {
moreActionsButtons.push(
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
);
}
if (canDelete) {
moreActionsButtons.push(<Menu.Divider />);
moreActionsButtons.push(
<Menu.Item key="delete" label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />
);
}
}
if (buttons.length || rightButtons.length || moreActionsButtons.length) {
return (
<>

@ -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,

Loading…
Cancel
Save