mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 08:32:10 +08:00
Alerting: Query and conditions improvements (#83426)
This commit is contained in:
@ -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>
|
||||
);
|
||||
return <VizWrapper data={data} thresholds={thresholds?.config} thresholdsType={thresholds?.mode} />;
|
||||
}
|
||||
|
||||
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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user