mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
Alerting: Access query details of provisioned alerts (#59626)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
b7fc837c35
commit
384322dc14
@ -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`
|
||||
|
445
public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx
Normal file
445
public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx
Normal 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;
|
||||
}
|
@ -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};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>;
|
||||
};
|
||||
|
@ -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: [] };
|
||||
}
|
||||
|
@ -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`
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user