diff --git a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx index 03bb94aadde..6fdab6e5aef 100644 --- a/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx +++ b/public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx @@ -24,6 +24,7 @@ import { import alertDef, { EvalFunction } from '../state/alertDef'; import { ExpressionResult } from './components/expressions/Expression'; +import { getThresholdsForQueries, ThresholdDefinition } from './components/rule-editor/util'; import { RuleViewerVisualization } from './components/rule-viewer/RuleViewerVisualization'; interface GrafanaRuleViewerProps { @@ -46,6 +47,8 @@ export function GrafanaRuleQueryViewer({ const expressions = queries.filter((q) => isExpressionQuery(q.model)); const styles = useStyles2(getExpressionViewerStyles); + const thresholds = getThresholdsForQueries(queries); + return (
@@ -62,6 +65,7 @@ export function GrafanaRuleQueryViewer({ relativeTimeRange={relativeTimeRange} evalTimeRange={evalTimeRanges[refId]} dataSource={dataSource} + thresholds={thresholds[refId]} queryData={evalDataByQuery[refId]} onEvalTimeRangeChange={(timeRange) => onTimeRangeChange(refId, timeRange)} /> @@ -97,6 +101,7 @@ interface QueryPreviewProps extends Pick void; } @@ -104,6 +109,7 @@ interface QueryPreviewProps extends Pick ({ margin: ${theme.spacing(1)}; `, contentBox: css` - flex: 1 0 100%; // RuleViewerVisualization uses AutoSizer which doesn't expand the box + flex: 1 0 100%; `, visualization: css` padding: ${theme.spacing(1)}; diff --git a/public/app/features/alerting/unified/components/PanelPluginsButtonGroup.tsx b/public/app/features/alerting/unified/components/PanelPluginsButtonGroup.tsx deleted file mode 100644 index d5bada5d1fa..00000000000 --- a/public/app/features/alerting/unified/components/PanelPluginsButtonGroup.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { useMemo } from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { RadioButtonGroup } from '@grafana/ui'; - -import { STAT, TABLE, TIMESERIES } from '../utils/constants'; - -export type SupportedPanelPlugins = 'timeseries' | 'table' | 'stat'; - -type Props = { - value: SupportedPanelPlugins; - onChange: (value: SupportedPanelPlugins) => void; - size?: 'sm' | 'md'; -}; - -export function PanelPluginsButtonGroup(props: Props): JSX.Element | null { - const { value, onChange, size = 'md' } = props; - const panels = useMemo(() => getSupportedPanels(), []); - - return ; -} - -function getSupportedPanels(): Array> { - return Object.values(config.panels).reduce((panels: Array>, panel) => { - if (isSupportedPanelPlugin(panel.id)) { - panels.push({ - value: panel.id, - label: panel.name, - imgUrl: panel.info.logos.small, - }); - } - return panels; - }, []); -} - -function isSupportedPanelPlugin(id: string): id is SupportedPanelPlugins { - switch (id) { - case TIMESERIES: - case TABLE: - case STAT: - return true; - default: - return false; - } -} diff --git a/public/app/features/alerting/unified/components/expressions/Expression.test.tsx b/public/app/features/alerting/unified/components/expressions/Expression.test.tsx index 7c4e2746940..326cd6a7acd 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.test.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.test.tsx @@ -14,6 +14,20 @@ describe('TestResult', () => { }).not.toThrow(); }); + it('should show labels and values', () => { + const series: DataFrame[] = [ + toDataFrame({ fields: [{ name: 'temp', values: [0.1234], labels: { label1: 'value1', label2: 'value2' } }] }), + toDataFrame({ fields: [{ name: 'temp', values: [0.5678], labels: { label1: 'value3', label2: 'value4' } }] }), + ]; + render(); + + expect(screen.getByTitle('{label1=value1, label2=value2}')).toBeInTheDocument(); + expect(screen.getByText('0.1234')).toBeInTheDocument(); + + expect(screen.getByTitle('{label1=value3, label2=value4}')).toBeInTheDocument(); + expect(screen.getByText('0.5678')).toBeInTheDocument(); + }); + it('should not paginate with less than PAGE_SIZE', () => { const series: DataFrame[] = [ toDataFrame({ @@ -58,7 +72,11 @@ function makeSeries(n: number) { fields: [ { name: 'temp', - values: [1], + values: [0.1234], + labels: { + label1: 'value1', + label2: 'value2', + }, }, ], }) diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index 739979f43b7..4057a771ac8 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -19,7 +19,7 @@ import { Spacer } from '../Spacer'; import { AlertStateTag } from '../rules/AlertStateTag'; import { AlertConditionIndicator } from './AlertConditionIndicator'; -import { formatLabels, getSeriesName, getSeriesValue, isEmptySeries } from './util'; +import { formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries } from './util'; interface ExpressionProps { isAlertCondition?: boolean; @@ -302,16 +302,37 @@ const FrameRow: FC = ({ frame, index, isAlertCondition }) => { const name = getSeriesName(frame) || 'Series ' + index; const value = getSeriesValue(frame); + const labelsRecord = getSeriesLabels(frame); + const labels = Object.entries(labelsRecord); + const hasLabels = labels.length > 0; const showFiring = isAlertCondition && value !== 0; const showNormal = isAlertCondition && value === 0; + const title = `${hasLabels ? '' : name}${hasLabels ? `{${formatLabels(labelsRecord)}}` : ''}`; + return (
- - {name} - +
+ {hasLabels ? '' : name} + {hasLabels && ( + <> + {'{'} + {labels.map(([key, value], index) => ( + + {key} + = + " + {value} + " + {index < labels.length - 1 && , } + + ))} + {'}'} + + )} +
{value}
{showFiring && } {showNormal && } @@ -397,6 +418,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ color: ${theme.colors.primary.text}; `, results: css` + display: flex; + flex-direction: column; + flex-wrap: nowrap; + border-top: solid 1px ${theme.colors.border.medium}; `, noResults: css` @@ -415,12 +440,21 @@ const getStyles = (theme: GrafanaTheme2) => ({ background-color: ${theme.colors.background.canvas}; } `, + labelKey: css` + color: ${theme.isDark ? '#73bf69' : '#56a64b'}; + `, + labelValue: css` + color: ${theme.isDark ? '#ce9178' : '#a31515'}; + `, resultValue: css` - color: ${theme.colors.text.maxContrast}; text-align: right; `, resultLabel: css` flex: 1; + overflow-x: auto; + + display: inline-block; + white-space: nowrap; `, noData: css` display: flex; diff --git a/public/app/features/alerting/unified/components/expressions/util.test.ts b/public/app/features/alerting/unified/components/expressions/util.test.ts index 30b022791da..7a20b352698 100644 --- a/public/app/features/alerting/unified/components/expressions/util.test.ts +++ b/public/app/features/alerting/unified/components/expressions/util.test.ts @@ -1,6 +1,6 @@ import { DataFrame, FieldType, toDataFrame } from '@grafana/data'; -import { getSeriesName, formatLabels, getSeriesValue, isEmptySeries } from './util'; +import { getSeriesName, formatLabels, getSeriesValue, isEmptySeries, getSeriesLabels } from './util'; const EMPTY_FRAME: DataFrame = toDataFrame([]); const NAMED_FRAME: DataFrame = { @@ -17,7 +17,7 @@ const DATA_FRAME_LARGE_DECIMAL: DataFrame = toDataFrame({ }); const DATA_FRAME_WITH_LABELS: DataFrame = toDataFrame({ - fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3], labels: { foo: 'bar' } }], + fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3], labels: { __name__: 'my-series', foo: 'bar' } }], }); describe('formatLabels', () => { @@ -51,16 +51,16 @@ describe('getSeriesName', () => { }); it('should work with empty data frame', () => { - expect(getSeriesName(EMPTY_FRAME)).toBe(''); + expect(getSeriesName(EMPTY_FRAME)).toBe(undefined); }); - it('should work with labeled frame', () => { + it('should work with __name__ labeled frame', () => { const name = getSeriesName(DATA_FRAME_WITH_LABELS); - expect(name).toBe('foo=bar'); + expect(name).toBe('my-series'); }); it('should work with NoData frames', () => { - expect(getSeriesName(EMPTY_FRAME)).toBe(''); + expect(getSeriesName(EMPTY_FRAME)).toBe(undefined); }); it('should give preference to displayNameFromDS', () => { @@ -97,3 +97,13 @@ describe('getSeriesValue', () => { expect(getSeriesValue(DATA_FRAME_LARGE_DECIMAL)).toBe(1.23457); }); }); + +describe('getSeriesLabels', () => { + it('should work for dataframe with labels', () => { + expect(getSeriesLabels(DATA_FRAME_WITH_LABELS)).toStrictEqual({ __name__: 'my-series', foo: 'bar' }); + }); + + it('should work for dataframe with no labels', () => { + expect(getSeriesLabels(EMPTY_FRAME)).toStrictEqual({}); + }); +}); diff --git a/public/app/features/alerting/unified/components/expressions/util.ts b/public/app/features/alerting/unified/components/expressions/util.ts index 1ca20292c9d..2375f61e820 100644 --- a/public/app/features/alerting/unified/components/expressions/util.ts +++ b/public/app/features/alerting/unified/components/expressions/util.ts @@ -9,11 +9,11 @@ import { DataFrame, Labels, roundDecimals } from '@grafana/data'; * see https://github.com/Microsoft/TypeScript/issues/13778 */ -const getSeriesName = (frame: DataFrame): string => { +const getSeriesName = (frame: DataFrame): string | undefined => { const firstField = frame.fields[0]; const displayNameFromDS = firstField?.config?.displayNameFromDS; - return displayNameFromDS ?? frame.name ?? formatLabels(firstField?.labels ?? {}); + return displayNameFromDS ?? frame.name ?? firstField?.labels?.__name__; }; const getSeriesValue = (frame: DataFrame) => { @@ -26,6 +26,11 @@ const getSeriesValue = (frame: DataFrame) => { return value; }; +const getSeriesLabels = (frame: DataFrame): Record => { + const firstField = frame.fields[0]; + return firstField?.labels ?? {}; +}; + const formatLabels = (labels: Labels): string => { return Object.entries(labels) .map(([key, value]) => key + '=' + value) @@ -38,4 +43,4 @@ const isEmptySeries = (series: DataFrame[]): boolean => { return isEmpty; }; -export { getSeriesName, getSeriesValue, formatLabels, isEmptySeries }; +export { getSeriesName, getSeriesValue, getSeriesLabels, formatLabels, isEmptySeries }; diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index 25232791e3b..ba365456a0d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -5,6 +5,7 @@ import React, { ChangeEvent, useState } from 'react'; import { CoreApp, DataQuery, + DataSourceApi, DataSourceInstanceSettings, getDefaultRelativeTimeRange, GrafanaTheme2, @@ -23,12 +24,9 @@ import { Tooltip, useStyles2, } from '@grafana/ui'; -import { isExpressionQuery } from 'app/features/expressions/guards'; import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -import { TABLE, TIMESERIES } from '../../utils/constants'; -import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; import { AlertConditionIndicator } from '../expressions/AlertConditionIndicator'; import { VizWrapper } from './VizWrapper'; @@ -81,8 +79,8 @@ export const QueryWrapper = ({ onChangeQueryOptions, }: Props) => { const styles = useStyles2(getStyles); - const isExpression = isExpressionQuery(query.model); - const [pluginId, changePluginId] = useState(isExpression ? TABLE : TIMESERIES); + const [dsInstance, setDsInstance] = useState(); + const defaults = dsInstance?.getDefaultQuery ? dsInstance.getDefaultQuery(CoreApp.UnifiedAlerting) : {}; function SelectingDataSourceTooltip() { const styles = useStyles2(getStyles); @@ -117,67 +115,65 @@ export const QueryWrapper = ({ maxDataPoints: queryOptions.maxDataPoints, }; - if (isExpressionQuery(query.model)) { - return null; - } else { - return ( - - - {onChangeTimeRange && ( - onChangeTimeRange(range, index)} - /> - )} -
- onChangeQueryOptions(options, index)} - /> -
- onSetCondition(query.refId)} - enabled={condition === query.refId} - error={error} + return ( + + + {onChangeTimeRange && ( + onChangeTimeRange(range, index)} /> - - ); - } + )} +
+ onChangeQueryOptions(options, index)} + /> +
+ onSetCondition(query.refId)} + enabled={condition === query.refId} + error={error} + /> +
+ ); } return ( -
- - alerting - dataSource={dsSettings} - onChangeDataSource={!isExpression ? (settings) => onChangeDataSource(settings, index) : undefined} - id={query.refId} - index={index} - key={query.refId} - data={data} - query={cloneDeep(query.model)} - onChange={(query) => onChangeQuery(query, index)} - onRemoveQuery={onRemoveQuery} - onAddQuery={() => onDuplicateQuery(cloneDeep(query))} - onRunQuery={onRunQueries} - queries={queries} - renderHeaderExtras={() => } - app={CoreApp.UnifiedAlerting} - visualization={ - data.state !== LoadingState.NotStarted ? ( - onChangeThreshold(thresholds, index) : undefined} - /> - ) : null - } - hideDisableQuery={true} - /> -
+ +
+ + alerting + dataSource={dsSettings} + onDataSourceLoaded={setDsInstance} + onChangeDataSource={(settings) => onChangeDataSource(settings, index)} + id={query.refId} + index={index} + key={query.refId} + data={data} + query={{ + ...defaults, + ...cloneDeep(query.model), + }} + onChange={(query) => onChangeQuery(query, index)} + onRemoveQuery={onRemoveQuery} + onAddQuery={() => onDuplicateQuery(cloneDeep(query))} + onRunQuery={onRunQueries} + queries={queries} + renderHeaderExtras={() => } + app={CoreApp.UnifiedAlerting} + hideDisableQuery={true} + /> +
+ {data.state !== LoadingState.NotStarted && ( + onChangeThreshold(thresholds, index) : undefined} + /> + )} +
); }; diff --git a/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx index cba32d4f669..3b5290d62b1 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx @@ -7,12 +7,9 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { DataQuery, LoadingState } from '@grafana/schema'; import { useStyles2 } from '@grafana/ui'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; -import { isExpressionQuery } from 'app/features/expressions/guards'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -import { TABLE, TIMESERIES } from '../../utils/constants'; import { isPromOrLokiQuery } from '../../utils/rule-form'; -import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; import { VizWrapper } from './VizWrapper'; @@ -39,10 +36,6 @@ export const RecordingRuleEditor: FC = ({ const styles = useStyles2(getStyles); - const isExpression = isExpressionQuery(queries[0]?.model); - - const [pluginId, changePluginId] = useState(isExpression ? TABLE : TIMESERIES); - useEffect(() => { setData(panelData?.[queries[0]?.refId]); }, [panelData, queries]); @@ -108,7 +101,7 @@ export const RecordingRuleEditor: FC = ({ {data && (
- +
)} diff --git a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx index 91c9585f953..22f31ccc3a0 100644 --- a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx @@ -1,136 +1,90 @@ import { css } from '@emotion/css'; -import React, { useEffect, useMemo, useState } from 'react'; +import React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { FieldConfigSource, GrafanaTheme2, PanelData, ThresholdsConfig } from '@grafana/data'; -import { PanelRenderer } from '@grafana/runtime'; -import { GraphFieldConfig, GraphTresholdsStyleMode } from '@grafana/schema'; -import { PanelContext, PanelContextProvider, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, isTimeSeriesFrames, PanelData, ThresholdsConfig } from '@grafana/data'; +import { GraphTresholdsStyleMode, LoadingState } from '@grafana/schema'; +import { useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; -import { PanelOptions } from 'app/plugins/panel/table/panelcfg.gen'; +import { GraphContainer } from 'app/features/explore/Graph/GraphContainer'; -import { useVizHeight } from '../../hooks/useVizHeight'; -import { SupportedPanelPlugins, PanelPluginsButtonGroup } from '../PanelPluginsButtonGroup'; +import { ExpressionResult } from '../expressions/Expression'; + +import { getStatusMessage } from './util'; interface Props { data: PanelData; - currentPanel: SupportedPanelPlugins; - changePanel: (panel: SupportedPanelPlugins) => void; thresholds?: ThresholdsConfig; thresholdsType?: GraphTresholdsStyleMode; onThresholdsChange?: (thresholds: ThresholdsConfig) => void; } -type PanelFieldConfig = FieldConfigSource; - -export const VizWrapper = ({ - data, - currentPanel, - changePanel, - thresholds, - thresholdsType = GraphTresholdsStyleMode.Line, -}: Props) => { - const [options, setOptions] = useState({ - frameIndex: 0, - showHeader: true, - }); - const vizHeight = useVizHeight(data, currentPanel, options.frameIndex); - const styles = useStyles2(getStyles(vizHeight)); - - const [fieldConfig, setFieldConfig] = useState(defaultFieldConfig(data, thresholds)); - - useEffect(() => { - setFieldConfig((fieldConfig) => ({ - ...fieldConfig, - defaults: { - ...fieldConfig.defaults, - thresholds: thresholds, - unit: defaultUnit(data), - custom: { - ...fieldConfig.defaults.custom, - thresholdsStyle: { - mode: thresholdsType, - }, - }, - }, - })); - }, [thresholds, setFieldConfig, data, thresholdsType]); - - const context: PanelContext = useMemo( - () => ({ - eventBus: appEvents, - canEditThresholds: false, - showThresholds: true, - }), - [] - ); - - if (!options || !data) { - return null; - } +/** The VizWrapper is just a simple component that renders either a table or a graph based on the type of data we receive from "PanelData" */ +export const VizWrapper = ({ data, thresholds, thresholdsType }: Props) => { + const styles = useStyles2(getStyles); + const isTimeSeriesData = isTimeSeriesFrames(data.series); + const statusMessage = getStatusMessage(data); + const thresholdsStyle = thresholdsType ? { mode: thresholdsType } : undefined; + const timeRange = { + from: data.timeRange.from.valueOf(), + to: data.timeRange.to.valueOf(), + }; return (
-
- -
- - {({ width }) => { - if (width === 0) { - return null; - } - return ( -
- - - -
- ); - }} + + {({ width }) => ( +
+ {isTimeSeriesData ? ( + {}} + splitOpenFn={() => {}} + loadingState={data.state} + thresholdsConfig={thresholds} + thresholdsStyle={thresholdsStyle} + /> + ) : ( +
+
Table
+ +
+ )} +
+ )}
); }; -const getStyles = (visHeight: number) => (theme: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` - padding: 0 ${theme.spacing(2)}; - height: ${visHeight + theme.spacing.gridSize * 4}px; + width: 100%; + position: relative; `, - buttonGroup: css` + instantVectorResultWrapper: css` + border: solid 1px ${theme.colors.border.medium}; + border-radius: ${theme.shape.borderRadius()}; + padding: 0; + display: flex; - justify-content: flex-end; + flex-direction: column; + flex-wrap: nowrap; `, + title: css({ + label: 'panel-title', + padding: theme.spacing(), + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + fontSize: theme.typography.h6.fontSize, + fontWeight: theme.typography.h6.fontWeight, + }), }); - -function defaultUnit(data: PanelData): string | undefined { - return data.series[0]?.fields.find((field) => field.type === 'number')?.config.unit; -} - -function defaultFieldConfig(data: PanelData, thresholds?: ThresholdsConfig): PanelFieldConfig { - if (!thresholds) { - return { defaults: {}, overrides: [] }; - } - - return { - defaults: { - thresholds: thresholds, - unit: defaultUnit(data), - custom: { - thresholdsStyle: { - mode: GraphTresholdsStyleMode.Line, - }, - }, - }, - overrides: [], - }; -} diff --git a/public/app/features/alerting/unified/components/rule-editor/util.test.ts b/public/app/features/alerting/unified/components/rule-editor/util.test.ts index 52d4c86ee0b..64b24bc9b87 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.test.ts @@ -285,6 +285,56 @@ describe('getThresholdsForQueries', () => { expect(thresholdsClassic).toMatchSnapshot(); }); + it('should not throw if no refId exists', () => { + const dataQuery: AlertQuery = { + refId: 'A', + datasourceUid: 'abc123', + queryType: '', + relativeTimeRange: { + from: 600, + to: 0, + }, + model: { + refId: 'A', + }, + }; + + const classicCondition = { + refId: 'B', + datasourceUid: '__expr__', + queryType: '', + model: { + refId: 'B', + type: 'classic_conditions', + datasource: ExpressionDatasourceRef, + conditions: [ + { + type: 'query', + evaluator: { + params: [0], + type: 'gt', + }, + operator: { + type: 'and', + }, + query: { + params: [''], + }, + reducer: { + params: [], + type: 'last', + }, + }, + ], + }, + }; + + expect(() => { + const thresholds = getThresholdsForQueries([dataQuery, classicCondition]); + expect(thresholds).toStrictEqual({}); + }).not.toThrowError(); + }); + it('should work for within_range', () => { const queries = createThresholdExample('within_range'); const thresholds = getThresholdsForQueries(queries); diff --git a/public/app/features/alerting/unified/components/rule-editor/util.ts b/public/app/features/alerting/unified/components/rule-editor/util.ts index 89dfe3e2717..626551539af 100644 --- a/public/app/features/alerting/unified/components/rule-editor/util.ts +++ b/public/app/features/alerting/unified/components/rule-editor/util.ts @@ -1,7 +1,7 @@ import { ValidateResult } from 'react-hook-form'; -import { DataFrame, ThresholdsConfig, ThresholdsMode, isTimeSeriesFrames } from '@grafana/data'; -import { GraphTresholdsStyleMode } from '@grafana/schema'; +import { DataFrame, ThresholdsConfig, ThresholdsMode, isTimeSeriesFrames, PanelData } from '@grafana/data'; +import { GraphTresholdsStyleMode, LoadingState } from '@grafana/schema'; import { config } from 'app/core/config'; import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { isExpressionQuery } from 'app/features/expressions/guards'; @@ -114,13 +114,12 @@ export function warningFromSeries(series: DataFrame[]): Error | undefined { return warning ? new Error(warning) : undefined; } -export type ThresholdDefinitions = Record< - string, - { - config: ThresholdsConfig; - mode: GraphTresholdsStyleMode; - } ->; +export type ThresholdDefinition = { + config: ThresholdsConfig; + mode: GraphTresholdsStyleMode; +}; + +export type ThresholdDefinitions = Record; /** * This function will retrieve threshold definitions for the given array of data and expression queries. @@ -147,11 +146,17 @@ export function getThresholdsForQueries(queries: AlertQuery[]) { // the time series panel does not support both. const hasRangeThreshold = query.model.conditions.some(isRangeCondition); - query.model.conditions.forEach((condition, index) => { + query.model.conditions.forEach((condition) => { const threshold = condition.evaluator.params; // "classic_conditions" use `condition.query.params[]` and "threshold" uses `query.model.expression` const refId = condition.query.params[0] ?? query.model.expression; + + // if an expression hasn't been linked to a data query yet, it won't have a refId + if (!refId) { + return; + } + const isRangeThreshold = isRangeCondition(condition); try { @@ -261,6 +266,9 @@ export function getThresholdsForQueries(queries: AlertQuery[]) { // now also sort the threshold values, if we don't then they will look weird in the time series panel // TODO this doesn't work for negative values for now, those need to be sorted inverse thresholds[refId].config.steps.sort((a, b) => a.value - b.value); + + // also make sure we remove any "undefined" values from our steps in case the threshold config is incomplete + thresholds[refId].config.steps = thresholds[refId].config.steps.filter((step) => step.value !== undefined); } return thresholds; @@ -271,3 +279,17 @@ function isRangeCondition(condition: ClassicCondition) { condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange ); } + +export function getStatusMessage(data: PanelData): string | undefined { + const genericErrorMessage = 'Failed to fetch data'; + if (data.state !== LoadingState.Error) { + return; + } + + const errors = data.errors; + if (errors?.length) { + return errors.map((error) => error.message ?? genericErrorMessage).join(', '); + } + + return data.error?.message ?? genericErrorMessage; +} diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index 9c01e69ecc6..bce99753e24 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -1,9 +1,9 @@ -import { css, cx } from '@emotion/css'; -import React, { useCallback, useState } from 'react'; -import AutoSizer from 'react-virtualized-auto-sizer'; +import { css } from '@emotion/css'; +import React, { useCallback } from 'react'; import { DataSourceInstanceSettings, + DataSourceJsonData, DateTime, dateTime, GrafanaTheme2, @@ -11,20 +11,20 @@ import { RelativeTimeRange, urlUtil, } from '@grafana/data'; -import { config, getDataSourceSrv, PanelRenderer } from '@grafana/runtime'; -import { Alert, CodeEditor, DateTimePicker, LinkButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { DateTimePicker, LinkButton, useStyles2 } from '@grafana/ui'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { PanelOptions } from 'app/plugins/panel/table/panelcfg.gen'; import { AccessControlAction } from 'app/types'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; -import { TABLE, TIMESERIES } from '../../utils/constants'; import { Authorize } from '../Authorize'; -import { PanelPluginsButtonGroup, SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; +import { VizWrapper } from '../rule-editor/VizWrapper'; +import { ThresholdDefinition } from '../rule-editor/util'; -interface RuleViewerVisualizationProps - extends Pick { +interface RuleViewerVisualizationProps extends Pick { + dsSettings: DataSourceInstanceSettings; data?: PanelData; + thresholds?: ThresholdDefinition; onTimeRangeChange: (range: RelativeTimeRange) => void; className?: string; } @@ -33,22 +33,15 @@ const headerHeight = 4; export function RuleViewerVisualization({ data, - refId, model, - datasourceUid, + thresholds, + dsSettings, relativeTimeRange, onTimeRangeChange, className, }: RuleViewerVisualizationProps): JSX.Element | null { - const theme = useTheme2(); const styles = useStyles2(getStyles); - const defaultPanel = isExpressionQuery(model) ? TABLE : TIMESERIES; - const [panel, setPanel] = useState(defaultPanel); - const dsSettings = getDataSourceSrv().getInstanceSettings(datasourceUid); - const [options, setOptions] = useState({ - frameIndex: 0, - showHeader: true, - }); + const isExpression = isExpressionQuery(model); const onTimeChange = useCallback( (newDateTime: DateTime) => { @@ -70,70 +63,29 @@ export function RuleViewerVisualization({ return null; } - if (!dsSettings) { - return ( -
- - -
- ); - } - return ( -
- - {({ width, height }) => { - return ( -
-
-
- {!isExpressionQuery(model) && relativeTimeRange ? ( - - ) : null} - - - {!isExpressionQuery(model) && ( - <> -
- - View in Explore - - - )} - -
-
- -
- ); - }} - +
+
+
+ {!isExpression && relativeTimeRange ? ( + + ) : null} + + {!isExpression && ( + + View in Explore + + )} + +
+
+
); } @@ -142,12 +94,12 @@ function createExploreLink(settings: DataSourceInstanceSettings, model: AlertDat const { name } = 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. Ideally we'd resolve the datasource name to the proper datasource Ref "{ type: string, uid: string }" and pass that in to 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`, { @@ -161,16 +113,13 @@ function createExploreLink(settings: DataSourceInstanceSettings, model: AlertDat const getStyles = (theme: GrafanaTheme2) => { return { - content: css` - width: 100%; - height: 250px; - `, 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}; @@ -186,9 +135,6 @@ const getStyles = (theme: GrafanaTheme2) => { display: flex; align-items: center; `, - spacing: css` - padding: ${theme.spacing(0, 1, 0, 0)}; - `, errorMessage: css` white-space: pre-wrap; `, diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index 6ff56904d6e..2eeb0fee9cf 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -15,11 +15,18 @@ import { LoadingState, SplitOpen, TimeZone, + ThresholdsConfig, DashboardCursorSync, EventBus, } from '@grafana/data'; import { PanelRenderer } from '@grafana/runtime'; -import { GraphDrawStyle, LegendDisplayMode, TooltipDisplayMode, SortOrder } from '@grafana/schema'; +import { + GraphDrawStyle, + LegendDisplayMode, + TooltipDisplayMode, + SortOrder, + GraphThresholdsStyleConfig, +} from '@grafana/schema'; import { Button, Icon, @@ -29,13 +36,14 @@ import { useStyles2, useTheme2, } from '@grafana/ui'; +import { GraphFieldConfig } from 'app/plugins/panel/graph/types'; import { defaultGraphConfig, getGraphFieldConfig } from 'app/plugins/panel/timeseries/config'; import { PanelOptions as TimeSeriesOptions } from 'app/plugins/panel/timeseries/panelcfg.gen'; import { ExploreGraphStyle } from 'app/types'; import { seriesVisibilityConfigFactory } from '../../dashboard/dashgrid/SeriesVisibilityConfigFactory'; -import { applyGraphStyle } from './exploreGraphStyleUtils'; +import { applyGraphStyle, applyThresholdsConfig } from './exploreGraphStyleUtils'; import { useStructureRev } from './useStructureRev'; const MAX_NUMBER_OF_TIME_SERIES = 20; @@ -55,6 +63,8 @@ interface Props { graphStyle: ExploreGraphStyle; anchorToZero?: boolean; yAxisMaximum?: number; + thresholdsConfig?: ThresholdsConfig; + thresholdsStyle?: GraphThresholdsStyleConfig; eventBus: EventBus; } @@ -73,6 +83,8 @@ export function ExploreGraph({ tooltipDisplayMode = TooltipDisplayMode.Single, anchorToZero = false, yAxisMaximum, + thresholdsConfig, + thresholdsStyle, eventBus, }: Props) { const theme = useTheme2(); @@ -93,7 +105,7 @@ export function ExploreGraph({ [] ); - const [fieldConfig, setFieldConfig] = useState({ + const [fieldConfig, setFieldConfig] = useState>({ defaults: { min: anchorToZero ? 0 : undefined, max: yAxisMaximum || undefined, @@ -109,10 +121,10 @@ export function ExploreGraph({ overrides: [], }); - const styledFieldConfig = useMemo( - () => applyGraphStyle(fieldConfig, graphStyle, yAxisMaximum), - [fieldConfig, graphStyle, yAxisMaximum] - ); + const styledFieldConfig = useMemo(() => { + const withGraphStyle = applyGraphStyle(fieldConfig, graphStyle, yAxisMaximum); + return applyThresholdsConfig(withGraphStyle, thresholdsStyle, thresholdsConfig); + }, [fieldConfig, graphStyle, yAxisMaximum, thresholdsConfig, thresholdsStyle]); const dataWithConfig = useMemo(() => { return applyFieldOverrides({ diff --git a/public/app/features/explore/Graph/GraphContainer.tsx b/public/app/features/explore/Graph/GraphContainer.tsx index ca3ce6cf752..2413636300d 100644 --- a/public/app/features/explore/Graph/GraphContainer.tsx +++ b/public/app/features/explore/Graph/GraphContainer.tsx @@ -1,7 +1,15 @@ import React, { useCallback, useState } from 'react'; -import { DataFrame, EventBus, AbsoluteTimeRange, TimeZone, SplitOpen, LoadingState } from '@grafana/data'; -import { PanelChrome } from '@grafana/ui'; +import { + DataFrame, + EventBus, + AbsoluteTimeRange, + TimeZone, + SplitOpen, + LoadingState, + ThresholdsConfig, +} from '@grafana/data'; +import { GraphThresholdsStyleConfig, PanelChrome, PanelChromeProps } from '@grafana/ui'; import { ExploreGraphStyle } from 'app/types'; import { storeGraphStyle } from '../state/utils'; @@ -10,18 +18,18 @@ import { ExploreGraph } from './ExploreGraph'; import { ExploreGraphLabel } from './ExploreGraphLabel'; import { loadGraphStyle } from './utils'; -interface Props { +interface Props extends Pick { loading: boolean; data: DataFrame[]; annotations?: DataFrame[]; eventBus: EventBus; - height: number; - width: number; absoluteRange: AbsoluteTimeRange; timeZone: TimeZone; onChangeTime: (absoluteRange: AbsoluteTimeRange) => void; splitOpenFn: SplitOpen; loadingState: LoadingState; + thresholdsConfig?: ThresholdsConfig; + thresholdsStyle?: GraphThresholdsStyleConfig; } export const GraphContainer = ({ @@ -34,7 +42,10 @@ export const GraphContainer = ({ annotations, onChangeTime, splitOpenFn, + thresholdsConfig, + thresholdsStyle, loadingState, + statusMessage, }: Props) => { const [graphStyle, setGraphStyle] = useState(loadGraphStyle); @@ -49,6 +60,7 @@ export const GraphContainer = ({ width={width} height={height} loadingState={loadingState} + statusMessage={statusMessage} actions={} > {(innerWidth, innerHeight) => ( @@ -63,6 +75,8 @@ export const GraphContainer = ({ annotations={annotations} splitOpenFn={splitOpenFn} loadingState={loadingState} + thresholdsConfig={thresholdsConfig} + thresholdsStyle={thresholdsStyle} eventBus={eventBus} /> )} diff --git a/public/app/features/explore/Graph/exploreGraphStyleUtils.ts b/public/app/features/explore/Graph/exploreGraphStyleUtils.ts index 1bbf6f7ad7a..fc121229a76 100644 --- a/public/app/features/explore/Graph/exploreGraphStyleUtils.ts +++ b/public/app/features/explore/Graph/exploreGraphStyleUtils.ts @@ -1,7 +1,7 @@ import produce from 'immer'; -import { FieldConfigSource } from '@grafana/data'; -import { GraphDrawStyle, GraphFieldConfig, StackingMode } from '@grafana/schema'; +import { FieldConfigSource, ThresholdsConfig } from '@grafana/data'; +import { GraphDrawStyle, GraphFieldConfig, GraphThresholdsStyleConfig, StackingMode } from '@grafana/schema'; import { ExploreGraphStyle } from 'app/types'; export type FieldConfig = FieldConfigSource; @@ -57,3 +57,15 @@ export function applyGraphStyle(config: FieldConfig, style: ExploreGraphStyle, m } }); } + +export function applyThresholdsConfig( + config: FieldConfig, + thresholdsStyle?: GraphThresholdsStyleConfig, + thresholdsConfig?: ThresholdsConfig +): FieldConfig { + return produce(config, (draft) => { + draft.defaults.thresholds = thresholdsConfig; + draft.defaults.custom = draft.defaults.custom ?? {}; + draft.defaults.custom.thresholdsStyle = thresholdsStyle; + }); +} diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index efcec635deb..7127fef7677 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -46,6 +46,7 @@ interface Props { index: number; dataSource: DataSourceInstanceSettings; onChangeDataSource?: (dsSettings: DataSourceInstanceSettings) => void; + onDataSourceLoaded?: (instance: DataSourceApi) => void; renderHeaderExtras?: () => ReactNode; onAddQuery: (query: TQuery) => void; onRemoveQuery: (query: TQuery) => void; @@ -162,6 +163,10 @@ export class QueryEditorRow extends PureComponent, queriedDataSourceIdentifier: interpolatedUID, diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index e63e7ed127f..889a5632c79 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -835,6 +835,22 @@ export class LokiDatasource getQueryHints(query: LokiQuery, result: DataFrame[]): QueryHint[] { return getQueryHints(query.expr, result); } + + getDefaultQuery(app: CoreApp): LokiQuery { + const defaults = { refId: 'A', expr: '' }; + + if (app === CoreApp.UnifiedAlerting) { + return { + ...defaults, + queryType: LokiQueryType.Instant, + }; + } + + return { + ...defaults, + queryType: LokiQueryType.Range, + }; + } } // NOTE: these two functions are very similar to the escapeLabelValueIn* functions diff --git a/public/app/plugins/datasource/prometheus/datasource.tsx b/public/app/plugins/datasource/prometheus/datasource.tsx index 4b0c4937188..8a2403906e1 100644 --- a/public/app/plugins/datasource/prometheus/datasource.tsx +++ b/public/app/plugins/datasource/prometheus/datasource.tsx @@ -1263,6 +1263,33 @@ export class PrometheusDatasource getCacheDurationInMinutes(): number { return getClientCacheDurationInMinutes(this.cacheLevel); } + + getDefaultQuery(app: CoreApp): PromQuery { + const defaults = { + refId: 'A', + expr: '', + range: true, + instant: false, + }; + + if (app === CoreApp.UnifiedAlerting) { + return { + ...defaults, + instant: true, + range: false, + }; + } + + if (app === CoreApp.Explore) { + return { + ...defaults, + instant: true, + range: true, + }; + } + + return defaults; + } } /**