Alerting: Access query details of provisioned alerts (#59626)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik 2022-12-15 13:33:41 +01:00 committed by GitHub
parent b7fc837c35
commit 384322dc14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 767 additions and 234 deletions

View File

@ -1,6 +1,7 @@
import { css, cx } from '@emotion/css';
import React, { FC, FormEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import Calendar from 'react-calendar';
import { usePopper } from 'react-popper';
import { useMedia } from 'react-use';
import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data';
@ -33,6 +34,13 @@ export const DateTimePicker: FC<Props> = ({ date, maxDate, label, onChange }) =>
const isFullscreen = useMedia(`(min-width: ${theme.breakpoints.values.lg}px)`);
const styles = useStyles2(getStyles);
const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>();
const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>();
const popper = usePopper(markerElement, selectorElement, {
placement: 'bottom-start',
});
const onApply = useCallback(
(date: DateTime) => {
setOpen(false);
@ -51,18 +59,29 @@ export const DateTimePicker: FC<Props> = ({ date, maxDate, label, onChange }) =>
return (
<div data-testid="date-time-picker" style={{ position: 'relative' }}>
<DateTimeInput date={date} onChange={onChange} isFullscreen={isFullscreen} onOpen={onOpen} label={label} />
<DateTimeInput
date={date}
onChange={onChange}
isFullscreen={isFullscreen}
onOpen={onOpen}
label={label}
ref={setMarkerElement}
/>
{isOpen ? (
isFullscreen ? (
<ClickOutsideWrapper onClick={() => setOpen(false)}>
<DateTimeCalendar
date={date}
onChange={onApply}
isFullscreen={true}
onClose={() => setOpen(false)}
maxDate={maxDate}
/>
</ClickOutsideWrapper>
<Portal>
<ClickOutsideWrapper onClick={() => setOpen(false)}>
<DateTimeCalendar
date={date}
onChange={onApply}
isFullscreen={true}
onClose={() => setOpen(false)}
maxDate={maxDate}
ref={setSelectorElement}
style={popper.styles.popper}
/>
</ClickOutsideWrapper>
</Portal>
) : (
<Portal>
<ClickOutsideWrapper onClick={() => setOpen(false)}>
@ -84,6 +103,7 @@ interface DateTimeCalendarProps {
onClose: () => void;
isFullscreen: boolean;
maxDate?: Date;
style?: React.CSSProperties;
}
interface InputProps {
@ -99,127 +119,141 @@ type InputState = {
invalid: boolean;
};
const DateTimeInput: FC<InputProps> = ({ date, label, onChange, isFullscreen, onOpen }) => {
const [internalDate, setInternalDate] = useState<InputState>(() => {
return { value: date ? dateTimeFormat(date) : dateTimeFormat(dateTime()), invalid: false };
});
useEffect(() => {
if (date) {
setInternalDate({
invalid: !isValid(dateTimeFormat(date)),
value: isDateTime(date) ? dateTimeFormat(date) : date,
});
}
}, [date]);
const onChangeDate = useCallback((event: FormEvent<HTMLInputElement>) => {
const isInvalid = !isValid(event.currentTarget.value);
setInternalDate({
value: event.currentTarget.value,
invalid: isInvalid,
const DateTimeInput = React.forwardRef<HTMLInputElement, InputProps>(
({ date, label, onChange, isFullscreen, onOpen }, ref) => {
const [internalDate, setInternalDate] = useState<InputState>(() => {
return { value: date ? dateTimeFormat(date) : dateTimeFormat(dateTime()), invalid: false };
});
}, []);
const onFocus = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!isFullscreen) {
return;
useEffect(() => {
if (date) {
setInternalDate({
invalid: !isValid(dateTimeFormat(date)),
value: isDateTime(date) ? dateTimeFormat(date) : date,
});
}
onOpen(event);
},
[isFullscreen, onOpen]
);
}, [date]);
const onBlur = useCallback(() => {
if (isDateTime(internalDate.value)) {
onChange(dateTime(internalDate.value));
}
}, [internalDate.value, onChange]);
const icon = <Button aria-label="Time picker" icon="calendar-alt" variant="secondary" onClick={onOpen} />;
return (
<InlineField
label={label}
onClick={stopPropagation}
invalid={!!(internalDate.value && internalDate.invalid)}
className={css`
margin-bottom: 0;
`}
>
<Input
onClick={stopPropagation}
onChange={onChangeDate}
addonAfter={icon}
value={internalDate.value}
onFocus={onFocus}
onBlur={onBlur}
data-testid="date-time-input"
placeholder="Select date/time"
/>
</InlineField>
);
};
const DateTimeCalendar: FC<DateTimeCalendarProps> = ({ date, onClose, onChange, isFullscreen, maxDate }) => {
const calendarStyles = useStyles2(getBodyStyles);
const styles = useStyles2(getStyles);
const [internalDate, setInternalDate] = useState<Date>(() => {
if (date && date.isValid()) {
return date.toDate();
}
return new Date();
});
const onChangeDate = useCallback((date: Date | Date[]) => {
if (!Array.isArray(date)) {
setInternalDate((prevState) => {
// If we don't use time from prevState
// the time will be reset to 00:00:00
date.setHours(prevState.getHours());
date.setMinutes(prevState.getMinutes());
date.setSeconds(prevState.getSeconds());
return date;
const onChangeDate = useCallback((event: FormEvent<HTMLInputElement>) => {
const isInvalid = !isValid(event.currentTarget.value);
setInternalDate({
value: event.currentTarget.value,
invalid: isInvalid,
});
}
}, []);
}, []);
const onChangeTime = useCallback((date: DateTime) => {
setInternalDate(date.toDate());
}, []);
const onFocus = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!isFullscreen) {
return;
}
onOpen(event);
},
[isFullscreen, onOpen]
);
return (
<div className={cx(styles.container, { [styles.fullScreen]: isFullscreen })} onClick={stopPropagation}>
<Calendar
next2Label={null}
prev2Label={null}
value={internalDate}
nextLabel={<Icon name="angle-right" />}
nextAriaLabel="Next month"
prevLabel={<Icon name="angle-left" />}
prevAriaLabel="Previous month"
onChange={onChangeDate}
locale="en"
className={calendarStyles.body}
tileClassName={calendarStyles.title}
maxDate={maxDate}
/>
<div className={styles.time}>
<TimeOfDayPicker showSeconds={true} onChange={onChangeTime} value={dateTime(internalDate)} />
const onBlur = useCallback(() => {
if (isDateTime(internalDate.value)) {
onChange(dateTime(internalDate.value));
}
}, [internalDate.value, onChange]);
const icon = <Button aria-label="Time picker" icon="calendar-alt" variant="secondary" onClick={onOpen} />;
return (
<InlineField
label={label}
onClick={stopPropagation}
invalid={!!(internalDate.value && internalDate.invalid)}
className={css`
margin-bottom: 0;
`}
>
<Input
onClick={stopPropagation}
onChange={onChangeDate}
addonAfter={icon}
value={internalDate.value}
onFocus={onFocus}
onBlur={onBlur}
data-testid="date-time-input"
placeholder="Select date/time"
ref={ref}
/>
</InlineField>
);
}
);
DateTimeInput.displayName = 'DateTimeInput';
const DateTimeCalendar = React.forwardRef<HTMLDivElement, DateTimeCalendarProps>(
({ date, onClose, onChange, isFullscreen, maxDate, style }, ref) => {
const calendarStyles = useStyles2(getBodyStyles);
const styles = useStyles2(getStyles);
const [internalDate, setInternalDate] = useState<Date>(() => {
if (date && date.isValid()) {
return date.toDate();
}
return new Date();
});
const onChangeDate = useCallback((date: Date | Date[]) => {
if (!Array.isArray(date)) {
setInternalDate((prevState) => {
// If we don't use time from prevState
// the time will be reset to 00:00:00
date.setHours(prevState.getHours());
date.setMinutes(prevState.getMinutes());
date.setSeconds(prevState.getSeconds());
return date;
});
}
}, []);
const onChangeTime = useCallback((date: DateTime) => {
setInternalDate(date.toDate());
}, []);
return (
<div
className={cx(styles.container, { [styles.fullScreen]: isFullscreen })}
style={style}
onClick={stopPropagation}
ref={ref}
>
<Calendar
next2Label={null}
prev2Label={null}
value={internalDate}
nextLabel={<Icon name="angle-right" />}
nextAriaLabel="Next month"
prevLabel={<Icon name="angle-left" />}
prevAriaLabel="Previous month"
onChange={onChangeDate}
locale="en"
className={calendarStyles.body}
tileClassName={calendarStyles.title}
maxDate={maxDate}
/>
<div className={styles.time}>
<TimeOfDayPicker showSeconds={true} onChange={onChangeTime} value={dateTime(internalDate)} />
</div>
<HorizontalGroup>
<Button type="button" onClick={() => onChange(dateTime(internalDate))}>
Apply
</Button>
<Button variant="secondary" type="button" onClick={onClose}>
Cancel
</Button>
</HorizontalGroup>
</div>
<HorizontalGroup>
<Button type="button" onClick={() => onChange(dateTime(internalDate))}>
Apply
</Button>
<Button variant="secondary" type="button" onClick={onClose}>
Cancel
</Button>
</HorizontalGroup>
</div>
);
};
);
}
);
DateTimeCalendar.displayName = 'DateTimeCalendar';
const getStyles = (theme: GrafanaTheme2) => ({
container: css`

View File

@ -0,0 +1,445 @@
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 { Stack } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Badge, useStyles2 } from '@grafana/ui';
import { mapRelativeTimeRangeToOption } from '@grafana/ui/src/components/DateTimePickers/RelativeTimeRangePicker/utils';
import { AlertQuery } from '../../../types/unified-alerting-dto';
import { isExpressionQuery } from '../../expressions/guards';
import {
downsamplingTypes,
ExpressionQuery,
ExpressionQueryType,
reducerModes,
ReducerMode,
reducerTypes,
thresholdFunctions,
upsamplingTypes,
} from '../../expressions/types';
import alertDef, { EvalFunction } from '../state/alertDef';
import { ExpressionResult } from './components/expressions/Expression';
import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization';
interface GrafanaRuleViewerProps {
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) {
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));
return (
<Stack gap={2} direction="column">
<Stack gap={2}>
{dataQueries.map(({ model, relativeTimeRange, refId, datasourceUid }, index) => {
const dataSource = dsByUid[datasourceUid];
return (
<QueryPreview
key={index}
refId={refId}
isAlertCondition={condition === refId}
model={model}
relativeTimeRange={relativeTimeRange}
evalTimeRange={evalTimeRanges[refId]}
dataSource={dataSource}
queryData={evalDataByQuery[refId]}
onEvalTimeRangeChange={(timeRange) => onTimeRangeChange(refId, timeRange)}
/>
);
})}
</Stack>
<Stack gap={1}>
{expressions.map(({ model, relativeTimeRange, refId, datasourceUid }, index) => {
const dataSource = dsByUid[datasourceUid];
return (
isExpressionQuery(model) && (
<ExpressionPreview
key={index}
refId={refId}
isAlertCondition={condition === refId}
model={model}
dataSource={dataSource}
evalData={evalDataByQuery[refId]}
/>
)
);
})}
</Stack>
</Stack>
);
}
interface QueryPreviewProps extends Pick<AlertQuery, 'refId' | 'relativeTimeRange' | 'model'> {
isAlertCondition: boolean;
dataSource?: DataSourceInstanceSettings;
queryData?: PanelData;
evalTimeRange?: RelativeTimeRange;
onEvalTimeRangeChange: (timeRange: RelativeTimeRange) => void;
}
export function QueryPreview({
refId,
relativeTimeRange,
model,
dataSource,
queryData,
evalTimeRange,
onEvalTimeRangeChange,
}: QueryPreviewProps) {
const styles = useStyles2(getQueryPreviewStyles);
// 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);
}
return (
<QueryBox refId={refId} headerItems={headerItems} className={styles.contentBox}>
<pre className={styles.code}>
<code>{dump(model)}</code>
</pre>
{dataSource && (
<RuleViewerVisualization
refId={refId}
datasourceUid={dataSource.uid}
model={model}
data={queryData}
relativeTimeRange={evalTimeRange}
onTimeRangeChange={onEvalTimeRangeChange}
className={styles.visualization}
/>
)}
</QueryBox>
);
}
const getQueryPreviewStyles = (theme: GrafanaTheme2) => ({
code: css`
margin: ${theme.spacing(1)};
`,
contentBox: css`
flex: 1 0 100%; // RuleViewerVisualization uses AutoSizer which doesn't expand the box
`,
visualization: css`
padding: ${theme.spacing(1)};
`,
});
interface ExpressionPreviewProps extends Pick<AlertQuery, 'refId'> {
isAlertCondition: boolean;
model: ExpressionQuery;
dataSource: DataSourceInstanceSettings;
evalData?: PanelData;
}
function ExpressionPreview({ refId, model, evalData, isAlertCondition }: ExpressionPreviewProps) {
function renderPreview() {
switch (model.type) {
case ExpressionQueryType.math:
return <MathExpressionViewer model={model} />;
case ExpressionQueryType.reduce:
return <ReduceConditionViewer model={model} />;
case ExpressionQueryType.resample:
return <ResampleExpressionViewer model={model} />;
case ExpressionQueryType.classic:
return <ClassicConditionViewer model={model} />;
case ExpressionQueryType.threshold:
return <ThresholdExpressionViewer model={model} />;
default:
return <>Expression not supported: {model.type}</>;
}
}
return (
<QueryBox refId={refId} headerItems={[startCase(model.type)]} isAlertCondition={isAlertCondition}>
{renderPreview()}
{evalData && <ExpressionResult series={evalData.series} isAlertCondition={isAlertCondition} />}
</QueryBox>
);
}
interface QueryBoxProps extends React.PropsWithChildren<unknown> {
refId: string;
headerItems?: string[];
isAlertCondition?: boolean;
className?: string;
}
function QueryBox({ refId, headerItems = [], children, isAlertCondition, className }: QueryBoxProps) {
const styles = useStyles2(getQueryBoxStyles);
return (
<div className={cx(styles.container, className)}>
<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>
)}
</header>
{children}
</div>
);
}
const getQueryBoxStyles = (theme: GrafanaTheme2) => ({
container: css`
flex: 1 0 25%;
border: 1px solid ${theme.colors.border.strong};
max-width: 100%;
`,
header: css`
display: flex;
align-items: center;
gap: ${theme.spacing(1)};
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;
`,
});
function ClassicConditionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getClassicConditionViewerStyles);
const reducerFunctions = keyBy(alertDef.reducerTypes, (rt) => rt.value);
const evalOperators = keyBy(alertDef.evalOperators, (eo) => eo.value);
const evalFunctions = keyBy(alertDef.evalFunctions, (ef) => ef.value);
return (
<div className={styles.container}>
{model.conditions?.map(({ query, operator, reducer, evaluator }, index) => {
const isRange = isRangeEvaluator(evaluator);
return (
<React.Fragment key={index}>
<div className={styles.blue}>
{index === 0 ? 'WHEN' : !!operator?.type && evalOperators[operator?.type]?.text}
</div>
<div className={styles.bold}>{reducer?.type && reducerFunctions[reducer.type]?.text}</div>
<div className={styles.blue}>OF</div>
<div className={styles.bold}>{query.params[0]}</div>
<div className={styles.blue}>{evalFunctions[evaluator.type].text}</div>
<div className={styles.bold}>
{isRange ? `(${evaluator.params[0]}; ${evaluator.params[1]})` : evaluator.params[0]}
</div>
</React.Fragment>
);
})}
</div>
);
}
const getClassicConditionViewerStyles = (theme: GrafanaTheme2) => ({
container: css`
padding: ${theme.spacing(1)};
display: grid;
grid-template-columns: max-content max-content max-content max-content max-content max-content;
gap: ${theme.spacing(0, 1)};
`,
...getCommonQueryStyles(theme),
});
function ReduceConditionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getReduceConditionViewerStyles);
const { reducer, expression, settings } = model;
const reducerType = reducerTypes.find((rt) => rt.value === reducer);
const reducerMode = settings?.mode ?? ReducerMode.Strict;
const modeName = reducerModes.find((rm) => rm.value === reducerMode);
return (
<div className={styles.container}>
<div className={styles.label}>Function</div>
<div className={styles.value}>{reducerType?.label}</div>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
<div className={styles.label}>Mode</div>
<div className={styles.value}>{modeName?.label}</div>
</div>
);
}
const getReduceConditionViewerStyles = (theme: GrafanaTheme2) => ({
container: css`
padding: ${theme.spacing(1)};
display: grid;
gap: ${theme.spacing(1)};
grid-template-rows: 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr 1fr;
> :nth-child(6) {
grid-column: span 3;
}
`,
...getCommonQueryStyles(theme),
});
function ResampleExpressionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getResampleExpressionViewerStyles);
const { expression, window, downsampler, upsampler } = model;
const downsamplerType = downsamplingTypes.find((dt) => dt.value === downsampler);
const upsamplerType = upsamplingTypes.find((ut) => ut.value === upsampler);
return (
<div className={styles.container}>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
<div className={styles.label}>Resample to</div>
<div className={styles.value}>{window}</div>
<div className={styles.label}>Downsample</div>
<div className={styles.value}>{downsamplerType?.label}</div>
<div className={styles.label}>Upsample</div>
<div className={styles.value}>{upsamplerType?.label}</div>
</div>
);
}
const getResampleExpressionViewerStyles = (theme: GrafanaTheme2) => ({
container: css`
padding: ${theme.spacing(1)};
display: grid;
gap: ${theme.spacing(1)};
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
`,
...getCommonQueryStyles(theme),
});
function ThresholdExpressionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getExpressionViewerStyles);
const { expression, conditions } = model;
const evaluator = conditions && conditions[0]?.evaluator;
const thresholdFunction = thresholdFunctions.find((tf) => tf.value === evaluator?.type);
const isRange = evaluator ? isRangeEvaluator(evaluator) : false;
return (
<div className={styles.container}>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
{evaluator && (
<>
<div className={styles.blue}>{thresholdFunction?.label}</div>
<div className={styles.bold}>
{isRange ? `(${evaluator.params[0]}; ${evaluator.params[1]})` : evaluator.params[0]}
</div>
</>
)}
</div>
);
}
const getExpressionViewerStyles = (theme: GrafanaTheme2) => {
const { blue, bold, ...common } = getCommonQueryStyles(theme);
return {
...common,
container: css`
padding: ${theme.spacing(1)};
display: flex;
gap: ${theme.spacing(1)};
`,
blue: css`
${blue};
margin: auto 0;
`,
bold: css`
${bold};
margin: auto 0;
`,
};
};
function MathExpressionViewer({ model }: { model: ExpressionQuery }) {
const styles = useStyles2(getExpressionViewerStyles);
const { expression } = model;
return (
<div className={styles.container}>
<div className={styles.label}>Input</div>
<div className={styles.value}>{expression}</div>
</div>
);
}
const getCommonQueryStyles = (theme: GrafanaTheme2) => ({
blue: css`
color: ${theme.colors.text.link};
`,
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
label: css`
display: flex;
align-items: center;
padding: ${theme.spacing(0.5, 1)};
background-color: ${theme.colors.background.secondary};
font-size: ${theme.typography.bodySmall.fontSize};
line-height: ${theme.typography.bodySmall.lineHeight};
font-weight: ${theme.typography.fontWeightBold};
`,
value: css`
padding: ${theme.spacing(0.5, 1)};
border: 1px solid ${theme.colors.border.weak};
`,
});
function isRangeEvaluator(evaluator: { params: number[]; type: EvalFunction }) {
return evaluator.type === EvalFunction.IsWithinRange || evaluator.type === EvalFunction.IsOutsideRange;
}

View File

@ -1,14 +1,16 @@
import { css } from '@emotion/css';
import produce from 'immer';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
import { useObservable, useToggle } from 'react-use';
import { GrafanaTheme2, LoadingState, PanelData } from '@grafana/data';
import { GrafanaTheme2, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
Alert,
Button,
Collapse,
Icon,
LoadingPlaceholder,
PanelChromeLoadingIndicator,
useStyles2,
VerticalGroup,
withErrorBoundary,
@ -18,11 +20,11 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../core/constants';
import { AlertQuery } from '../../../types/unified-alerting-dto';
import { GrafanaRuleQueryViewer, QueryPreview } from './GrafanaRuleQueryViewer';
import { AlertLabels } from './components/AlertLabels';
import { DetailsField } from './components/DetailsField';
import { ProvisionedResource, ProvisioningAlert } from './components/Provisioning';
import { RuleViewerLayout, RuleViewerLayoutContent } from './components/rule-viewer/RuleViewerLayout';
import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization';
import { RuleDetailsActionButtons } from './components/rules/RuleDetailsActionButtons';
import { RuleDetailsAnnotations } from './components/rules/RuleDetailsAnnotations';
import { RuleDetailsDataSources } from './components/rules/RuleDetailsDataSources';
@ -47,47 +49,59 @@ const pageTitle = 'View rule';
export function RuleViewer({ match }: RuleViewerProps) {
const styles = useStyles2(getStyles);
const [expandQuery, setExpandQuery] = useToggle(false);
const { id } = match.params;
const identifier = ruleId.tryParse(id, true);
const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
const runner = useMemo(() => new AlertingQueryRunner(), []);
const data = useObservable(runner.get());
const queries2 = useMemo(() => alertRuleToQueries(rule), [rule]);
const [queries, setQueries] = useState<AlertQuery[]>([]);
const queries = useMemo(() => alertRuleToQueries(rule), [rule]);
const { allDataSourcesAvailable } = useAlertQueriesStatus(queries2);
const [evaluationTimeRanges, setEvaluationTimeRanges] = useState<Record<string, RelativeTimeRange>>({});
const { allDataSourcesAvailable } = useAlertQueriesStatus(queries);
const onRunQueries = useCallback(() => {
if (queries.length > 0 && allDataSourcesAvailable) {
runner.run(queries);
const evalCustomizedQueries = queries.map<AlertQuery>((q) => ({
...q,
relativeTimeRange: evaluationTimeRanges[q.refId] ?? q.relativeTimeRange,
}));
runner.run(evalCustomizedQueries);
}
}, [queries, runner, allDataSourcesAvailable]);
}, [queries, evaluationTimeRanges, runner, allDataSourcesAvailable]);
useEffect(() => {
setQueries(queries2);
}, [queries2]);
const alertQueries = alertRuleToQueries(rule);
const defaultEvalTimeRanges = Object.fromEntries(
alertQueries.map((q) => [q.refId, q.relativeTimeRange ?? { from: 0, to: 0 }])
);
setEvaluationTimeRanges(defaultEvalTimeRanges);
}, [rule]);
useEffect(() => {
if (allDataSourcesAvailable) {
if (allDataSourcesAvailable && expandQuery) {
onRunQueries();
}
}, [onRunQueries, allDataSourcesAvailable]);
}, [onRunQueries, allDataSourcesAvailable, expandQuery]);
useEffect(() => {
return () => runner.destroy();
}, [runner]);
const onChangeQuery = useCallback((query: AlertQuery) => {
setQueries((queries) =>
queries.map((q) => {
if (q.refId === query.refId) {
return query;
}
return q;
})
);
}, []);
const onQueryTimeRangeChange = useCallback(
(refId: string, timeRange: RelativeTimeRange) => {
const newEvalTimeRanges = produce(evaluationTimeRanges, (draft) => {
draft[refId] = timeRange;
});
setEvaluationTimeRanges(newEvalTimeRanges);
},
[evaluationTimeRanges, setEvaluationTimeRanges]
);
if (!identifier?.ruleSourceName) {
return (
@ -183,33 +197,49 @@ export function RuleViewer({ match }: RuleViewerProps) {
<RuleDetailsMatchingInstances rule={rule} pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }} />
</div>
</RuleViewerLayoutContent>
{!isFederatedRule && data && Object.keys(data).length > 0 && (
<>
<div className={styles.queriesTitle}>
Query results <PanelChromeLoadingIndicator loading={isLoading(data)} onCancel={() => runner.cancel()} />
<Collapse
label="Query & Results"
isOpen={expandQuery}
onToggle={setExpandQuery}
loading={data && isLoading(data)}
collapsible={true}
className={styles.collapse}
>
{isGrafanaRulerRule(rule.rulerRule) && !isFederatedRule && (
<GrafanaRuleQueryViewer
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 className={styles.queries}>
{queries.map((query) => {
return (
<QueryPreview
key={query.refId}
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>
<RuleViewerLayoutContent padding={0}>
<div className={styles.queries}>
{queries.map((query) => {
return (
<div key={query.refId} className={styles.query}>
<RuleViewerVisualization
query={query}
data={data && data[query.refId]}
onChangeQuery={onChangeQuery}
/>
</div>
);
})}
</div>
</RuleViewerLayoutContent>
</>
)}
{!isFederatedRule && !allDataSourcesAvailable && (
<Alert title="Query not available" severity="warning" className={styles.queryWarning}>
Cannot display the query preview. Some of the data sources used in the queries are not available.
</Alert>
)}
)}
{!isFederatedRule && !allDataSourcesAvailable && (
<Alert title="Query not available" severity="warning" className={styles.queryWarning}>
Cannot display the query preview. Some of the data sources used in the queries are not available.
</Alert>
)}
</Collapse>
</RuleViewerLayout>
);
}
@ -227,6 +257,11 @@ const getStyles = (theme: GrafanaTheme2) => {
height: 100%;
width: 100%;
`,
collapse: css`
margin-top: ${theme.spacing(2)};
border-color: ${theme.colors.border.weak};
border-radius: ${theme.shape.borderRadius()};
`,
queriesTitle: css`
padding: ${theme.spacing(2, 0.5)};
font-size: ${theme.typography.h5.fontSize};

View File

@ -8,7 +8,7 @@ interface AlertConditionProps {
enabled?: boolean;
error?: Error;
warning?: Error;
onSetCondition: () => void;
onSetCondition?: () => void;
}
export const AlertConditionIndicator: FC<AlertConditionProps> = ({
@ -33,7 +33,7 @@ export const AlertConditionIndicator: FC<AlertConditionProps> = ({
if (!enabled) {
return (
<div className={styles.actionLink} onClick={() => onSetCondition()}>
<div className={styles.actionLink} onClick={() => onSetCondition && onSetCondition()}>
Make this the alert condition
</div>
);

View File

@ -56,10 +56,6 @@ export const Expression: FC<ExpressionProps> = ({
const hasResults = Array.isArray(data?.series) && !isLoading;
const series = data?.series ?? [];
// sometime we receive results where every value is just "null" when noData occurs
const emptyResults = hasResults && isEmptySeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeries(series);
const alertCondition = isAlertCondition ?? false;
const showSummary = isAlertCondition && hasResults;
@ -109,24 +105,7 @@ export const Expression: FC<ExpressionProps> = ({
onUpdateExpressionType={(type) => onUpdateExpressionType(query.refId, type)}
/>
<div className={styles.expression.body}>{renderExpressionType(query)}</div>
{hasResults && (
<div className={styles.expression.results}>
{!emptyResults && isTimeSeriesResults && (
<div>
{series.map((frame, index) => (
<TimeseriesRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
))}
</div>
)}
{!emptyResults &&
!isTimeSeriesResults &&
series.map((frame, index) => (
// There's no way to uniquely identify a frame that doesn't cause render bugs :/ (Gilles)
<FrameRow key={uniqueId()} frame={frame} index={index} isAlertCondition={alertCondition} />
))}
{emptyResults && <div className={cx(styles.expression.noData, styles.mutedText)}>No data</div>}
</div>
)}
{hasResults && <ExpressionResult series={series} isAlertCondition={isAlertCondition} />}
<div className={styles.footer}>
<Stack direction="row" alignItems="center">
<AlertConditionIndicator
@ -149,7 +128,39 @@ export const Expression: FC<ExpressionProps> = ({
);
};
const PreviewSummary: FC<{ firing: number; normal: number }> = ({ firing, normal }) => {
interface ExpressionResultProps {
series: DataFrame[];
isAlertCondition?: boolean;
}
export const ExpressionResult: FC<ExpressionResultProps> = ({ series, isAlertCondition }) => {
const styles = useStyles2(getStyles);
// sometimes we receive results where every value is just "null" when noData occurs
const emptyResults = isEmptySeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeries(series);
return (
<div className={styles.expression.results}>
{!emptyResults && isTimeSeriesResults && (
<div>
{series.map((frame, index) => (
<TimeseriesRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
))}
</div>
)}
{!emptyResults &&
!isTimeSeriesResults &&
series.map((frame, index) => (
// There's no way to uniquely identify a frame that doesn't cause render bugs :/ (Gilles)
<FrameRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
))}
{emptyResults && <div className={cx(styles.expression.noData, styles.mutedText)}>No data</div>}
</div>
);
};
export const PreviewSummary: FC<{ firing: number; normal: number }> = ({ firing, normal }) => {
const { mutedText } = useStyles2(getStyles);
return <span className={mutedText}>{`${firing} firing, ${normal} normal`}</span>;
};

View File

@ -16,8 +16,8 @@ interface Props {
data: PanelData;
currentPanel: SupportedPanelPlugins;
changePanel: (panel: SupportedPanelPlugins) => void;
thresholds: ThresholdsConfig;
onThresholdsChange: (thresholds: ThresholdsConfig) => void;
thresholds?: ThresholdsConfig;
onThresholdsChange?: (thresholds: ThresholdsConfig) => void;
}
type PanelFieldConfig = FieldConfigSource<GraphFieldConfig>;
@ -30,7 +30,7 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel, onThres
const vizHeight = useVizHeight(data, currentPanel, options.frameIndex);
const styles = useStyles2(getStyles(vizHeight));
const [fieldConfig, setFieldConfig] = useState<PanelFieldConfig>(defaultFieldConfig(thresholds, data));
const [fieldConfig, setFieldConfig] = useState<PanelFieldConfig>(defaultFieldConfig(data, thresholds));
useEffect(() => {
setFieldConfig((fieldConfig) => ({
@ -109,7 +109,7 @@ function defaultUnit(data: PanelData): string | undefined {
return data.series[0]?.fields.find((field) => field.type === 'number')?.config.unit;
}
function defaultFieldConfig(thresholds: ThresholdsConfig, data: PanelData): PanelFieldConfig {
function defaultFieldConfig(data: PanelData, thresholds?: ThresholdsConfig): PanelFieldConfig {
if (!thresholds) {
return { defaults: {}, overrides: [] };
}

View File

@ -1,35 +1,50 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DataSourceInstanceSettings, DateTime, dateTime, GrafanaTheme2, PanelData, urlUtil } from '@grafana/data';
import {
DataSourceInstanceSettings,
DateTime,
dateTime,
GrafanaTheme2,
PanelData,
RelativeTimeRange,
urlUtil,
} from '@grafana/data';
import { config, getDataSourceSrv, PanelRenderer } from '@grafana/runtime';
import { Alert, CodeEditor, DateTimePicker, LinkButton, useStyles2, useTheme2 } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { PanelOptions } from 'app/plugins/panel/table/models.gen';
import { AccessControlAction } from 'app/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { TABLE, TIMESERIES } from '../../utils/constants';
import { Authorize } from '../Authorize';
import { PanelPluginsButtonGroup, SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
type RuleViewerVisualizationProps = {
interface RuleViewerVisualizationProps
extends Pick<AlertQuery, 'refId' | 'datasourceUid' | 'model' | 'relativeTimeRange'> {
data?: PanelData;
query: AlertQuery;
onChangeQuery: (query: AlertQuery) => void;
};
onTimeRangeChange: (range: RelativeTimeRange) => void;
className?: string;
}
const headerHeight = 4;
export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JSX.Element | null {
export function RuleViewerVisualization({
data,
refId,
model,
datasourceUid,
relativeTimeRange,
onTimeRangeChange,
className,
}: RuleViewerVisualizationProps): JSX.Element | null {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const { data, query, onChangeQuery } = props;
const defaultPanel = isExpressionQuery(query.model) ? TABLE : TIMESERIES;
const defaultPanel = isExpressionQuery(model) ? TABLE : TIMESERIES;
const [panel, setPanel] = useState<SupportedPanelPlugins>(defaultPanel);
const dsSettings = getDataSourceSrv().getInstanceSettings(query.datasourceUid);
const relativeTimeRange = query.relativeTimeRange;
const dsSettings = getDataSourceSrv().getInstanceSettings(datasourceUid);
const [options, setOptions] = useState<PanelOptions>({
frameIndex: 0,
showHeader: true,
@ -41,13 +56,10 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS
if (relativeTimeRange) {
const interval = relativeTimeRange.from - relativeTimeRange.to;
onChangeQuery({
...query,
relativeTimeRange: { from: now + interval, to: now },
});
onTimeRangeChange({ from: now + interval, to: now });
}
},
[onChangeQuery, query, relativeTimeRange]
[onTimeRangeChange, relativeTimeRange]
);
const setDateTime = useCallback((relativeTimeRangeTo: number) => {
@ -60,7 +72,7 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS
if (!dsSettings) {
return (
<div className={styles.content}>
<div className={cx(styles.content, className)}>
<Alert title="Could not find datasource for query" />
<CodeEditor
width="100%"
@ -68,7 +80,7 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS
language="json"
showLineNumbers={false}
showMiniMap={false}
value={JSON.stringify(query, null, '\t')}
value={JSON.stringify(model, null, '\t')}
readOnly={true}
/>
</div>
@ -76,18 +88,14 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS
}
return (
<div className={styles.content}>
<div className={cx(styles.content, className)}>
<AutoSizer>
{({ width, height }) => {
return (
<div style={{ width, height }}>
<div className={styles.header}>
<div>
{`Query ${query.refId}`}
<span className={styles.dataSource}>({dsSettings.name})</span>
</div>
<div className={styles.actions}>
{!isExpressionQuery(query.model) && relativeTimeRange ? (
{!isExpressionQuery(model) && relativeTimeRange ? (
<DateTimePicker
date={setDateTime(relativeTimeRange.to)}
onChange={onTimeChange}
@ -96,7 +104,7 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS
) : null}
<PanelPluginsButtonGroup onChange={setPanel} value={panel} size="md" />
<Authorize actions={[AccessControlAction.DataSourcesExplore]}>
{!isExpressionQuery(query.model) && (
{!isExpressionQuery(model) && (
<>
<div className={styles.spacing} />
<LinkButton
@ -104,7 +112,7 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS
variant="secondary"
icon="compass"
target="_blank"
href={createExploreLink(dsSettings, query)}
href={createExploreLink(dsSettings, model)}
>
View in Explore
</LinkButton>
@ -130,9 +138,9 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS
);
}
function createExploreLink(settings: DataSourceInstanceSettings, query: AlertQuery): string {
function createExploreLink(settings: DataSourceInstanceSettings, model: AlertDataQuery): string {
const { name } = settings;
const { refId, ...rest } = query.model;
const { refId, ...rest } = model;
const queryParams = { ...rest, datasource: name };
return urlUtil.renderUrl(`${config.appSubUrl}/explore`, {
@ -154,7 +162,7 @@ const getStyles = (theme: GrafanaTheme2) => {
height: ${theme.spacing(headerHeight)};
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
white-space: nowrap;
`,
refId: css`

View File

@ -3,7 +3,7 @@ import React, { FC } from 'react';
import { SelectableValue } from '@grafana/data';
import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui';
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerMode, reducerTypes } from '../types';
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerModes, reducerTypes } from '../types';
interface Props {
labelWidth?: number | 'auto';
@ -78,7 +78,7 @@ export const Reduce: FC<Props> = ({ labelWidth = 'auto', onChange, refIds, query
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Mode" labelWidth={labelWidth}>
<Select onChange={onModeChanged} options={reducerMode} value={mode} width={25} />
<Select onChange={onModeChanged} options={reducerModes} value={mode} width={25} />
</InlineField>
{replaceWithNumber()}
</InlineFieldRow>

View File

@ -56,7 +56,7 @@ export enum ReducerMode {
DropNonNumbers = 'dropNN',
}
export const reducerMode: Array<SelectableValue<ReducerMode>> = [
export const reducerModes: Array<SelectableValue<ReducerMode>> = [
{
value: ReducerMode.Strict,
label: 'Strict',