Merge branch 'master' into hugoh/library-panel-api

This commit is contained in:
Hugo Häggmark
2020-12-07 07:35:27 +01:00
156 changed files with 5730 additions and 3057 deletions
-5
View File
@@ -949,8 +949,6 @@ steps:
- name: release-npm-packages
image: grafana/build-container:1.3.0
commands:
- ./node_modules/.bin/lerna bootstrap
- echo "//registry.npmjs.org/:_authToken=$${NPM_TOKEN}" >> ~/.npmrc
- ./scripts/build/release-packages.sh ${DRONE_TAG}
environment:
NPM_TOKEN:
@@ -1730,9 +1728,6 @@ steps:
- name: release-npm-packages
image: grafana/build-container:1.3.0
commands:
- ./node_modules/.bin/lerna bootstrap
- echo "//registry.npmjs.org/:_authToken=$${NPM_TOKEN}" >> ~/.npmrc
environment:
NPM_TOKEN:
from_secret: npm_token
+2 -2
View File
@@ -1,9 +1,9 @@
+++
title = "Developers"
aliases = ["/docs/plugins/developing/"]
aliases = ["/docs/grafana/latest/plugins/developing/"]
weight = 190
+++
# Developers
This section of the documentation contains pages with resources for Grafana developers.
This section of the documentation contains pages with resources for Grafana developers.
+5 -4
View File
@@ -68,7 +68,8 @@ JSON Body schema:
"deleteKey":"XXXXXXX",
"deleteUrl":"myurl/api/snapshots-delete/XXXXXXX",
"key":"YYYYYYY",
"url":"myurl/dashboard/snapshot/YYYYYYY"
"url":"myurl/dashboard/snapshot/YYYYYYY",
"id": 1,
}
```
@@ -192,7 +193,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
HTTP/1.1 200
Content-Type: application/json
{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches."}
{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", "id": 1}
```
## Delete Snapshot by deleteKey
@@ -214,5 +215,5 @@ Accept: application/json
HTTP/1.1 200
Content-Type: application/json
{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches."}
```
{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", "id": 1}
```
@@ -16,6 +16,7 @@ Grafana comes with the following transformations:
- [Series to rows](#series-to-rows)
- [Add field from calculation](#add-field-from-calculation)
- [Labels to fields](#labels-to-fields)
- [Concatenate fields](#concatenate-fields)
- [Group by](#group-by)
- [Merge](#merge)
@@ -23,19 +24,55 @@ Keep reading for detailed descriptions of each type of transformation and the op
## Reduce
Apply a _Reduce_ transformation when you want to simplify your results down to one value. Reduce removes the time component. If visualized as a table, it reduces a column down to one row (value).
The _Reduce_ transformation will apply a calculation to each field in the frame and return a single value. Time fields are removed when applying
this transformation.
In the **Calculations** field, enter one or more calculation types. Click to see a list of calculation choices. For information about available calculations, refer to the [Calculation list]({{< relref "../calculations-list.md" >}}).
Consider the input:
Once you select at least one calculation, Grafana reduces the results down to one value using the calculation you select. If you select more than one calculation, then more than one value is displayed.
Query A:
Here's an example of a table with time series data. Before I apply the transformation, you can see all the data organized by time.
| Time | Temp | Uptime |
| ------------------- | ------- | ------- |
| 2020-07-07 11:34:20 | 12.3 | 256122 |
| 2020-07-07 11:24:20 | 15.4 | 1230233 |
{{< docs-imagebox img="/img/docs/transformations/reduce-before-7-0.png" class="docs-image--no-shadow" max-width= "1100px" >}}
Query B:
| Time | AQI | Errors |
| ------------------- | ------- | ------ |
| 2020-07-07 11:34:20 | 6.5 | 15 |
| 2020-07-07 11:24:20 | 3.2 | 5 |
The reduce transformer has two modes:
- **Series to rows -** Creates a row for each field and a column for each calculation.
- **Reduce fields -** Keeps the existing frame structure, but collapses each field into a single value.
For example, if you used the **First** and **Last** calculation with a **Series to rows** transformation, then
the result would be:
| Field | First | Last |
| ------- | ------- | ------- |
| Temp | 12.3 | 15.4 |
| Uptime | 256122 | 1230233 |
| AQI | 6.5 | 3.2 |
| Errors | 15 | 5 |
The **Reduce fields** with the **Last** calculation,
results in two frames, each with one row:
Query A:
| Temp | Uptime |
| ------- | ------- |
| 15.4 | 1230233 |
Query B:
| AQI | Errors |
| ------- | ------ |
| 3.2 | 5 |
After I apply the transformation, there is no time value and each column has been reduced to one row showing the results of the calculations that I chose.
{{< docs-imagebox img="/img/docs/transformations/reduce-after-7-0.png" class="docs-image--no-shadow" max-width= "1100px" >}}
## Merge
@@ -242,6 +279,31 @@ We would then get :
This transformation allows you to extract some key information out of your time series and display them in a convenient way.
## Concatenate fields
> **Note:** This transformation is only available in Grafana 7.3+.
This transformation combines all fields from all frames into one result. Consider:
Query A:
| Temp | Uptime |
| ------- | ------- |
| 15.4 | 1230233 |
Query B:
| AQI | Errors |
| ------- | ------ |
| 3.2 | 5 |
After you concatenate the fields, the data frame would be:
| Temp | Uptime | AQI | Errors |
| ------- | ------- | ------- | ------ |
| 15.4 | 1230233 | 3.2 | 5 |
## Series to rows
> **Note:** This transformation is only available in Grafana 7.1+.
+1 -1
View File
@@ -1,6 +1,6 @@
+++
title = "Install plugins"
aliases = ["/docs/plugins/installation/"]
aliases = ["/docs/grafana/latest/plugins/installation/"]
weight = 1
+++
+1 -1
View File
@@ -1,7 +1,7 @@
+++
title = "Plugin signatures"
type = "docs"
aliases = ["/docs/plugins/plugin-signature-verification"]
aliases = ["/docs/grafana/latest/plugins/plugin-signature-verification"]
+++
# Plugin signatures
@@ -46,10 +46,9 @@ describe('Variables - Add variable', () => {
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect()
.should('be.visible')
.within(select => {
e2e.components.Select.singleValue().should('have.text', '');
e2e.components.Select.singleValue().should('have.text', 'gdev-testdata');
});
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput().should('not.exist');
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRefreshSelect()
.should('be.visible')
.within(select => {
@@ -567,6 +567,7 @@ export interface DataSourceInstanceSettings<T extends DataSourceJsonData = DataS
username?: string;
password?: string; // when access is direct, for some legacy datasources
database?: string;
isDefault?: boolean;
/**
* This is the full Authorization header if basic auth is enabled.
@@ -582,7 +583,6 @@ export interface DataSourceSelectItem {
name: string;
value: string | null;
meta: DataSourcePluginMeta;
sort: string;
}
/**
@@ -18,6 +18,17 @@ export interface DisplayValue extends FormattedValue {
title?: string;
}
/**
* Explicit control for text settings
*/
export interface TextDisplayOptions {
/* Explicit text size */
titleSize?: number;
/* Explicit text size */
valueSize?: number;
}
/**
* These represents the display value with the longest title and text.
* Used to align widths and heights when displaying multiple DisplayValues
@@ -1,5 +1,3 @@
import { Pages } from './pages';
export const Components = {
DataSource: {
TestData: {
@@ -57,8 +55,8 @@ export const Components = {
},
OptionsPane: {
content: 'Panel editor option pane content',
close: Pages.Dashboard.Toolbar.toolbarItems('Close options pane'),
open: Pages.Dashboard.Toolbar.toolbarItems('Open options pane'),
close: 'Dashboard navigation bar button Close options pane',
open: 'Dashboard navigation bar button Open options pane',
select: 'Panel editor option pane select',
tab: (title: string) => `Panel editor option pane tab ${title}`,
},
@@ -1,3 +1,5 @@
import { Components } from './components';
export const Pages = {
Login: {
url: '/login',
@@ -87,7 +89,7 @@ export const Pages = {
submitButton: 'Variable editor Submit button',
},
QueryVariable: {
queryOptionsDataSourceSelect: 'Variable editor Form Query DataSource select',
queryOptionsDataSourceSelect: Components.DataSourcePicker.container,
queryOptionsRefreshSelect: 'Variable editor Form Query Refresh select',
queryOptionsRegExInput: 'Variable editor Form Query RegEx field',
queryOptionsSortSelect: 'Variable editor Form Query Sort select',
@@ -16,14 +16,9 @@ export interface DataSourceSrv {
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi>;
/**
* Get all data sources
* Get a list of data sources
*/
getAll(): DataSourceInstanceSettings[];
/**
* Get all data sources except for internal ones that usually should not be listed like mixed data source.
*/
getExternal(): DataSourceInstanceSettings[];
getList(filters?: GetDataSourceListFilters): DataSourceInstanceSettings[];
/**
* Get settings and plugin metadata by name or uid
@@ -31,6 +26,17 @@ export interface DataSourceSrv {
getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined;
}
/** @public */
export interface GetDataSourceListFilters {
mixed?: boolean;
metrics?: boolean;
tracing?: boolean;
annotations?: boolean;
dashboard?: boolean;
variables?: boolean;
pluginId?: string;
}
let singletonInstance: DataSourceSrv;
/**
+1 -1
View File
@@ -71,7 +71,7 @@
"react-transition-group": "4.4.1",
"slate": "0.47.8",
"tinycolor2": "1.4.1",
"uplot": "1.4.6"
"uplot": "1.4.7"
},
"devDependencies": {
"@rollup/plugin-commonjs": "16.0.0",
@@ -14,6 +14,7 @@ import {
getFieldColorMode,
getColorForTheme,
FALLBACK_COLOR,
TextDisplayOptions,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -42,6 +43,7 @@ export interface Props extends Themeable {
display?: DisplayProcessor;
value: DisplayValue;
orientation: VizOrientation;
text?: TextDisplayOptions;
itemSpacing?: number;
lcdCellWidth?: number;
displayMode: BarGaugeDisplayMode;
@@ -172,7 +174,7 @@ export class BarGauge extends PureComponent<Props> {
}
renderRetroBars(): ReactNode {
const { field, value, itemSpacing, alignmentFactors, orientation, lcdCellWidth } = this.props;
const { field, value, itemSpacing, alignmentFactors, orientation, lcdCellWidth, text } = this.props;
const {
valueHeight,
valueWidth,
@@ -193,7 +195,7 @@ export class BarGauge extends PureComponent<Props> {
const valueColor = getValueColor(this.props);
const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value;
const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation);
const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text);
const containerStyles: CSSProperties = {
width: `${wrapperWidth}px`,
@@ -270,7 +272,7 @@ function isVertical(orientation: VizOrientation) {
}
function calculateTitleDimensions(props: Props): TitleDimensions {
const { height, width, alignmentFactors, orientation } = props;
const { height, width, alignmentFactors, orientation, text } = props;
const title = alignmentFactors ? alignmentFactors.title : props.value.title;
if (!title) {
@@ -278,16 +280,26 @@ function calculateTitleDimensions(props: Props): TitleDimensions {
}
if (isVertical(orientation)) {
const fontSize = text?.titleSize ?? 14;
return {
fontSize: 14,
fontSize: fontSize,
width: width,
height: 14 * TITLE_LINE_HEIGHT,
height: fontSize * TITLE_LINE_HEIGHT,
placement: 'below',
};
}
// if height above 40 put text to above bar
if (height > 40) {
if (text?.titleSize) {
return {
fontSize: text?.titleSize,
width: 0,
height: text.titleSize * TITLE_LINE_HEIGHT,
placement: 'above',
};
}
const maxTitleHeightRatio = 0.45;
const titleHeight = Math.max(Math.min(height * maxTitleHeightRatio, MAX_VALUE_HEIGHT), 17);
@@ -306,7 +318,7 @@ function calculateTitleDimensions(props: Props): TitleDimensions {
const textSize = measureText(title, titleFontSize);
return {
fontSize: titleFontSize,
fontSize: text?.titleSize ?? titleFontSize,
height: 0,
width: textSize.width + 15,
placement: 'left',
@@ -370,7 +382,7 @@ interface BarAndValueDimensions {
}
function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
const { height, width, orientation } = props;
const { height, width, orientation, text } = props;
const titleDim = calculateTitleDimensions(props);
let maxBarHeight = 0;
@@ -381,14 +393,23 @@ function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
let wrapperHeight = 0;
if (isVertical(orientation)) {
valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT);
if (text?.valueSize) {
valueHeight = text.valueSize * VALUE_LINE_HEIGHT;
} else {
valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT);
}
valueWidth = width;
maxBarHeight = height - (titleDim.height + valueHeight);
maxBarWidth = width;
wrapperWidth = width;
wrapperHeight = height - titleDim.height;
} else {
valueHeight = height - titleDim.height;
if (text?.valueSize) {
valueHeight = text.valueSize * VALUE_LINE_HEIGHT;
} else {
valueHeight = height - titleDim.height;
}
valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), MIN_VALUE_WIDTH);
maxBarHeight = height - titleDim.height;
maxBarWidth = width - valueWidth - titleDim.width;
@@ -420,14 +441,14 @@ export function getValuePercent(value: number, minValue: number, maxValue: numbe
* Only exported to for unit test
*/
export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles {
const { displayMode, field, value, alignmentFactors, orientation, theme } = props;
const { displayMode, field, value, alignmentFactors, orientation, theme, text } = props;
const { valueWidth, valueHeight, maxBarHeight, maxBarWidth } = calculateBarAndValueDimensions(props);
const valuePercent = getValuePercent(value.numeric, field.min!, field.max!);
const valueColor = getValueColor(props);
const valueToBaseSizeOn = alignmentFactors ? alignmentFactors : value;
const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation);
const valueStyles = getValueStyles(valueToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation, text);
const isBasic = displayMode === 'basic';
const wrapperStyles: CSSProperties = {
@@ -581,7 +602,8 @@ function getValueStyles(
color: string,
width: number,
height: number,
orientation: VizOrientation
orientation: VizOrientation,
text?: TextDisplayOptions
): CSSProperties {
const styles: CSSProperties = {
color,
@@ -597,15 +619,12 @@ function getValueStyles(
const formattedValueString = formattedValueToString(value);
if (isVertical(orientation)) {
styles.fontSize = calculateFontSize(formattedValueString, textWidth, height, VALUE_LINE_HEIGHT);
styles.fontSize = text?.valueSize ?? calculateFontSize(formattedValueString, textWidth, height, VALUE_LINE_HEIGHT);
styles.justifyContent = `center`;
} else {
styles.fontSize = calculateFontSize(
formattedValueString,
textWidth - VALUE_LEFT_PADDING * 2,
height,
VALUE_LINE_HEIGHT
);
styles.fontSize =
text?.valueSize ??
calculateFontSize(formattedValueString, textWidth - VALUE_LEFT_PADDING * 2, height, VALUE_LINE_HEIGHT);
styles.justifyContent = `flex-end`;
styles.paddingLeft = `${VALUE_LEFT_PADDING}px`;
styles.paddingRight = `${VALUE_LEFT_PADDING}px`;
@@ -1,6 +1,6 @@
// Library
import React, { PureComponent } from 'react';
import { DisplayValue, GraphSeriesValue, DisplayValueAlignmentFactors } from '@grafana/data';
import { DisplayValue, GraphSeriesValue, DisplayValueAlignmentFactors, TextDisplayOptions } from '@grafana/data';
// Types
import { Themeable } from '../../types';
@@ -64,6 +64,8 @@ export interface Props extends Themeable {
justifyMode?: BigValueJustifyMode;
/** Factors that should influence the positioning of the text */
alignmentFactors?: DisplayValueAlignmentFactors;
/** Explicit font size control */
text?: TextDisplayOptions;
/** Specify which text should be visible in the BigValue */
textMode?: BigValueTextMode;
@@ -29,7 +29,7 @@ export abstract class BigValueLayout {
textValues: BigValueTextValues;
constructor(private props: Props) {
const { width, height, value, theme } = props;
const { width, height, value, theme, text } = props;
this.valueColor = getColorForTheme(value.color || 'green', theme);
this.panelPadding = height > 100 ? 12 : 8;
@@ -43,6 +43,18 @@ export abstract class BigValueLayout {
this.chartWidth = 0;
this.maxTextWidth = width - this.panelPadding * 2;
this.maxTextHeight = height - this.panelPadding * 2;
// Explicit font sizing
if (text) {
if (text.titleSize) {
this.titleFontSize = text.titleSize;
this.titleToAlignTo = undefined;
}
if (text.valueSize) {
this.valueFontSize = text.valueSize;
this.valueToAlignTo = '';
}
}
}
getTitleStyles(): CSSProperties {
@@ -235,9 +247,9 @@ export class WideNoChartLayout extends BigValueLayout {
constructor(props: Props) {
super(props);
const valueWidthPercent = 0.3;
const valueWidthPercent = this.titleToAlignTo?.length ? 0.3 : 1.0;
if (this.titleToAlignTo && this.titleToAlignTo.length > 0) {
if (this.valueToAlignTo.length) {
// initial value size
this.valueFontSize = calculateFontSize(
this.valueToAlignTo,
@@ -245,7 +257,9 @@ export class WideNoChartLayout extends BigValueLayout {
this.maxTextHeight,
LINE_HEIGHT
);
}
if (this.titleToAlignTo?.length) {
// How big can we make the title and still have it fit
this.titleFontSize = calculateFontSize(
this.titleToAlignTo,
@@ -257,9 +271,6 @@ export class WideNoChartLayout extends BigValueLayout {
// make sure it's a bit smaller than valueFontSize
this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize);
} else {
// if no title wide
this.valueFontSize = calculateFontSize(this.valueToAlignTo, this.maxTextWidth, this.maxTextHeight, LINE_HEIGHT);
}
}
@@ -292,6 +303,7 @@ export class WideWithChartLayout extends BigValueLayout {
super(props);
const { width, height } = props;
const chartHeightPercent = 0.5;
const titleWidthPercent = 0.6;
const valueWidthPercent = 1 - titleWidthPercent;
@@ -300,7 +312,7 @@ export class WideWithChartLayout extends BigValueLayout {
this.chartWidth = width;
this.chartHeight = height * chartHeightPercent;
if (this.titleToAlignTo && this.titleToAlignTo.length > 0) {
if (this.titleToAlignTo?.length) {
this.titleFontSize = calculateFontSize(
this.titleToAlignTo,
this.maxTextWidth * titleWidthPercent,
@@ -310,12 +322,14 @@ export class WideWithChartLayout extends BigValueLayout {
);
}
this.valueFontSize = calculateFontSize(
this.valueToAlignTo,
this.maxTextWidth * valueWidthPercent,
this.maxTextHeight * chartHeightPercent,
LINE_HEIGHT
);
if (this.valueToAlignTo.length) {
this.valueFontSize = calculateFontSize(
this.valueToAlignTo,
this.maxTextWidth * valueWidthPercent,
this.maxTextHeight * chartHeightPercent,
LINE_HEIGHT
);
}
}
getValueAndTitleContainerStyles() {
@@ -350,7 +364,7 @@ export class StackedWithChartLayout extends BigValueLayout {
this.chartHeight = height * chartHeightPercent;
this.chartWidth = width;
if (this.titleToAlignTo && this.titleToAlignTo.length > 0) {
if (this.titleToAlignTo?.length) {
this.titleFontSize = calculateFontSize(
this.titleToAlignTo,
this.maxTextWidth,
@@ -358,19 +372,22 @@ export class StackedWithChartLayout extends BigValueLayout {
LINE_HEIGHT,
MAX_TITLE_SIZE
);
}
titleHeight = this.titleFontSize * LINE_HEIGHT;
titleHeight = this.titleFontSize * LINE_HEIGHT;
if (this.valueToAlignTo.length) {
this.valueFontSize = calculateFontSize(
this.valueToAlignTo,
this.maxTextWidth,
this.maxTextHeight - this.chartHeight - titleHeight,
LINE_HEIGHT
);
}
this.valueFontSize = calculateFontSize(
this.valueToAlignTo,
this.maxTextWidth,
this.maxTextHeight - this.chartHeight - titleHeight,
LINE_HEIGHT
);
// make title fontsize it's a bit smaller than valueFontSize
this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize);
if (this.titleToAlignTo?.length) {
this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize);
}
// make chart take up unused space
this.chartHeight = height - this.titleFontSize * LINE_HEIGHT - this.valueFontSize * LINE_HEIGHT;
@@ -398,7 +415,7 @@ export class StackedWithNoChartLayout extends BigValueLayout {
const titleHeightPercent = 0.15;
let titleHeight = 0;
if (this.titleToAlignTo && this.titleToAlignTo.length > 0) {
if (this.titleToAlignTo?.length) {
this.titleFontSize = calculateFontSize(
this.titleToAlignTo,
this.maxTextWidth,
@@ -410,12 +427,14 @@ export class StackedWithNoChartLayout extends BigValueLayout {
titleHeight = this.titleFontSize * LINE_HEIGHT;
}
this.valueFontSize = calculateFontSize(
this.valueToAlignTo,
this.maxTextWidth,
this.maxTextHeight - titleHeight,
LINE_HEIGHT
);
if (this.valueToAlignTo.length) {
this.valueFontSize = calculateFontSize(
this.valueToAlignTo,
this.maxTextWidth,
this.maxTextHeight - titleHeight,
LINE_HEIGHT
);
}
// make title fontsize it's a bit smaller than valueFontSize
this.titleFontSize = Math.min(this.valueFontSize * 0.7, this.titleFontSize);
@@ -20,17 +20,13 @@ export default {
docs: {
page: mdx,
},
knobs: {
disabled: true,
},
},
};
export const Simple: Story<ButtonProps> = ({ disabled, icon, children, size, variant }) => {
return (
<Button variant={variant} size={size} icon={icon} disabled={disabled}>
{children}
</Button>
);
};
export const Simple: Story<ButtonProps> = ({ children, ...args }) => <Button {...args}>{children}</Button>;
Simple.args = {
variant: 'primary',
size: 'md',
@@ -1,62 +1,62 @@
import React, { useState, useCallback } from 'react';
import { boolean, number, text } from '@storybook/addon-knobs';
import { Field, Input, Switch } from '@grafana/ui';
import { Story } from '@storybook/react';
import { Field, FieldProps } from './Field';
import { Input, Switch } from '..';
import mdx from './Field.mdx';
export default {
title: 'Forms/Field',
component: Field,
argTypes: {
children: { control: { disable: true } },
className: { control: { disable: true } },
},
parameters: {
docs: {
page: mdx,
},
knobs: {
disabled: true,
},
},
};
const getKnobs = () => {
const CONTAINER_GROUP = 'Container options';
// ---
const containerWidth = number(
'Container width',
300,
{
range: true,
min: 100,
max: 500,
step: 10,
},
CONTAINER_GROUP
);
export const Simple: Story<FieldProps> = args => (
<div>
<Field {...args}>
<Input id="thisField" />
</Field>
</div>
);
const BEHAVIOUR_GROUP = 'Behaviour props';
const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP);
const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP);
const loading = boolean('Loading', false, BEHAVIOUR_GROUP);
const error = text('Error message', '', BEHAVIOUR_GROUP);
return { containerWidth, disabled, invalid, loading, error };
Simple.args = {
label: 'Graphite API key',
description: 'Your Graphite instance API key',
disabled: false,
invalid: false,
loading: false,
error: 'Not valid input',
horizontal: false,
};
export const Simple = () => {
const { containerWidth, ...otherProps } = getKnobs();
return (
<div style={{ width: containerWidth }}>
<Field label="Graphite API key" description="Your Graphite instance API key" {...otherProps}>
<Input id="thisField" />
</Field>
</div>
);
};
export const HorizontalLayout = () => {
export const HorizontalLayout: Story<FieldProps> = args => {
const [checked, setChecked] = useState(false);
const onChange = useCallback(e => setChecked(e.currentTarget.checked), [setChecked]);
const { containerWidth, ...otherProps } = getKnobs();
return (
<div style={{ width: containerWidth }}>
<Field horizontal label="Show labels" description="Display thresholds's labels" {...otherProps}>
<div>
<Field {...args}>
<Switch checked={checked} onChange={onChange} />
</Field>
</div>
);
};
HorizontalLayout.args = {
label: 'Show labels',
description: 'Display threshold labels',
disabled: false,
invalid: false,
loading: false,
error: 'Not valid input',
horizontal: true,
};
@@ -10,6 +10,7 @@ import {
getColorForTheme,
FieldColorModeId,
FALLBACK_COLOR,
TextDisplayOptions,
} from '@grafana/data';
import { Themeable } from '../../types';
import { calculateFontSize } from '../../utils/measureText';
@@ -21,6 +22,7 @@ export interface Props extends Themeable {
showThresholdLabels: boolean;
width: number;
value: DisplayValue;
text?: TextDisplayOptions;
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
}
@@ -108,7 +110,7 @@ export class Gauge extends PureComponent<Props> {
// remove gauge & marker width (on left and right side)
// and 10px is some padding that flot adds to the outer canvas
const valueWidth = valueWidthBase - ((gaugeWidth + (showThresholdMarkers ? thresholdMarkersWidth : 0)) * 2 + 10);
const fontSize = calculateFontSize(text, valueWidth, dimension, 1, gaugeWidth * 1.7);
const fontSize = this.props.text?.valueSize ?? calculateFontSize(text, valueWidth, dimension, 1, gaugeWidth * 1.7);
const thresholdLabelFontSize = fontSize / 2.5;
let min = field.min!;
@@ -180,7 +182,7 @@ export class Gauge extends PureComponent<Props> {
}
renderVisualization = () => {
const { width, value, height, onClick } = this.props;
const { width, value, height, onClick, text } = this.props;
const autoProps = calculateGaugeAutoProps(width, height, value.title);
return (
@@ -194,7 +196,7 @@ export class Gauge extends PureComponent<Props> {
<div
style={{
textAlign: 'center',
fontSize: autoProps.titleFontSize,
fontSize: text?.titleSize ?? autoProps.titleFontSize,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -12,7 +12,7 @@ import {
import { alignDataFrames } from './utils';
import { UPlotChart } from '../uPlot/Plot';
import { PlotProps } from '../uPlot/types';
import { AxisPlacement, GraphFieldConfig, GraphMode, PointMode } from '../uPlot/config';
import { AxisPlacement, GraphFieldConfig, DrawStyle, PointMode } from '../uPlot/config';
import { useTheme } from '../../themes';
import { VizLayout } from '../VizLayout/VizLayout';
import { LegendDisplayMode, LegendItem, LegendOptions } from '../Legend/Legend';
@@ -34,7 +34,7 @@ export interface GraphNGProps extends Omit<PlotProps, 'data' | 'config'> {
}
const defaultConfig: GraphFieldConfig = {
mode: GraphMode.Line,
drawStyle: DrawStyle.Line,
points: PointMode.Auto,
axisPlacement: AxisPlacement.Auto,
};
@@ -134,11 +134,11 @@ export const GraphNG: React.FC<GraphNGProps> = ({
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
const pointsMode = customConfig.mode === GraphMode.Points ? PointMode.Always : customConfig.points;
const pointsMode = customConfig.drawStyle === DrawStyle.Points ? PointMode.Always : customConfig.points;
builder.addSeries({
scaleKey,
mode: customConfig.mode!,
drawStyle: customConfig.drawStyle!,
lineColor: seriesColor,
lineWidth: customConfig.lineWidth,
lineInterpolation: customConfig.lineInterpolation,
@@ -147,6 +147,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
pointColor: seriesColor,
fillOpacity: customConfig.fillOpacity,
fillColor: seriesColor,
spanNulls: customConfig.spanNulls || false,
});
if (hasLegend.current) {
@@ -43,6 +43,7 @@ export function mapDimesions(match: XYFieldMatchers, frame: DataFrame, frames?:
export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): AlignedFrameWithGapTest | null {
const valuesFromFrames: AlignedData[] = [];
const sourceFields: Field[] = [];
const skipGaps: boolean[][] = [];
// Default to timeseries config
if (!fields) {
@@ -54,6 +55,7 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
for (const frame of frames) {
const dims = mapDimesions(fields, frame, frames);
if (!(dims.x.length && dims.y.length)) {
continue; // both x and y matched something!
}
@@ -62,9 +64,12 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
throw new Error('Only a single x field is supported');
}
let skipGapsFrame: boolean[] = [];
// Add the first X axis
if (!sourceFields.length) {
sourceFields.push(dims.x[0]);
skipGapsFrame.push(true);
}
const alignedData: AlignedData = [
@@ -74,10 +79,15 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
// Add the Y values
for (const field of dims.y) {
let values = field.values.toArray();
let spanNulls = field.config.custom.spanNulls || false;
if (field.config.nullValueMode === NullValueMode.AsZero) {
values = values.map(v => (v === null ? 0 : v));
spanNulls = true;
}
alignedData.push(values);
skipGapsFrame.push(spanNulls);
// This will cache an appropriate field name in the field state
getFieldDisplayName(field, frame, frames);
@@ -85,6 +95,7 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
}
valuesFromFrames.push(alignedData);
skipGaps.push(skipGapsFrame);
}
if (valuesFromFrames.length === 0) {
@@ -92,7 +103,7 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
}
// do the actual alignment (outerJoin on the first arrays)
const { data: alignedData, isGap } = outerJoinValues(valuesFromFrames);
let { data: alignedData, isGap } = outerJoinValues(valuesFromFrames, skipGaps);
if (alignedData!.length !== sourceFields.length) {
throw new Error('outerJoinValues lost a field?');
@@ -111,18 +122,20 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
};
}
export function outerJoinValues(tables: AlignedData[]): AlignedDataWithGapTest {
// skipGaps is a tables-matched bool array indicating which series can skip storing indices of original nulls
export function outerJoinValues(tables: AlignedData[], skipGaps?: boolean[][]): AlignedDataWithGapTest {
if (tables.length === 1) {
return {
data: tables[0],
isGap: () => true,
isGap: skipGaps ? (u: uPlot, seriesIdx: number, dataIdx: number) => !skipGaps[0][seriesIdx] : () => true,
};
}
let xVals: Set<number> = new Set();
let xNulls: Array<Set<number>> = [new Set()];
for (const t of tables) {
for (let ti = 0; ti < tables.length; ti++) {
let t = tables[ti];
let xs = t[0];
let len = xs.length;
let nulls: Set<number> = new Set();
@@ -132,11 +145,13 @@ export function outerJoinValues(tables: AlignedData[]): AlignedDataWithGapTest {
}
for (let j = 1; j < t.length; j++) {
let ys = t[j];
if (skipGaps == null || !skipGaps[ti][j]) {
let ys = t[j];
for (let i = 0; i < len; i++) {
if (ys[i] == null) {
nulls.add(xs[i]);
for (let i = 0; i < len; i++) {
if (ys[i] == null) {
nulls.add(xs[i]);
}
}
}
}
@@ -117,9 +117,11 @@ const getStyles = stylesFactory(
? 0
: `-${finalSpacing}`;
const label = orientation === Orientation.Vertical ? 'vertical-group' : 'horizontal-group';
return {
layout: css`
label: HorizontalGroup;
label: ${label};
display: flex;
flex-direction: ${orientation === Orientation.Vertical ? 'column' : 'row'};
flex-wrap: ${wrap ? 'wrap' : 'nowrap'};
@@ -14,7 +14,6 @@ export const useExpandableLabel = (
const Label: React.FC<LabelProps> = ({ Component, onClick }) => (
<div
className="gf-form"
ref={ref}
onClick={() => {
setExpanded(true);
@@ -59,14 +59,7 @@ export const SingleValue = (props: Props) => {
return (
<components.SingleValue {...props}>
<div
className={cx(
styles.singleValue,
css`
overflow: hidden;
`
)}
>
<div className={cx(styles.singleValue)}>
{data.imgUrl ? (
<FadeWithImage loading={loading} imgUrl={data.imgUrl} />
) : (
@@ -16,11 +16,13 @@ import {
ThresholdsConfig,
validateFieldConfig,
FieldColorModeId,
TextDisplayOptions,
} from '@grafana/data';
export interface SingleStatBaseOptions {
reduceOptions: ReduceDataOptions;
orientation: VizOrientation;
text?: TextDisplayOptions;
}
const optionsToKeep = ['reduceOptions', 'orientation'];
@@ -2,7 +2,7 @@ import React from 'react';
import { UPlotChart } from './Plot';
import { act, render } from '@testing-library/react';
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
import { GraphFieldConfig, GraphMode } from '../uPlot/config';
import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
import uPlot from 'uplot';
import createMockRaf from 'mock-raf';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
@@ -40,7 +40,7 @@ const mockData = () => {
values: new ArrayVector([10, 20, 5]),
config: {
custom: {
mode: GraphMode.Line,
drawStyle: DrawStyle.Line,
},
} as FieldConfig<GraphFieldConfig>,
});
@@ -15,7 +15,7 @@ export enum PointMode {
Always = 'always',
}
export enum GraphMode {
export enum DrawStyle {
Line = 'line', // default
Bars = 'bars', // will also have a gap percent
Points = 'points', // Only show points
@@ -23,14 +23,16 @@ export enum GraphMode {
export enum LineInterpolation {
Linear = 'linear',
Staircase = 'staircase', // https://leeoniya.github.io/uPlot/demos/line-stepped.html
Smooth = 'smooth', // https://leeoniya.github.io/uPlot/demos/line-smoothing.html
Smooth = 'smooth',
StepBefore = 'stepBefore',
StepAfter = 'stepAfter',
}
export interface LineConfig {
lineColor?: string;
lineWidth?: number;
lineInterpolation?: LineInterpolation;
spanNulls?: boolean;
}
export interface AreaConfig {
@@ -53,20 +55,21 @@ export interface AxisConfig {
}
export interface GraphFieldConfig extends LineConfig, AreaConfig, PointsConfig, AxisConfig {
mode?: GraphMode;
drawStyle?: DrawStyle;
}
export const graphFieldOptions = {
mode: [
{ label: 'Lines', value: GraphMode.Line },
{ label: 'Bars', value: GraphMode.Bars },
{ label: 'Points', value: GraphMode.Points },
] as Array<SelectableValue<GraphMode>>,
drawStyle: [
{ label: 'Lines', value: DrawStyle.Line },
{ label: 'Bars', value: DrawStyle.Bars },
{ label: 'Points', value: DrawStyle.Points },
] as Array<SelectableValue<DrawStyle>>,
lineInterpolation: [
{ label: 'Linear', value: LineInterpolation.Linear },
{ label: 'Staircase', value: LineInterpolation.Staircase },
{ label: 'Smooth', value: LineInterpolation.Smooth },
{ label: 'Step Before', value: LineInterpolation.StepBefore },
{ label: 'Step After', value: LineInterpolation.StepAfter },
] as Array<SelectableValue<LineInterpolation>>,
points: [
@@ -3,7 +3,7 @@
import { UPlotConfigBuilder } from './UPlotConfigBuilder';
import { GrafanaTheme } from '@grafana/data';
import { expect } from '../../../../../../public/test/lib/common';
import { AxisPlacement, GraphMode, PointMode } from '../config';
import { AxisPlacement, DrawStyle, PointMode } from '../config';
describe('UPlotConfigBuilder', () => {
describe('scales config', () => {
@@ -121,7 +121,7 @@ describe('UPlotConfigBuilder', () => {
it('allows series configuration', () => {
const builder = new UPlotConfigBuilder();
builder.addSeries({
mode: GraphMode.Line,
drawStyle: DrawStyle.Line,
scaleKey: 'scale-x',
fillColor: '#ff0000',
fillOpacity: 0.5,
@@ -130,6 +130,7 @@ describe('UPlotConfigBuilder', () => {
pointColor: '#00ff00',
lineColor: '#0000ff',
lineWidth: 1,
spanNulls: false,
});
expect(builder.getConfig()).toMatchInlineSnapshot(`
@@ -147,6 +148,7 @@ describe('UPlotConfigBuilder', () => {
"stroke": "#00ff00",
},
"scale": "scale-x",
"spanGaps": false,
"stroke": "#0000ff",
"width": 1,
},
@@ -1,18 +1,18 @@
import tinycolor from 'tinycolor2';
import uPlot, { Series } from 'uplot';
import { GraphMode, LineConfig, AreaConfig, PointsConfig, PointMode, LineInterpolation } from '../config';
import { barsBuilder, smoothBuilder, staircaseBuilder } from '../paths';
import { DrawStyle, LineConfig, AreaConfig, PointsConfig, PointMode, LineInterpolation } from '../config';
import { barsBuilder, smoothBuilder, stepBeforeBuilder, stepAfterBuilder } from '../paths';
import { PlotConfigBuilder } from '../types';
export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig {
mode: GraphMode;
drawStyle: DrawStyle;
scaleKey: string;
}
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
getConfig() {
const {
mode,
drawStyle,
lineInterpolation,
lineColor,
lineWidth,
@@ -22,29 +22,39 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
fillColor,
fillOpacity,
scaleKey,
spanNulls,
} = this.props;
let lineConfig: Partial<Series> = {};
if (mode === GraphMode.Points) {
if (drawStyle === DrawStyle.Points) {
lineConfig.paths = () => null;
} else {
lineConfig.stroke = lineColor;
lineConfig.width = lineWidth;
lineConfig.paths = (self: uPlot, seriesIdx: number, idx0: number, idx1: number) => {
lineConfig.paths = (
self: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
let pathsBuilder = self.paths;
if (mode === GraphMode.Bars) {
if (drawStyle === DrawStyle.Bars) {
pathsBuilder = barsBuilder;
} else if (mode === GraphMode.Line) {
if (lineInterpolation === LineInterpolation.Staircase) {
pathsBuilder = staircaseBuilder;
} else if (drawStyle === DrawStyle.Line) {
if (lineInterpolation === LineInterpolation.StepBefore) {
pathsBuilder = stepBeforeBuilder;
} else if (lineInterpolation === LineInterpolation.StepAfter) {
pathsBuilder = stepAfterBuilder;
} else if (lineInterpolation === LineInterpolation.Smooth) {
pathsBuilder = smoothBuilder;
}
}
return pathsBuilder(self, seriesIdx, idx0, idx1);
return pathsBuilder(self, seriesIdx, idx0, idx1, extendGap, buildClip);
};
}
@@ -58,7 +68,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
// we cannot set points.show property above (even to undefined) as that will clear uPlot's default auto behavior
if (points === PointMode.Auto) {
if (mode === GraphMode.Bars) {
if (drawStyle === DrawStyle.Bars) {
pointsConfig.points!.show = false;
}
} else if (points === PointMode.Never) {
@@ -78,6 +88,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
return {
scale: scaleKey,
spanGaps: spanNulls,
...lineConfig,
...pointsConfig,
...areaConfig,
+153 -40
View File
@@ -1,6 +1,13 @@
import uPlot, { Series } from 'uplot';
export const barsBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => {
export const barsBuilder: Series.PathBuilder = (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
const series = u.series[seriesIdx];
const xdata = u.data[0];
const ydata = u.data[seriesIdx];
@@ -53,47 +60,115 @@ export const barsBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx
};
};
export const staircaseBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => {
const series = u.series[seriesIdx];
const xdata = u.data[0];
const ydata = u.data[seriesIdx];
const scaleX = u.series[0].scale as string;
const scaleY = series.scale as string;
/*
const enum StepSide {
Before,
After,
}
*/
const stroke = new Path2D();
stroke.moveTo(Math.round(u.valToPos(xdata[0], scaleX, true)), Math.round(u.valToPos(ydata[0]!, scaleY, true)));
export const stepBeforeBuilder = stepBuilderFactory(false);
export const stepAfterBuilder = stepBuilderFactory(true);
for (let i = idx0; i <= idx1 - 1; i++) {
let x0 = Math.round(u.valToPos(xdata[i], scaleX, true));
let y0 = Math.round(u.valToPos(ydata[i]!, scaleY, true));
let x1 = Math.round(u.valToPos(xdata[i + 1], scaleX, true));
let y1 = Math.round(u.valToPos(ydata[i + 1]!, scaleY, true));
// babel does not support inlined const enums, so this uses a boolean flag for perf
// possible workaround: https://github.com/dosentmatter/babel-plugin-const-enum
function stepBuilderFactory(after: boolean): Series.PathBuilder {
return (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
const series = u.series[seriesIdx];
const xdata = u.data[0];
const ydata = u.data[seriesIdx];
const scaleX = u.series[0].scale as string;
const scaleY = series.scale as string;
const halfStroke = series.width! / 2;
stroke.lineTo(x0, y0);
stroke.lineTo(x1, y0);
const stroke = new Path2D();
if (i === idx1 - 1) {
stroke.lineTo(x1, y1);
// find first non-null dataPt
while (ydata[idx0] == null) {
idx0++;
}
}
const fill = new Path2D(stroke);
// find last-null dataPt
while (ydata[idx1] == null) {
idx1--;
}
//@ts-ignore
let fillTo = series.fillTo(u, seriesIdx, series.min, series.max);
let gaps: Series.Gaps = [];
let inGap = false;
let prevYPos = Math.round(u.valToPos(ydata[idx0]!, scaleY, true));
let firstXPos = Math.round(u.valToPos(xdata[idx0], scaleX, true));
let prevXPos = firstXPos;
let minY = Math.round(u.valToPos(fillTo, scaleY, true));
let minX = Math.round(u.valToPos(u.scales[scaleX].min!, scaleX, true));
let maxX = Math.round(u.valToPos(u.scales[scaleX].max!, scaleX, true));
stroke.moveTo(firstXPos, prevYPos);
fill.lineTo(maxX, minY);
fill.lineTo(minX, minY);
for (let i = idx0 + 1; i <= idx1; i++) {
let yVal1 = ydata[i];
return {
stroke,
fill,
let x1 = Math.round(u.valToPos(xdata[i], scaleX, true));
if (yVal1 == null) {
//@ts-ignore
if (series.isGap(u, seriesIdx, i)) {
extendGap(gaps, prevXPos, x1);
inGap = true;
}
continue;
}
let y1 = Math.round(u.valToPos(yVal1, scaleY, true));
if (inGap) {
extendGap(gaps, prevXPos, x1);
// don't clip vertical extenders
if (prevYPos !== y1) {
let lastGap = gaps[gaps.length - 1];
lastGap[0] += halfStroke;
lastGap[1] -= halfStroke;
}
inGap = false;
}
if (after) {
stroke.lineTo(x1, prevYPos);
} else {
stroke.lineTo(prevXPos, y1);
}
stroke.lineTo(x1, y1);
prevYPos = y1;
prevXPos = x1;
}
const fill = new Path2D(stroke);
//@ts-ignore
let fillTo = series.fillTo(u, seriesIdx, series.min, series.max);
let minY = Math.round(u.valToPos(fillTo, scaleY, true));
fill.lineTo(prevXPos, minY);
fill.lineTo(firstXPos, minY);
let clip = !series.spanGaps ? buildClip(gaps) : null;
return {
stroke,
fill,
clip,
};
};
};
}
// adapted from https://gist.github.com/nicholaswmin/c2661eb11cad5671d816 (MIT)
/**
@@ -217,26 +292,63 @@ function catmullRomFitting(xCoords: number[], yCoords: number[], alpha: number)
return path;
}
export const smoothBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => {
export const smoothBuilder: Series.PathBuilder = (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
const series = u.series[seriesIdx];
const xdata = u.data[0];
const ydata = u.data[seriesIdx];
const scaleX = u.series[0].scale as string;
const scaleY = series.scale as string;
const alpha = 0.5;
// find first non-null dataPt
while (ydata[idx0] == null) {
idx0++;
}
// find last-null dataPt
while (ydata[idx1] == null) {
idx1--;
}
let gaps: Series.Gaps = [];
let inGap = false;
let firstXPos = Math.round(u.valToPos(xdata[idx0], scaleX, true));
let prevXPos = firstXPos;
let xCoords = [];
let yCoords = [];
for (let i = idx0; i <= idx1; i++) {
if (ydata[i] != null) {
xCoords.push(u.valToPos(xdata[i], scaleX, true));
let yVal = ydata[i];
let xVal = xdata[i];
let xPos = u.valToPos(xVal, scaleX, true);
if (yVal == null) {
//@ts-ignore
if (series.isGap(u, seriesIdx, i)) {
extendGap(gaps, prevXPos + 1, xPos);
inGap = true;
}
continue;
} else {
if (inGap) {
extendGap(gaps, prevXPos + 1, xPos + 1);
inGap = false;
}
xCoords.push((prevXPos = xPos));
yCoords.push(u.valToPos(ydata[i]!, scaleY, true));
}
}
const stroke = catmullRomFitting(xCoords, yCoords, alpha);
const stroke = catmullRomFitting(xCoords, yCoords, 0.5);
const fill = new Path2D(stroke);
@@ -244,14 +356,15 @@ export const smoothBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, i
let fillTo = series.fillTo(u, seriesIdx, series.min, series.max);
let minY = Math.round(u.valToPos(fillTo, scaleY, true));
let minX = Math.round(u.valToPos(u.scales[scaleX].min!, scaleX, true));
let maxX = Math.round(u.valToPos(u.scales[scaleX].max!, scaleX, true));
fill.lineTo(maxX, minY);
fill.lineTo(minX, minY);
fill.lineTo(prevXPos, minY);
fill.lineTo(firstXPos, minY);
let clip = !series.spanGaps ? buildClip(gaps) : null;
return {
stroke,
fill,
clip,
};
};
+9 -2
View File
@@ -132,6 +132,7 @@ func CreateDashboardSnapshot(c *models.ReqContext, cmd models.CreateDashboardSna
"deleteKey": cmd.DeleteKey,
"url": url,
"deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
"id": cmd.Result.Id,
})
}
@@ -223,7 +224,10 @@ func DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) Response {
return Error(500, "Failed to delete dashboard snapshot", err)
}
return JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches."})
return JSON(200, util.DynMap{
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
"id": query.Result.Id,
})
}
// DELETE /api/snapshots/:key
@@ -269,7 +273,10 @@ func DeleteDashboardSnapshot(c *models.ReqContext) Response {
return Error(500, "Failed to delete dashboard snapshot", err)
}
return JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches."})
return JSON(200, util.DynMap{
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
"id": query.Result.Id,
})
}
// GET /api/dashboard/snapshots
+8
View File
@@ -109,6 +109,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
require.NoError(t, err)
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
assert.Equal(t, 1, respJSON.Get("id").MustInt())
assert.Equal(t, http.MethodGet, externalRequest.Method)
assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host))
@@ -141,6 +142,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
require.NoError(t, err)
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
assert.Equal(t, 1, respJSON.Get("id").MustInt())
assert.Equal(t, ts.URL, fmt.Sprintf("http://%s", externalRequest.Host))
assert.Equal(t, "/", externalRequest.URL.EscapedPath())
})
@@ -163,6 +165,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
require.NoError(t, err)
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
assert.Equal(t, 1, respJSON.Get("id").MustInt())
})
})
@@ -186,6 +189,11 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
require.NoError(t, writeErr)
assert.Equal(t, 200, sc.resp.Code)
respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
require.NoError(t, err)
assert.True(t, strings.HasPrefix(respJSON.Get("message").MustString(), "Snapshot deleted"))
assert.Equal(t, 1, respJSON.Get("id").MustInt())
})
loggedInUserScenarioWithRole(t,
-1
View File
@@ -146,7 +146,6 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
if isDefault, _ := dsM["isDefault"].(bool); isDefault {
defaultDS = n
}
delete(dsM, "isDefault")
meta := dsM["meta"].(*plugins.DataSourcePlugin)
if meta.Preload {
+1 -1
View File
@@ -153,7 +153,7 @@ func (e *cloudWatchExecutor) newSession(region string) (*session.Session, error)
}
duration := stscreds.DefaultDuration
expiration := time.Now().Add(duration)
expiration := time.Now().UTC().Add(duration)
if dsInfo.AssumeRoleARN != "" {
// We should assume a role in AWS
plog.Debug("Trying to assume role in AWS", "arn", dsInfo.AssumeRoleARN)
@@ -3,72 +3,119 @@ import React, { PureComponent } from 'react';
// Components
import { HorizontalGroup, Select } from '@grafana/ui';
import { SelectableValue, DataSourceSelectItem } from '@grafana/data';
import { SelectableValue, DataSourceInstanceSettings } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { isUnsignedPluginSignature, PluginSignatureBadge } from '../../../features/plugins/PluginSignatureBadge';
import { getDataSourceSrv } from '@grafana/runtime';
export interface Props {
onChange: (ds: DataSourceSelectItem) => void;
datasources: DataSourceSelectItem[];
current?: DataSourceSelectItem | null;
onChange: (ds: DataSourceInstanceSettings) => void;
current: string | null;
hideTextValue?: boolean;
onBlur?: () => void;
autoFocus?: boolean;
openMenuOnFocus?: boolean;
showLoading?: boolean;
placeholder?: string;
invalid?: boolean;
tracing?: boolean;
mixed?: boolean;
dashboard?: boolean;
metrics?: boolean;
annotations?: boolean;
variables?: boolean;
pluginId?: string;
noDefault?: boolean;
}
export class DataSourcePicker extends PureComponent<Props> {
export interface State {
error?: string;
}
export class DataSourcePicker extends PureComponent<Props, State> {
dataSourceSrv = getDataSourceSrv();
static defaultProps: Partial<Props> = {
autoFocus: false,
openMenuOnFocus: false,
placeholder: 'Select datasource',
};
searchInput: HTMLElement;
state: State = {};
constructor(props: Props) {
super(props);
}
onChange = (item: SelectableValue<string>) => {
const ds = this.props.datasources.find(ds => ds.name === item.value);
componentDidMount() {
const { current } = this.props;
const dsSettings = this.dataSourceSrv.getInstanceSettings(current);
if (!dsSettings) {
this.setState({ error: 'Could not find data source ' + current });
}
}
if (ds) {
this.props.onChange(ds);
onChange = (item: SelectableValue<string>) => {
const dsSettings = this.dataSourceSrv.getInstanceSettings(item.value);
if (dsSettings) {
this.props.onChange(dsSettings);
this.setState({ error: undefined });
}
};
render() {
const {
datasources,
current,
autoFocus,
hideTextValue,
onBlur,
openMenuOnFocus,
showLoading,
placeholder,
invalid,
} = this.props;
private getCurrentValue() {
const { current, hideTextValue, noDefault } = this.props;
const options = datasources.map(ds => ({
value: ds.name,
label: ds.name,
imgUrl: ds.meta.info.logos.small,
meta: ds.meta,
}));
if (!current && noDefault) {
return null;
}
const value = current && {
label: current.name.substr(0, 37),
value: current.name,
imgUrl: current.meta.info.logos.small,
loading: showLoading,
const ds = this.dataSourceSrv.getInstanceSettings(current);
if (ds) {
return {
label: ds.name.substr(0, 37),
value: ds.name,
imgUrl: ds.meta.info.logos.small,
hideText: hideTextValue,
meta: ds.meta,
};
}
return {
label: (current ?? 'no name') + ' - not found',
value: current,
imgUrl: '',
hideText: hideTextValue,
meta: current.meta,
};
}
getDataSourceOptions() {
const { tracing, metrics, mixed, dashboard, variables, annotations, pluginId } = this.props;
const options = this.dataSourceSrv
.getList({
tracing,
metrics,
dashboard,
mixed,
variables,
annotations,
pluginId,
})
.map(ds => ({
value: ds.name,
label: `${ds.name}${ds.isDefault ? ' (default)' : ''}`,
imgUrl: ds.meta.info.logos.small,
meta: ds.meta,
}));
return options;
}
render() {
const { autoFocus, onBlur, openMenuOnFocus, placeholder } = this.props;
const { error } = this.state;
const options = this.getDataSourceOptions();
const value = this.getCurrentValue();
return (
<div aria-label={selectors.components.DataSourcePicker.container}>
@@ -87,9 +134,9 @@ export class DataSourcePicker extends PureComponent<Props> {
placeholder={placeholder}
noOptionsMessage="No datasources found"
value={value}
invalid={invalid}
invalid={!!error}
getOptionLabel={o => {
if (isUnsignedPluginSignature(o.meta.signature) && o !== value) {
if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) {
return (
<HorizontalGroup align="center" justify="space-between">
<span>{o.label}</span> <PluginSignatureBadge status={o.meta.signature} />
@@ -103,5 +150,3 @@ export class DataSourcePicker extends PureComponent<Props> {
);
}
}
export default DataSourcePicker;
+9 -10
View File
@@ -5,11 +5,11 @@ import _ from 'lodash';
import { DataQuery, DataSourceApi, dateTimeFormat, AppEvents, urlUtil, ExploreUrlState } from '@grafana/data';
import appEvents from 'app/core/app_events';
import store from 'app/core/store';
import { getExploreDatasources } from '../../features/explore/state/selectors';
// Types
import { RichHistoryQuery } from 'app/types/explore';
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { getDataSourceSrv } from '@grafana/runtime';
const RICH_HISTORY_KEY = 'grafana.explore.richHistory';
@@ -275,22 +275,21 @@ export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortO
* exploreDatasources add generic datasource image and add property isRemoved = true.
*/
export function createDatasourcesList(queriesDatasources: string[]) {
const exploreDatasources = getExploreDatasources();
const datasources: Array<{ label: string; value: string; imgUrl: string; isRemoved: boolean }> = [];
queriesDatasources.forEach(queryDsName => {
const index = exploreDatasources.findIndex(exploreDs => exploreDs.name === queryDsName);
if (index !== -1) {
queriesDatasources.forEach(dsName => {
const dsSettings = getDataSourceSrv().getInstanceSettings(dsName);
if (dsSettings) {
datasources.push({
label: queryDsName,
value: queryDsName,
imgUrl: exploreDatasources[index].meta.info.logos.small,
label: dsSettings.name,
value: dsSettings.name,
imgUrl: dsSettings.meta.info.logos.small,
isRemoved: false,
});
} else {
datasources.push({
label: queryDsName,
value: queryDsName,
label: dsName,
value: dsName,
imgUrl: 'public/img/icn-datasource.svg',
isRemoved: true,
});
@@ -22,13 +22,10 @@ describe('getAlertingValidationMessage', () => {
const getMock = jest.fn().mockResolvedValue(datasource);
const datasourceSrv: DataSourceSrv = {
get: getMock,
getExternal(): DataSourceInstanceSettings[] {
getList(): DataSourceInstanceSettings[] {
return [];
},
getInstanceSettings: (() => {}) as any,
getAll(): DataSourceInstanceSettings[] {
return [];
},
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
@@ -66,10 +63,7 @@ describe('getAlertingValidationMessage', () => {
return Promise.resolve(alertingDatasource);
},
getInstanceSettings: (() => {}) as any,
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
getList(): DataSourceInstanceSettings[] {
return [];
},
};
@@ -96,10 +90,7 @@ describe('getAlertingValidationMessage', () => {
const datasourceSrv: DataSourceSrv = {
get: getMock,
getInstanceSettings: (() => {}) as any,
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
getList(): DataSourceInstanceSettings[] {
return [];
},
};
@@ -128,10 +119,7 @@ describe('getAlertingValidationMessage', () => {
const datasourceSrv: DataSourceSrv = {
get: getMock,
getInstanceSettings: (() => {}) as any,
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
getList(): DataSourceInstanceSettings[] {
return [];
},
};
@@ -160,10 +148,7 @@ describe('getAlertingValidationMessage', () => {
const datasourceSrv: DataSourceSrv = {
get: getMock,
getInstanceSettings: (() => {}) as any,
getExternal(): DataSourceInstanceSettings[] {
return [];
},
getAll(): DataSourceInstanceSettings[] {
getList(): DataSourceInstanceSettings[] {
return [];
},
};
@@ -2,7 +2,6 @@ import React, { PureComponent } from 'react';
import { QueryGroup } from 'app/features/query/components/QueryGroup';
import { QueryGroupOptions } from 'app/features/query/components/QueryGroupOptions';
import { PanelModel } from '../../state';
import { DataQuery, DataSourceSelectItem } from '@grafana/data';
import { getLocationSrv } from '@grafana/runtime';
interface Props {
@@ -22,6 +21,10 @@ export class PanelEditorQueries extends PureComponent<Props, State> {
buildQueryOptions({ panel }: Props): QueryGroupOptions {
return {
dataSource: {
name: panel.datasource,
},
queries: panel.targets,
maxDataPoints: panel.maxDataPoints,
minInterval: panel.interval,
timeRange: {
@@ -32,29 +35,10 @@ export class PanelEditorQueries extends PureComponent<Props, State> {
};
}
onDataSourceChange = (ds: DataSourceSelectItem, queries: DataQuery[]) => {
const { panel } = this.props;
panel.datasource = ds.value;
panel.targets = queries;
panel.refresh();
this.forceUpdate();
};
onRunQueries = () => {
this.props.panel.refresh();
};
onQueriesChange = (queries: DataQuery[]) => {
const { panel } = this.props;
panel.targets = queries;
panel.refresh();
this.forceUpdate();
};
onOpenQueryInspector = () => {
getLocationSrv().update({
query: { inspect: this.props.panel.id, inspectTab: 'query' },
@@ -62,9 +46,11 @@ export class PanelEditorQueries extends PureComponent<Props, State> {
});
};
onQueryOptionsChange = (options: QueryGroupOptions) => {
onOptionsChange = (options: QueryGroupOptions) => {
const { panel } = this.props;
panel.datasource = options.dataSource.default ? null : options.dataSource.name!;
panel.targets = options.queries;
panel.timeFrom = options.timeRange?.from;
panel.timeShift = options.timeRange?.shift;
panel.hideTimeOverride = options.timeRange?.hide;
@@ -81,15 +67,11 @@ export class PanelEditorQueries extends PureComponent<Props, State> {
return (
<QueryGroup
dataSourceName={panel.datasource}
options={options}
queryRunner={panel.getQueryRunner()}
queries={panel.targets}
onQueriesChange={this.onQueriesChange}
onDataSourceChange={this.onDataSourceChange}
onRunQueries={this.onRunQueries}
onOpenQueryInspector={this.onOpenQueryInspector}
onOptionsChange={this.onQueryOptionsChange}
onOptionsChange={this.onOptionsChange}
/>
);
}
+4 -12
View File
@@ -7,7 +7,7 @@ import { css } from 'emotion';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { Icon, IconButton, LegacyForms, SetInterval, Tooltip } from '@grafana/ui';
import { DataQuery, RawTimeRange, TimeRange, TimeZone } from '@grafana/data';
import { DataQuery, DataSourceInstanceSettings, RawTimeRange, TimeRange, TimeZone } from '@grafana/data';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store';
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
@@ -24,7 +24,6 @@ import { LiveTailButton } from './LiveTailButton';
import { ResponsiveButton } from './ResponsiveButton';
import { RunButton } from './RunButton';
import { LiveTailControls } from './useLiveTailControls';
import { getExploreDatasources } from './state/selectors';
import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers';
import { cancelQueries, clearQueries, runQueries } from './state/query';
@@ -81,8 +80,8 @@ interface DispatchProps {
type Props = StateProps & DispatchProps & OwnProps;
export class UnConnectedExploreToolbar extends PureComponent<Props> {
onChangeDatasource = async (option: { value: any }) => {
this.props.changeDatasource(this.props.exploreId, option.value, { importQueries: true });
onChangeDatasource = async (dsSettings: DataSourceInstanceSettings) => {
this.props.changeDatasource(this.props.exploreId, dsSettings.name, { importQueries: true });
};
onClearAll = () => {
@@ -141,12 +140,6 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
});
}
getSelectedDatasource = () => {
const { datasourceName } = this.props;
const exploreDatasources = getExploreDatasources();
return datasourceName ? exploreDatasources.find(datasource => datasource.name === datasourceName) : undefined;
};
render() {
const {
datasourceMissing,
@@ -214,8 +207,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
>
<DataSourcePicker
onChange={this.onChangeDatasource}
datasources={getExploreDatasources()}
current={this.getSelectedDatasource()}
current={this.props.datasourceName}
hideTextValue={showSmallDataSourcePicker}
/>
</div>
@@ -13,14 +13,8 @@ describe('createSpanLinkFactory', () => {
it('returns undefined if there is no loki data source', () => {
setDataSourceSrv({
getExternal() {
return [
{
meta: {
id: 'not loki',
},
} as DataSourceInstanceSettings,
];
getList() {
return [];
},
} as any);
const splitOpenFn = jest.fn();
@@ -30,7 +24,7 @@ describe('createSpanLinkFactory', () => {
it('creates correct link', () => {
setDataSourceSrv({
getExternal() {
getList() {
return [
{
name: 'loki1',
@@ -15,9 +15,7 @@ export function createSpanLinkFactory(splitOpenFn: (options: { datasourceUid: st
}
// Right now just hardcoded for first loki DS we can find
const lokiDs = getDataSourceSrv()
.getExternal()
.find(ds => ds.meta.id === 'loki');
const lokiDs = getDataSourceSrv().getList({ pluginId: 'loki' })[0];
if (!lokiDs) {
return undefined;
+4 -2
View File
@@ -220,10 +220,12 @@ function setup(options?: SetupOptions): { datasources: { [name: string]: DataSou
const dsSettings = options?.datasources || defaultDatasources;
setDataSourceSrv({
getExternal(): DataSourceInstanceSettings[] {
getList(): DataSourceInstanceSettings[] {
return dsSettings.map(d => d.settings);
},
getInstanceSettings(name: string) {
return dsSettings.map(d => d.settings).find(x => x.name === name);
},
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi> {
return Promise.resolve((name ? dsSettings.find(d => d.api.name === name) : dsSettings[0])!.api);
},
@@ -10,26 +10,9 @@ import {
refreshExplore,
} from './explorePane';
import { setQueriesAction } from './query';
import * as DatasourceSrv from 'app/features/plugins/datasource_srv';
import { makeExplorePaneState, makeInitialUpdateState } from './utils';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
jest.mock('app/features/plugins/datasource_srv');
const getDatasourceSrvMock = (DatasourceSrv.getDatasourceSrv as any) as jest.Mock<DatasourceSrv.DatasourceSrv>;
beforeEach(() => {
getDatasourceSrvMock.mockClear();
getDatasourceSrvMock.mockImplementation(
() =>
({
getExternal: jest.fn().mockReturnValue([]),
get: jest.fn().mockReturnValue({
testDatasource: jest.fn(),
init: jest.fn(),
}),
} as any)
);
});
import { setDataSourceSrv } from '@grafana/runtime';
jest.mock('../../dashboard/services/TimeSrv', () => ({
getTimeSrv: jest.fn().mockReturnValue({
@@ -47,6 +30,21 @@ const testRange = {
},
};
setDataSourceSrv({
getList() {
return [];
},
getInstanceSettings(name: string) {
return { name: 'hello' };
},
get() {
return Promise.resolve({
testDatasource: jest.fn(),
init: jest.fn(),
});
},
} as any);
const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
const exploreId = ExploreId.left;
const containerWidth = 1920;
@@ -32,7 +32,7 @@ import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { runQueries, setQueriesAction } from './query';
import { updateTime } from './time';
import { toRawTimeRange } from '../utils/time';
import { getExploreDatasources } from './selectors';
import { getDataSourceSrv } from '@grafana/runtime';
//
// Actions and Payloads
@@ -131,7 +131,7 @@ export function initializeExplore(
originPanelId?: number | null
): ThunkResult<void> {
return async (dispatch, getState) => {
const exploreDatasources = getExploreDatasources();
const exploreDatasources = getDataSourceSrv().getList();
let instance = undefined;
let history: HistoryItem[] = [];
@@ -1,8 +1,6 @@
import { createSelector } from 'reselect';
import { ExploreItemState } from 'app/types';
import { filterLogLevels, dedupLogRows } from 'app/core/logs_model';
import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { DataSourceSelectItem } from '@grafana/data';
const logsRowsSelector = (state: ExploreItemState) => state.logsResult && state.logsResult.rows;
const hiddenLogLevelsSelector = (state: ExploreItemState) => state.hiddenLogLevels;
@@ -19,16 +17,3 @@ export const deduplicatedRowsSelector = createSelector(
return dedupLogRows(filteredRows, dedupStrategy);
}
);
export const getExploreDatasources = (): DataSourceSelectItem[] => {
return getDatasourceSrv()
.getExternal()
.map(
(ds: any) =>
({
value: ds.name,
name: ds.name,
meta: ds.meta,
} as DataSourceSelectItem)
);
};
@@ -11,11 +11,11 @@ import {
Legend,
} from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import DataSourcePicker from 'app/core/components/Select/DataSourcePicker';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers';
import { validateTitle, validateUid } from '../utils/validation';
interface Props extends Omit<FormAPI<ImportDashboardDTO>, 'formState' | 'watch'> {
interface Props extends Omit<FormAPI<ImportDashboardDTO>, 'formState'> {
uidReset: boolean;
inputs: DashboardInputs;
initialFolderId: number;
@@ -36,8 +36,10 @@ export const ImportDashboardForm: FC<Props> = ({
onUidReset,
onCancel,
onSubmit,
watch,
}) => {
const [isSubmitted, setSubmitted] = useState(false);
const watchDataSources = watch('dataSources');
/*
This useEffect is needed for overwriting a dashboard. It
@@ -96,6 +98,7 @@ export const ImportDashboardForm: FC<Props> = ({
{inputs.dataSources &&
inputs.dataSources.map((input: DataSourceInput, index: number) => {
const dataSourceOption = `dataSources[${index}]`;
const current = watchDataSources ?? [];
return (
<Field
label={input.label}
@@ -105,8 +108,10 @@ export const ImportDashboardForm: FC<Props> = ({
>
<InputControl
as={DataSourcePicker}
noDefault={true}
pluginId={input.pluginId}
name={`${dataSourceOption}`}
datasources={input.options}
current={current[index]?.name}
control={control}
placeholder={input.info}
rules={{ required: true }}
@@ -87,7 +87,7 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
validateFieldsOnMount={['title', 'uid']}
validateOn="onChange"
>
{({ register, errors, control, getValues }) => (
{({ register, errors, control, watch, getValues }) => (
<ImportDashboardForm
register={register}
errors={errors}
@@ -98,6 +98,7 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
onCancel={this.onCancel}
onUidReset={this.onUidReset}
onSubmit={this.onSubmit}
watch={watch}
initialFolderId={folder.id}
/>
)}
@@ -1,6 +1,5 @@
import { AppEvents, DataSourceInstanceSettings, DataSourceSelectItem, locationUtil } from '@grafana/data';
import { AppEvents, DataSourceInstanceSettings, locationUtil } from '@grafana/data';
import { getBackendSrv } from 'app/core/services/backend_srv';
import config from 'app/core/config';
import {
clearDashboard,
setInputs,
@@ -13,6 +12,7 @@ import { updateLocation } from 'app/core/actions';
import { ThunkResult, FolderInfo, DashboardDTO, DashboardDataDTO } from 'app/types';
import { appEvents } from '../../../core/core';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { getDataSourceSrv } from '@grafana/runtime';
export function fetchGcomDashboard(id: string): ThunkResult<void> {
return async dispatch => {
@@ -73,13 +73,13 @@ export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkR
const inputs = getState().importDashboard.inputs;
let inputsToPersist = [] as any[];
importDashboardForm.dataSources?.forEach((dataSource: DataSourceSelectItem, index: number) => {
importDashboardForm.dataSources?.forEach((dataSource: DataSourceInstanceSettings, index: number) => {
const input = inputs.dataSources[index];
inputsToPersist.push({
name: input.name,
type: input.type,
pluginId: input.pluginId,
value: dataSource.value,
value: dataSource.name,
});
});
@@ -105,19 +105,13 @@ export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkR
}
const getDataSourceOptions = (input: { pluginId: string; pluginName: string }, inputModel: any) => {
const sources = Object.values(config.datasources).filter(
(val: DataSourceInstanceSettings) => val.type === input.pluginId
);
const sources = getDataSourceSrv().getList({ pluginId: input.pluginId });
if (sources.length === 0) {
inputModel.info = 'No data sources of type ' + input.pluginName + ' found';
} else if (!inputModel.info) {
inputModel.info = 'Select a ' + input.pluginName + ' data source';
}
inputModel.options = sources.map(val => {
return { name: val.name, value: val.name, meta: val.meta };
});
};
export function moveDashboards(dashboardUids: string[], toFolder: FolderInfo) {
@@ -1,5 +1,5 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { DataSourceSelectItem } from '@grafana/data';
import { DataSourceInstanceSettings } from '@grafana/data';
export enum DashboardSource {
Gcom = 0,
@@ -11,7 +11,7 @@ export interface ImportDashboardDTO {
uid: string;
gnetId: string;
constants: string[];
dataSources: DataSourceSelectItem[];
dataSources: DataSourceInstanceSettings[];
folder: { id: number; title?: string };
}
@@ -30,7 +30,6 @@ export interface DashboardInput {
export interface DataSourceInput extends DashboardInput {
pluginId: string;
options: DataSourceSelectItem[];
}
export interface DashboardInputs {
+102 -71
View File
@@ -1,9 +1,9 @@
// Libraries
import sortBy from 'lodash/sortBy';
import coreModule from 'app/core/core_module';
// Services & Utils
import { importDataSourcePlugin } from './plugin_loader';
import {
GetDataSourceListFilters,
DataSourceSrv as DataSourceService,
getDataSourceSrv as getDataSourceService,
TemplateSrv,
@@ -15,6 +15,7 @@ import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
// Pretend Datasource
import { expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
import { DataSourceVariableModel } from '../variables/types';
import { cloneDeep } from 'lodash';
export class DatasourceSrv implements DataSourceService {
private datasources: Record<string, DataSourceApi> = {};
@@ -49,6 +50,20 @@ export class DatasourceSrv implements DataSourceService {
return this.settingsMapByName[this.defaultName];
}
// Complex logic to support template variable data source names
// For this we just pick the current or first data source in the variable
if (nameOrUid[0] === '$') {
const interpolatedName = this.templateSrv.replace(nameOrUid, {}, variableInterpolation);
const dsSettings = this.settingsMapByUid[interpolatedName] ?? this.settingsMapByName[interpolatedName];
if (!dsSettings) {
return undefined;
}
// The return name or uid needs preservet string containing the variable
const clone = cloneDeep(dsSettings);
clone.name = nameOrUid;
return clone;
}
return this.settingsMapByUid[nameOrUid] ?? this.settingsMapByName[nameOrUid];
}
@@ -69,12 +84,7 @@ export class DatasourceSrv implements DataSourceService {
}
// Interpolation here is to support template variable in data source selection
nameOrUid = this.templateSrv.replace(nameOrUid, scopedVars, (value: any[]) => {
if (Array.isArray(value)) {
return value[0];
}
return value;
});
nameOrUid = this.templateSrv.replace(nameOrUid, scopedVars, variableInterpolation);
if (nameOrUid === 'default') {
return this.get(this.defaultName);
@@ -130,88 +140,109 @@ export class DatasourceSrv implements DataSourceService {
return Object.values(this.settingsMapByName);
}
getExternal(): DataSourceInstanceSettings[] {
const datasources = this.getAll().filter(ds => !ds.meta.builtIn);
return sortBy(datasources, ['name']);
}
getAnnotationSources() {
const sources: any[] = [];
this.addDataSourceVariables(sources);
Object.values(this.settingsMapByName).forEach(value => {
if (value.meta?.annotations) {
sources.push(value);
getList(filters: GetDataSourceListFilters = {}): DataSourceInstanceSettings[] {
const base = Object.values(this.settingsMapByName).filter(x => {
if (x.meta.id === 'grafana' || x.meta.id === 'mixed' || x.meta.id === 'dashboard') {
return false;
}
if (filters.metrics && !x.meta.metrics) {
return false;
}
if (filters.tracing && !x.meta.tracing) {
return false;
}
if (filters.annotations && !x.meta.annotations) {
return false;
}
if (filters.pluginId && x.meta.id !== filters.pluginId) {
return false;
}
return true;
});
return sources;
}
if (filters.variables) {
for (const variable of this.templateSrv.getVariables().filter(variable => variable.type === 'datasource')) {
const dsVar = variable as DataSourceVariableModel;
const first = dsVar.current.value === 'default' ? this.defaultName : dsVar.current.value;
const dsName = (first as unknown) as string;
const dsSettings = this.settingsMapByName[dsName];
getMetricSources(options?: { skipVariables?: boolean }) {
const metricSources: DataSourceSelectItem[] = [];
Object.entries(this.settingsMapByName).forEach(([key, value]) => {
if (value.meta?.metrics) {
let metricSource: DataSourceSelectItem = { value: key, name: key, meta: value.meta, sort: key };
//Make sure grafana and mixed are sorted at the bottom
if (value.meta.id === 'grafana') {
metricSource.sort = String.fromCharCode(253);
} else if (value.meta.id === 'dashboard') {
metricSource.sort = String.fromCharCode(254);
} else if (value.meta.id === 'mixed') {
metricSource.sort = String.fromCharCode(255);
}
metricSources.push(metricSource);
if (key === this.defaultName) {
metricSource = { value: null, name: 'default', meta: value.meta, sort: key };
metricSources.push(metricSource);
if (dsSettings) {
const key = `$\{${variable.name}\}`;
base.push({
...dsSettings,
name: key,
});
}
}
});
if (!options || !options.skipVariables) {
this.addDataSourceVariables(metricSources);
}
metricSources.sort((a, b) => {
if (a.sort.toLowerCase() > b.sort.toLowerCase()) {
const sorted = base.sort((a, b) => {
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
if (a.sort.toLowerCase() < b.sort.toLowerCase()) {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
return 0;
});
return metricSources;
if (!filters.pluginId) {
if (filters.mixed) {
base.push(this.getInstanceSettings('-- Mixed --')!);
}
if (filters.dashboard) {
base.push(this.getInstanceSettings('-- Dashboard --')!);
}
if (!filters.tracing) {
base.push(this.getInstanceSettings('-- Grafana --')!);
}
}
return sorted;
}
addDataSourceVariables(list: any[]) {
// look for data source variables
this.templateSrv
.getVariables()
.filter(variable => variable.type === 'datasource')
.forEach((variable: DataSourceVariableModel) => {
const first = variable.current.value === 'default' ? this.defaultName : variable.current.value;
const index = (first as unknown) as string;
const ds = this.settingsMapByName[index];
if (ds) {
const key = `$${variable.name}`;
list.push({
name: key,
value: key,
meta: ds.meta,
sort: key,
});
}
});
/**
* @deprecated use getList
* */
getExternal(): DataSourceInstanceSettings[] {
return this.getList();
}
/**
* @deprecated use getList
* */
getAnnotationSources() {
return this.getList({ annotations: true, variables: true }).map(x => {
return {
name: x.name,
value: x.isDefault ? null : x.name,
meta: x.meta,
};
});
}
/**
* @deprecated use getList
* */
getMetricSources(options?: { skipVariables?: boolean }): DataSourceSelectItem[] {
return this.getList({ metrics: true, variables: !options?.skipVariables }).map(x => {
return {
name: x.name,
value: x.isDefault ? null : x.name,
meta: x.meta,
};
});
}
}
export function variableInterpolation(value: any[]) {
if (Array.isArray(value)) {
return value[0];
}
return value;
}
export const getDatasourceSrv = (): DatasourceSrv => {
@@ -1,6 +1,6 @@
import 'app/features/plugins/datasource_srv';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { DataSourceInstanceSettings, DataSourcePlugin, DataSourcePluginMeta, PluginMeta } from '@grafana/data';
import { DataSourceInstanceSettings, DataSourcePlugin } from '@grafana/data';
// Datasource variable $datasource with current value 'BBB'
const templateSrv: any = {
@@ -13,7 +13,9 @@ const templateSrv: any = {
},
},
],
replace: (v: string) => v,
replace: (v: string) => {
return v.replace('${datasource}', 'BBB');
},
};
class TestDataSource {
@@ -27,120 +29,184 @@ jest.mock('../plugin_loader', () => ({
}));
describe('datasource_srv', () => {
const _datasourceSrv = new DatasourceSrv({} as any, {} as any, templateSrv);
const datasources = {
buildIn: {
id: 1,
uid: '1',
type: 'b',
name: 'buildIn',
meta: { builtIn: true } as DataSourcePluginMeta,
jsonData: {},
const dataSourceSrv = new DatasourceSrv({} as any, {} as any, templateSrv);
const dataSourceInit = {
mmm: {
type: 'test-db',
name: 'mmm',
uid: 'uid-code-mmm',
meta: { metrics: true, annotations: true } as any,
},
external1: {
id: 2,
uid: '2',
type: 'e',
name: 'external1',
meta: { builtIn: false } as DataSourcePluginMeta,
jsonData: {},
'-- Grafana --': {
type: 'grafana',
name: '-- Grafana --',
meta: { builtIn: true, metrics: true, id: 'grafana' },
},
external2: {
id: 3,
uid: '3',
type: 'e2',
name: 'external2',
meta: {} as PluginMeta,
jsonData: {},
'-- Dashboard --': {
type: 'dashboard',
name: '-- Dashboard --',
meta: { builtIn: true, metrics: true, id: 'dashboard' },
},
'-- Mixed --': {
type: 'test-db',
name: '-- Mixed --',
meta: { builtIn: true, metrics: true, id: 'mixed' },
},
ZZZ: {
type: 'test-db',
name: 'ZZZ',
uid: 'uid-code-ZZZ',
meta: { metrics: true },
},
aaa: {
type: 'test-db',
name: 'aaa',
uid: 'uid-code-aaa',
meta: { metrics: true },
},
BBB: {
type: 'test-db',
name: 'BBB',
uid: 'uid-code-BBB',
meta: { metrics: true },
},
Jaeger: {
type: 'jaeger-db',
name: 'Jaeger',
uid: 'uid-code-Jaeger',
meta: { tracing: true, id: 'jaeger' },
},
};
beforeEach(() => {
_datasourceSrv.init(datasources, 'external1');
});
describe('when getting data source class instance', () => {
it('should load plugin and create instance and set meta', async () => {
const ds = (await _datasourceSrv.get('external1')) as any;
expect(ds.meta).toBe(datasources.external1.meta);
expect(ds.instanceSettings).toBe(datasources.external1);
// validate that it caches instance
const ds2 = await _datasourceSrv.get('external1');
expect(ds).toBe(ds2);
});
it('should be able to load data source using uid as well', async () => {
const dsByUid = await _datasourceSrv.get('2');
const dsByName = await _datasourceSrv.get('external1');
expect(dsByUid.meta).toBe(datasources.external1.meta);
expect(dsByUid).toBe(dsByName);
});
});
describe('when getting external metric sources', () => {
it('should return list of explore sources', () => {
const externalSources = _datasourceSrv.getExternal();
expect(externalSources.length).toBe(2);
expect(externalSources[0].name).toBe('external1');
expect(externalSources[1].name).toBe('external2');
});
});
describe('when loading metric sources', () => {
let metricSources: any;
describe('Given a list of data sources', () => {
beforeEach(() => {
_datasourceSrv.init(
{
mmm: {
type: 'test-db',
meta: { metrics: true } as any,
},
'--Grafana--': {
type: 'grafana',
meta: { builtIn: true, metrics: true, id: 'grafana' },
},
'--Mixed--': {
type: 'test-db',
meta: { builtIn: true, metrics: true, id: 'mixed' },
},
ZZZ: {
type: 'test-db',
meta: { metrics: true },
},
aaa: {
type: 'test-db',
meta: { metrics: true },
},
BBB: {
type: 'test-db',
meta: { metrics: true },
},
} as any,
'BBB'
);
metricSources = _datasourceSrv.getMetricSources({});
dataSourceSrv.init(dataSourceInit as any, 'BBB');
});
it('should return a list of sources sorted case insensitively with builtin sources last', () => {
expect(metricSources[1].name).toBe('aaa');
expect(metricSources[2].name).toBe('BBB');
expect(metricSources[3].name).toBe('default');
expect(metricSources[4].name).toBe('mmm');
expect(metricSources[5].name).toBe('ZZZ');
expect(metricSources[6].name).toBe('--Grafana--');
expect(metricSources[7].name).toBe('--Mixed--');
describe('when getting data source class instance', () => {
it('should load plugin and create instance and set meta', async () => {
const ds = (await dataSourceSrv.get('mmm')) as any;
expect(ds.meta).toBe(dataSourceInit.mmm.meta);
expect(ds.instanceSettings).toBe(dataSourceInit.mmm);
// validate that it caches instance
const ds2 = await dataSourceSrv.get('mmm');
expect(ds).toBe(ds2);
});
it('should be able to load data source using uid as well', async () => {
const dsByUid = await dataSourceSrv.get('uid-code-mmm');
const dsByName = await dataSourceSrv.get('mmm');
expect(dsByUid.meta).toBe(dsByName.meta);
expect(dsByUid).toBe(dsByName);
});
});
it('should set default data source', () => {
expect(metricSources[3].name).toBe('default');
expect(metricSources[3].sort).toBe('BBB');
describe('when getting instance settings', () => {
it('should work by name or uid', () => {
expect(dataSourceSrv.getInstanceSettings('mmm')).toBe(dataSourceSrv.getInstanceSettings('uid-code-mmm'));
});
it('should work with variable', () => {
const ds = dataSourceSrv.getInstanceSettings('${datasource}');
expect(ds?.name).toBe('${datasource}');
expect(ds?.uid).toBe('uid-code-BBB');
});
});
it('should set default inject the variable datasources', () => {
expect(metricSources[0].name).toBe('$datasource');
expect(metricSources[0].sort).toBe('$datasource');
describe('when getting external metric sources', () => {
it('should return list of explore sources', () => {
const externalSources = dataSourceSrv.getExternal();
expect(externalSources.length).toBe(6);
});
});
it('Can get list of data sources with variables: true', () => {
const list = dataSourceSrv.getList({ metrics: true, variables: true });
expect(list[0].name).toBe('${datasource}');
});
it('Can get list of data sources with tracing: true', () => {
const list = dataSourceSrv.getList({ tracing: true });
expect(list[0].name).toBe('Jaeger');
});
it('Can get list of data sources with annotation: true', () => {
const list = dataSourceSrv.getList({ annotations: true });
expect(list[0].name).toBe('mmm');
});
it('Can get get list and filter by pluginId', () => {
const list = dataSourceSrv.getList({ pluginId: 'jaeger' });
expect(list[0].name).toBe('Jaeger');
expect(list.length).toBe(1);
});
it('Can get list of data sources with metrics: true, builtIn: true, mixed: true', () => {
expect(dataSourceSrv.getList({ metrics: true, dashboard: true, mixed: true })).toMatchInlineSnapshot(`
Array [
Object {
"meta": Object {
"metrics": true,
},
"name": "aaa",
"type": "test-db",
"uid": "uid-code-aaa",
},
Object {
"meta": Object {
"metrics": true,
},
"name": "BBB",
"type": "test-db",
"uid": "uid-code-BBB",
},
Object {
"meta": Object {
"annotations": true,
"metrics": true,
},
"name": "mmm",
"type": "test-db",
"uid": "uid-code-mmm",
},
Object {
"meta": Object {
"metrics": true,
},
"name": "ZZZ",
"type": "test-db",
"uid": "uid-code-ZZZ",
},
Object {
"meta": Object {
"builtIn": true,
"id": "mixed",
"metrics": true,
},
"name": "-- Mixed --",
"type": "test-db",
},
Object {
"meta": Object {
"builtIn": true,
"id": "dashboard",
"metrics": true,
},
"name": "-- Dashboard --",
"type": "dashboard",
},
Object {
"meta": Object {
"builtIn": true,
"id": "grafana",
"metrics": true,
},
"name": "-- Grafana --",
"type": "grafana",
},
]
`);
});
});
});
@@ -16,6 +16,7 @@ import {
TimeRange,
toLegacyResponseData,
EventBusExtended,
DataSourceInstanceSettings,
} from '@grafana/data';
import { QueryEditorRowTitle } from './QueryEditorRowTitle';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
@@ -27,8 +28,7 @@ import { PanelModel } from 'app/features/dashboard/state';
interface Props {
data: PanelData;
query: DataQuery;
dataSourceValue: string | null;
inMixedMode?: boolean;
dsSettings: DataSourceInstanceSettings;
id: string;
index: number;
onAddQuery: (query?: DataQuery) => void;
@@ -38,7 +38,7 @@ interface Props {
}
interface State {
loadedDataSourceValue: string | null | undefined;
loadedDataSourceIdentifier?: string | null;
datasource: DataSourceApi | null;
hasTextEditMode: boolean;
data?: PanelData;
@@ -52,7 +52,6 @@ export class QueryEditorRow extends PureComponent<Props, State> {
state: State = {
datasource: null,
loadedDataSourceValue: undefined,
hasTextEditMode: false,
data: undefined,
isOpen: true,
@@ -89,27 +88,31 @@ export class QueryEditorRow extends PureComponent<Props, State> {
};
}
getQueryDataSourceIdentifier(): string | null | undefined {
const { query, dsSettings } = this.props;
return dsSettings.meta.mixed ? query.datasource : dsSettings.uid;
}
async loadDatasource() {
const { query, dataSourceValue } = this.props;
const dataSourceSrv = getDatasourceSrv();
let datasource;
let datasource: DataSourceApi;
const dataSourceIdentifier = this.getQueryDataSourceIdentifier();
try {
const datasourceName = dataSourceValue || query.datasource;
datasource = await dataSourceSrv.get(datasourceName);
datasource = await dataSourceSrv.get(dataSourceIdentifier);
} catch (error) {
datasource = await dataSourceSrv.get();
}
this.setState({
datasource,
loadedDataSourceValue: this.props.dataSourceValue,
loadedDataSourceIdentifier: dataSourceIdentifier,
hasTextEditMode: _.has(datasource, 'components.QueryCtrl.prototype.toggleEditorMode'),
});
}
componentDidUpdate(prevProps: Props) {
const { loadedDataSourceValue } = this.state;
const { datasource, loadedDataSourceIdentifier } = this.state;
const { data, query } = this.props;
if (data !== prevProps.data) {
@@ -125,7 +128,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
}
// check if we need to load another datasource
if (loadedDataSourceValue !== this.props.dataSourceValue) {
if (datasource && loadedDataSourceIdentifier !== this.getQueryDataSourceIdentifier()) {
if (this.angularQueryEditor) {
this.angularQueryEditor.destroy();
this.angularQueryEditor = null;
@@ -137,6 +140,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
if (!this.element || this.angularQueryEditor) {
return;
}
this.renderAngularQueryEditor();
}
@@ -259,14 +263,14 @@ export class QueryEditorRow extends PureComponent<Props, State> {
};
renderTitle = (props: { isOpen: boolean; openRow: () => void }) => {
const { query, inMixedMode } = this.props;
const { query, dsSettings } = this.props;
const { datasource } = this.state;
const isDisabled = query.hide;
return (
<QueryEditorRowTitle
query={query}
inMixedMode={inMixedMode}
inMixedMode={dsSettings.meta.mixed}
datasource={datasource!}
disabled={isDisabled}
onClick={e => this.onToggleEditMode(e, props)}
@@ -2,14 +2,14 @@
import React, { PureComponent } from 'react';
// Types
import { DataQuery, PanelData, DataSourceSelectItem } from '@grafana/data';
import { DataQuery, DataSourceInstanceSettings, PanelData } from '@grafana/data';
import { QueryEditorRow } from './QueryEditorRow';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
interface Props {
// The query configuration
queries: DataQuery[];
datasource: DataSourceSelectItem;
dsSettings: DataSourceInstanceSettings;
// Query editing
onQueriesChange: (queries: DataQuery[]) => void;
@@ -67,7 +67,7 @@ export class QueryEditorRows extends PureComponent<Props> {
};
render() {
const { props } = this;
const { dsSettings, data, queries } = this.props;
return (
<DragDropContext onDragEnd={this.onDragEnd}>
@@ -75,19 +75,18 @@ export class QueryEditorRows extends PureComponent<Props> {
{provided => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
{props.queries.map((query, index) => (
{queries.map((query, index) => (
<QueryEditorRow
dataSourceValue={query.datasource || props.datasource.value}
dsSettings={dsSettings}
id={query.refId}
index={index}
key={query.refId}
data={props.data}
data={data}
query={query}
onChange={query => this.onChangeQuery(query, index)}
onRemoveQuery={this.onRemoveQuery}
onAddQuery={this.props.onAddQuery}
onRunQuery={this.props.onRunQueries}
inMixedMode={props.datasource.meta.mixed}
/>
))}
{provided.placeholder}
@@ -2,21 +2,20 @@
import React, { PureComponent } from 'react';
// Components
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { Button, CustomScrollbar, HorizontalGroup, Modal, stylesFactory, Field } from '@grafana/ui';
import { Button, CustomScrollbar, HorizontalGroup, Modal, stylesFactory } from '@grafana/ui';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryEditorRows } from './QueryEditorRows';
// Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { backendSrv } from 'app/core/services/backend_srv';
import config from 'app/core/config';
// Types
import {
DataQuery,
DataSourceSelectItem,
DefaultTimeRange,
LoadingState,
PanelData,
DataSourceApi,
DataSourceInstanceSettings,
} from '@grafana/data';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { addQuery } from 'app/core/utils/query';
@@ -30,20 +29,15 @@ import { css } from 'emotion';
interface Props {
queryRunner: PanelQueryRunner;
queries: DataQuery[];
dataSourceName: string | null;
options: QueryGroupOptions;
onOpenQueryInspector?: () => void;
onRunQueries: () => void;
onQueriesChange: (queries: DataQuery[]) => void;
onDataSourceChange: (ds: DataSourceSelectItem, queries: DataQuery[]) => void;
onOptionsChange: (options: QueryGroupOptions) => void;
}
interface State {
dataSource?: DataSourceApi;
dataSourceItem: DataSourceSelectItem;
dataSourceError?: string;
dsSettings?: DataSourceInstanceSettings;
helpContent: React.ReactNode;
isLoadingHelp: boolean;
isPickerOpen: boolean;
@@ -54,13 +48,12 @@ interface State {
}
export class QueryGroup extends PureComponent<Props, State> {
datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
backendSrv = backendSrv;
dataSourceSrv = getDataSourceSrv();
querySubscription: Unsubscribable | null;
state: State = {
isLoadingHelp: false,
dataSourceItem: this.findCurrentDataSource(this.props.dataSourceName),
helpContent: null,
isPickerOpen: false,
isAddingMixed: false,
@@ -74,19 +67,18 @@ export class QueryGroup extends PureComponent<Props, State> {
};
async componentDidMount() {
const { queryRunner, dataSourceName: datasourceName } = this.props;
const { queryRunner, options } = this.props;
this.querySubscription = queryRunner.getData({ withTransforms: false, withFieldConfig: false }).subscribe({
next: (data: PanelData) => this.onPanelDataUpdate(data),
});
try {
const ds = await getDataSourceSrv().get(datasourceName);
this.setState({ dataSource: ds });
const ds = await this.dataSourceSrv.get(options.dataSource.name);
const dsSettings = this.dataSourceSrv.getInstanceSettings(options.dataSource.name);
this.setState({ dataSource: ds, dsSettings });
} catch (error) {
const ds = await getDataSourceSrv().get();
const dataSourceItem = this.findCurrentDataSource(ds.name);
this.setState({ dataSource: ds, dataSourceError: error?.message, dataSourceItem });
console.log('failed to load data source', error);
}
}
@@ -101,62 +93,73 @@ export class QueryGroup extends PureComponent<Props, State> {
this.setState({ data });
}
findCurrentDataSource(dataSourceName: string | null): DataSourceSelectItem {
return this.datasources.find(datasource => datasource.value === dataSourceName) || this.datasources[0];
}
onChangeDataSource = async (newDsItem: DataSourceSelectItem) => {
let { queries } = this.props;
const { dataSourceItem } = this.state;
onChangeDataSource = async (newSettings: DataSourceInstanceSettings) => {
let { queries } = this.props.options;
const { dsSettings } = this.state;
// switching to mixed
if (newDsItem.meta.mixed) {
if (newSettings.meta.mixed) {
for (const query of queries) {
if (query.datasource !== ExpressionDatasourceID) {
query.datasource = query.datasource;
query.datasource = dsSettings?.name;
if (!query.datasource) {
query.datasource = config.defaultDatasource;
}
}
}
} else if (dataSourceItem) {
} else if (dsSettings) {
// if switching from mixed
if (dataSourceItem.meta.mixed) {
if (dsSettings.meta.mixed) {
// Remove the explicit datasource
for (const query of queries) {
if (query.datasource !== ExpressionDatasourceID) {
delete query.datasource;
}
}
} else if (dataSourceItem.meta.id !== newDsItem.meta.id) {
} else if (dsSettings.meta.id !== newSettings.meta.id) {
// we are changing data source type, clear queries
queries = [{ refId: 'A' }];
}
}
const dataSource = await getDataSourceSrv().get(newDsItem.value);
const dataSource = await this.dataSourceSrv.get(newSettings.name);
this.props.onDataSourceChange(newDsItem, queries);
this.onChange({
queries,
dataSource: {
name: newSettings.name,
uid: newSettings.uid,
default: newSettings.isDefault,
},
});
this.setState({
dataSourceItem: newDsItem,
dataSource: dataSource,
dataSourceError: undefined,
dsSettings: newSettings,
});
};
onAddQueryClick = () => {
if (this.state.dataSourceItem.meta.mixed) {
if (this.state.dsSettings?.meta.mixed) {
this.setState({ isAddingMixed: true });
return;
}
this.props.onQueriesChange(addQuery(this.props.queries));
this.onChange({ queries: addQuery(this.props.options.queries) });
this.onScrollBottom();
};
onChange(changedProps: Partial<QueryGroupOptions>) {
this.props.onOptionsChange({
...this.props.options,
...changedProps,
});
}
onAddExpressionClick = () => {
this.props.onQueriesChange(addQuery(this.props.queries, expressionDatasource.newQuery()));
this.onChange({
queries: addQuery(this.props.options.queries, expressionDatasource.newQuery()),
});
this.onScrollBottom();
};
@@ -166,45 +169,51 @@ export class QueryGroup extends PureComponent<Props, State> {
renderTopSection(styles: QueriesTabStyls) {
const { onOpenQueryInspector, options, onOptionsChange } = this.props;
const { dataSourceItem, dataSource, dataSourceError, data } = this.state;
if (!dataSource) {
return null;
}
const { dataSource, data } = this.state;
return (
<div>
<div className={styles.dataSourceRow}>
<div className={styles.dataSourceRowItem}>
<Field invalid={!!dataSourceError} error={dataSourceError}>
<DataSourcePicker
datasources={this.datasources}
onChange={this.onChangeDataSource}
current={dataSourceItem}
/>
</Field>
</div>
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
icon="question-circle"
title="Open data source help"
onClick={this.onOpenHelp}
<DataSourcePicker
onChange={this.onChangeDataSource}
current={options.dataSource.name}
metrics={true}
mixed={true}
dashboard={true}
variables={true}
/>
</div>
<div className={styles.dataSourceRowItemOptions}>
<QueryGroupOptionsEditor options={options} dataSource={dataSource} data={data} onChange={onOptionsChange} />
</div>
{onOpenQueryInspector && (
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
onClick={onOpenQueryInspector}
aria-label={selectors.components.QueryTab.queryInspectorButton}
>
Query inspector
</Button>
</div>
{dataSource && (
<>
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
icon="question-circle"
title="Open data source help"
onClick={this.onOpenHelp}
/>
</div>
<div className={styles.dataSourceRowItemOptions}>
<QueryGroupOptionsEditor
options={options}
dataSource={dataSource}
data={data}
onChange={onOptionsChange}
/>
</div>
{onOpenQueryInspector && (
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
onClick={onOpenQueryInspector}
aria-label={selectors.components.QueryTab.queryInspectorButton}
>
Query inspector
</Button>
</div>
)}
</>
)}
</div>
</div>
@@ -220,13 +229,9 @@ export class QueryGroup extends PureComponent<Props, State> {
};
renderMixedPicker = () => {
// We cannot filter on mixed flag as some mixed data sources like external plugin
// meta queries data source is mixed but also supports it's own queries
const filteredDsList = this.datasources.filter(ds => ds.meta.id !== 'mixed');
return (
<DataSourcePicker
datasources={filteredDsList}
mixed={false}
onChange={this.onAddMixedQuery}
current={null}
autoFocus={true}
@@ -246,8 +251,8 @@ export class QueryGroup extends PureComponent<Props, State> {
};
onAddQuery = (query: Partial<DataQuery>) => {
const { queries, onQueriesChange } = this.props;
onQueriesChange(addQuery(queries, query));
const { queries } = this.props.options;
this.onChange({ queries: addQuery(queries, query) });
this.onScrollBottom();
};
@@ -256,20 +261,24 @@ export class QueryGroup extends PureComponent<Props, State> {
this.setState({ scrollTop: target.scrollTop });
};
renderQueries() {
const { onQueriesChange, queries, onRunQueries } = this.props;
const { dataSourceItem, data } = this.state;
onQueriesChange = (queries: DataQuery[]) => {
this.onChange({ queries });
};
if (isSharedDashboardQuery(dataSourceItem.name)) {
return <DashboardQueryEditor queries={queries} panelData={data} onChange={onQueriesChange} />;
renderQueries(dsSettings: DataSourceInstanceSettings) {
const { options, onRunQueries } = this.props;
const { data } = this.state;
if (isSharedDashboardQuery(dsSettings.name)) {
return <DashboardQueryEditor queries={options.queries} panelData={data} onChange={this.onQueriesChange} />;
}
return (
<div aria-label={selectors.components.QueryTab.content}>
<QueryEditorRows
queries={queries}
datasource={dataSourceItem}
onQueriesChange={onQueriesChange}
queries={options.queries}
dsSettings={dsSettings}
onQueriesChange={this.onQueriesChange}
onAddQuery={this.onAddQuery}
onRunQueries={onRunQueries}
data={data}
@@ -278,9 +287,9 @@ export class QueryGroup extends PureComponent<Props, State> {
);
}
renderAddQueryRow() {
const { dataSourceItem, isAddingMixed } = this.state;
const showAddButton = !(isAddingMixed || isSharedDashboardQuery(dataSourceItem.name));
renderAddQueryRow(dsSettings: DataSourceInstanceSettings) {
const { isAddingMixed } = this.state;
const showAddButton = !(isAddingMixed || isSharedDashboardQuery(dsSettings.name));
return (
<HorizontalGroup spacing="md" align="flex-start">
@@ -305,7 +314,7 @@ export class QueryGroup extends PureComponent<Props, State> {
}
render() {
const { scrollTop, isHelpOpen } = this.state;
const { scrollTop, isHelpOpen, dsSettings } = this.state;
const styles = getStyles();
return (
@@ -318,13 +327,16 @@ export class QueryGroup extends PureComponent<Props, State> {
>
<div className={styles.innerWrapper}>
{this.renderTopSection(styles)}
<div className={styles.queriesWrapper}>{this.renderQueries()}</div>
{this.renderAddQueryRow()}
{isHelpOpen && (
<Modal title="Data source help" isOpen={true} onDismiss={this.onCloseHelp}>
<PluginHelp plugin={this.state.dataSourceItem.meta} type="query_help" />
</Modal>
{dsSettings && (
<>
<div className={styles.queriesWrapper}>{this.renderQueries(dsSettings)}</div>
{this.renderAddQueryRow(dsSettings)}
{isHelpOpen && (
<Modal title="Data source help" isOpen={true} onDismiss={this.onCloseHelp}>
<PluginHelp plugin={dsSettings.meta} type="query_help" />
</Modal>
)}
</>
)}
</div>
</CustomScrollbar>
@@ -2,7 +2,7 @@
import React, { PureComponent, ChangeEvent, FocusEvent } from 'react';
// Utils
import { rangeUtil, PanelData, DataSourceApi } from '@grafana/data';
import { rangeUtil, PanelData, DataSourceApi, DataQuery } from '@grafana/data';
// Components
import { Switch, Input, InlineField, InlineFormLabel, stylesFactory } from '@grafana/ui';
@@ -13,6 +13,8 @@ import { config } from 'app/core/config';
import { css } from 'emotion';
export interface QueryGroupOptions {
queries: DataQuery[];
dataSource: QueryGroupDataSource;
maxDataPoints?: number | null;
minInterval?: string | null;
cacheTimeout?: string | null;
@@ -23,6 +25,12 @@ export interface QueryGroupOptions {
};
}
interface QueryGroupDataSource {
name?: string | null;
uid?: string;
default?: boolean;
}
interface Props {
options: QueryGroupOptions;
dataSource: DataSourceApi;
+10 -34
View File
@@ -1,12 +1,4 @@
import {
ApplyFieldOverrideOptions,
DataQuery,
DataSourceSelectItem,
DataTransformerConfig,
dateMath,
FieldColorModeId,
PanelData,
} from '@grafana/data';
import { ApplyFieldOverrideOptions, DataTransformerConfig, dateMath, FieldColorModeId, PanelData } from '@grafana/data';
import { GraphNG, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import React, { FC, useMemo, useState } from 'react';
@@ -16,43 +8,29 @@ import { QueryGroupOptions } from '../query/components/QueryGroupOptions';
import { PanelQueryRunner } from '../query/state/PanelQueryRunner';
interface State {
queries: DataQuery[];
queryRunner: PanelQueryRunner;
dataSourceName: string | null;
queryOptions: QueryGroupOptions;
data?: PanelData;
}
export const TestStuffPage: FC = () => {
const [state, setState] = useState<State>(getDefaultState());
const { queryOptions, queryRunner, queries, dataSourceName } = state;
const onDataSourceChange = (ds: DataSourceSelectItem, queries: DataQuery[]) => {
setState({
...state,
dataSourceName: ds.value,
queries: queries,
});
};
const { queryOptions, queryRunner } = state;
const onRunQueries = () => {
const timeRange = { from: 'now-1h', to: 'now' };
queryRunner.run({
queries,
queries: queryOptions.queries,
datasource: queryOptions.dataSource.name!,
timezone: 'browser',
datasource: dataSourceName,
timeRange: { from: dateMath.parse(timeRange.from)!, to: dateMath.parse(timeRange.to)!, raw: timeRange },
maxDataPoints: queryOptions.maxDataPoints ?? 100,
minInterval: queryOptions.minInterval,
});
};
const onQueriesChange = (queries: DataQuery[]) => {
setState({ ...state, queries: queries });
};
const onQueryOptionsChange = (queryOptions: QueryGroupOptions) => {
const onOptionsChange = (queryOptions: QueryGroupOptions) => {
setState({ ...state, queryOptions });
};
@@ -68,13 +46,9 @@ export const TestStuffPage: FC = () => {
<div>
<QueryGroup
options={queryOptions}
dataSourceName={dataSourceName}
queryRunner={queryRunner}
queries={queries}
onDataSourceChange={onDataSourceChange}
onRunQueries={onRunQueries}
onQueriesChange={onQueriesChange}
onOptionsChange={onQueryOptionsChange}
onOptionsChange={onOptionsChange}
/>
</div>
@@ -109,10 +83,12 @@ export function getDefaultState(): State {
};
return {
queries: [],
dataSourceName: 'gdev-testdata',
queryRunner: new PanelQueryRunner(dataConfig),
queryOptions: {
queries: [],
dataSource: {
name: 'gdev-testdata',
},
maxDataPoints: 100,
},
};
@@ -478,6 +478,5 @@ function createDatasource(name: string, selectable = true): DataSourceSelectItem
meta: {
mixed: !selectable,
} as DataSourcePluginMeta,
sort: '',
};
}
@@ -27,13 +27,11 @@ describe('data source actions', () => {
name: 'first-name',
value: 'first-value',
meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }),
sort: '',
},
{
name: 'second-name',
value: 'second-value',
meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }),
sort: '',
},
];
@@ -80,13 +78,11 @@ describe('data source actions', () => {
name: 'first-name',
value: 'first-value',
meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }),
sort: '',
},
{
name: 'second-name',
value: 'second-value',
meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }),
sort: '',
},
];
@@ -134,13 +130,11 @@ describe('data source actions', () => {
name: 'first-name',
value: 'first-value',
meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }),
sort: '',
},
{
name: 'second-name',
value: 'second-value',
meta: getMockPlugin({ name: 'mock-data-name', id: 'mock-data-id' }),
sort: '',
},
{
name: 'mixed-name',
@@ -150,7 +144,6 @@ describe('data source actions', () => {
id: 'mixed-data-id',
mixed: true,
} as unknown) as DataSourcePluginMeta),
sort: '',
},
];
@@ -1,5 +1,5 @@
import React, { FormEvent, PropsWithChildren, ReactElement, useCallback } from 'react';
import { HorizontalGroup, InlineField, TextArea, useStyles } from '@grafana/ui';
import { InlineField, TextArea, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
@@ -38,16 +38,7 @@ export function VariableTextAreaField({
}, []);
return (
<HorizontalGroup spacing="none">
<InlineField
label={name}
labelWidth={labelWidth ?? 12}
grow={false}
tooltip={tooltip}
className={styles.inlineFieldOverride}
>
<span hidden />
</InlineField>
<InlineField label={name} labelWidth={labelWidth ?? 12} tooltip={tooltip}>
<TextArea
rows={getLineCount(value)}
value={value}
@@ -59,15 +50,12 @@ export function VariableTextAreaField({
cols={width}
className={styles.textarea}
/>
</HorizontalGroup>
</InlineField>
);
}
function getStyles(theme: GrafanaTheme) {
return {
inlineFieldOverride: css`
margin: 0;
`,
textarea: css`
white-space: pre-wrap;
min-height: 32px;
@@ -1,28 +0,0 @@
import React, { PropsWithChildren, useMemo } from 'react';
import { DataSourceSelectItem, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { VariableSelectField } from '../editor/VariableSelectField';
interface Props {
onChange: (option: SelectableValue<string>) => void;
datasource: string | null;
dataSources?: DataSourceSelectItem[];
}
export function QueryVariableDatasourceSelect({ onChange, datasource, dataSources }: PropsWithChildren<Props>) {
const options = useMemo(() => {
return dataSources ? dataSources.map(ds => ({ label: ds.name, value: ds.value ?? '' })) : [];
}, [dataSources]);
const value = useMemo(() => options.find(o => o.value === datasource) ?? options[0], [options, datasource]);
return (
<VariableSelectField
name="Data source"
value={value}
options={options}
onChange={onChange}
labelWidth={10}
ariaLabel={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect}
/>
);
}
@@ -9,6 +9,7 @@ import { initialVariableEditorState } from '../editor/reducer';
import { describe, expect } from '../../../../test/lib/common';
import { NEW_VARIABLE_ID } from '../state/types';
import { LegacyVariableQueryEditor } from '../editor/LegacyVariableQueryEditor';
import { setDataSourceSrv } from '@grafana/runtime';
const setupTestContext = (options: Partial<Props>) => {
const defaults: Props = {
@@ -21,7 +22,6 @@ const setupTestContext = (options: Partial<Props>) => {
...initialVariableEditorState,
extended: {
VariableQueryEditor: LegacyVariableQueryEditor,
dataSources: [],
dataSource: ({} as unknown) as DataSourceApi,
},
},
@@ -34,6 +34,11 @@ const setupTestContext = (options: Partial<Props>) => {
return { rerender, props };
};
setDataSourceSrv({
getInstanceSettings: () => null,
getList: () => [],
} as any);
describe('QueryVariableEditor', () => {
describe('when the component is mounted', () => {
it('then it should call initQueryVariableEditor', () => {
@@ -1,9 +1,9 @@
import React, { ChangeEvent, PureComponent } from 'react';
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
import { InlineFieldRow, VerticalGroup } from '@grafana/ui';
import { InlineField, InlineFieldRow, VerticalGroup } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { getTemplateSrv } from '@grafana/runtime';
import { LoadingState, SelectableValue } from '@grafana/data';
import { DataSourceInstanceSettings, LoadingState, SelectableValue } from '@grafana/data';
import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor';
import { QueryVariableModel, VariableRefresh, VariableSort, VariableWithMultiSupport } from '../types';
@@ -20,9 +20,9 @@ import { isLegacyQueryEditor, isQueryEditor } from '../guard';
import { VariableSectionHeader } from '../editor/VariableSectionHeader';
import { VariableTextField } from '../editor/VariableTextField';
import { VariableSwitchField } from '../editor/VariableSwitchField';
import { QueryVariableDatasourceSelect } from './QueryVariableDatasourceSelect';
import { QueryVariableRefreshSelect } from './QueryVariableRefreshSelect';
import { QueryVariableSortSelect } from './QueryVariableSortSelect';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
export interface OwnProps extends VariableEditorProps<QueryVariableModel> {}
@@ -65,9 +65,12 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State>
}
}
onDataSourceChange = (option: SelectableValue<string>) => {
onDataSourceChange = (dsSettings: DataSourceInstanceSettings) => {
this.props.onPropChange({ propName: 'query', propValue: '' });
this.props.onPropChange({ propName: 'datasource', propValue: option.value });
this.props.onPropChange({
propName: 'datasource',
propValue: dsSettings.isDefault ? null : dsSettings.name,
});
};
onLegacyQueryChange = async (query: any, definition: string) => {
@@ -182,19 +185,19 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State>
return (
<VerticalGroup spacing="xs">
<VariableSectionHeader name="Query Options" />
<VerticalGroup spacing="md">
<VerticalGroup spacing="lg">
<VerticalGroup spacing="none">
<VerticalGroup spacing="xs">
<InlineFieldRow>
<QueryVariableDatasourceSelect
<InlineFieldRow>
<InlineField label="Data source" labelWidth={20}>
<DataSourcePicker
current={this.props.variable.datasource}
onChange={this.onDataSourceChange}
datasource={this.props.variable.datasource}
dataSources={this.props.editor.extended?.dataSources}
variables={true}
/>
<QueryVariableRefreshSelect onChange={this.onRefreshChange} refresh={this.props.variable.refresh} />
</InlineFieldRow>
<div style={{ flexDirection: 'column' }}>{this.renderQueryEditor()}</div>
</VerticalGroup>
</InlineField>
<QueryVariableRefreshSelect onChange={this.onRefreshChange} refresh={this.props.variable.refresh} />
</InlineFieldRow>
<div style={{ flexDirection: 'column' }}>{this.renderQueryEditor()}</div>
<VariableTextField
value={this.state.regex ?? this.props.variable.regex}
name="Regex"
@@ -37,25 +37,22 @@ import { notifyApp } from '../../../core/reducers/appNotification';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { setVariableQueryRunner, VariableQueryRunner } from './VariableQueryRunner';
import { setDataSourceSrv } from '@grafana/runtime';
const mocks: Record<string, any> = {
datasource: {
metricFindQuery: jest.fn().mockResolvedValue([]),
},
datasourceSrv: {
getMetricSources: jest.fn().mockReturnValue([]),
dataSourceSrv: {
get: (name: string) => Promise.resolve(mocks[name]),
getList: jest.fn().mockReturnValue([]),
},
pluginLoader: {
importDataSourcePlugin: jest.fn().mockResolvedValue({ components: {} }),
},
};
jest.mock('../../plugins/datasource_srv', () => ({
getDatasourceSrv: jest.fn(() => ({
get: jest.fn((name: string) => mocks[name]),
getMetricSources: () => mocks.datasourceSrv.getMetricSources(),
})),
}));
setDataSourceSrv(mocks.dataSourceSrv as any);
jest.mock('../../plugins/plugin_loader', () => ({
importDataSourcePlugin: () => mocks.pluginLoader.importDataSourcePlugin(),
@@ -272,11 +269,10 @@ describe('query actions', () => {
describe('when initQueryVariableEditor is dispatched', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const defaultMetricSource = { name: '', value: '', meta: {}, sort: '' };
const testMetricSource = { name: 'test', value: 'test', meta: {}, sort: '' };
const testMetricSource = { name: 'test', value: 'test', meta: {} };
const editor = {};
mocks.datasourceSrv.getMetricSources = jest.fn().mockReturnValue([testMetricSource]);
mocks.dataSourceSrv.getList = jest.fn().mockReturnValue([testMetricSource]);
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
components: { VariableQueryEditor: editor },
});
@@ -287,12 +283,9 @@ describe('query actions', () => {
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [updateDatasources, setDatasource, setEditor] = actions;
const expectedNumberOfActions = 3;
const [setDatasource, setEditor] = actions;
const expectedNumberOfActions = 2;
expect(updateDatasources).toEqual(
changeVariableEditorExtended({ propName: 'dataSources', propValue: [defaultMetricSource, testMetricSource] })
);
expect(setDatasource).toEqual(
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks['datasource'] })
);
@@ -305,11 +298,10 @@ describe('query actions', () => {
describe('when initQueryVariableEditor is dispatched and metricsource without value is available', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const defaultMetricSource = { name: '', value: '', meta: {}, sort: '' };
const testMetricSource = { name: 'test', value: (null as unknown) as string, meta: {}, sort: '' };
const testMetricSource = { name: 'test', value: (null as unknown) as string, meta: {} };
const editor = {};
mocks.datasourceSrv.getMetricSources = jest.fn().mockReturnValue([testMetricSource]);
mocks.dataSourceSrv.getList = jest.fn().mockReturnValue([testMetricSource]);
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
components: { VariableQueryEditor: editor },
});
@@ -320,12 +312,9 @@ describe('query actions', () => {
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [updateDatasources, setDatasource, setEditor] = actions;
const expectedNumberOfActions = 3;
const [setDatasource, setEditor] = actions;
const expectedNumberOfActions = 2;
expect(updateDatasources).toEqual(
changeVariableEditorExtended({ propName: 'dataSources', propValue: [defaultMetricSource] })
);
expect(setDatasource).toEqual(
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks['datasource'] })
);
@@ -338,10 +327,9 @@ describe('query actions', () => {
describe('when initQueryVariableEditor is dispatched and no metric sources was found', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const defaultDatasource = { name: '', value: '', meta: {}, sort: '' };
const editor = {};
mocks.datasourceSrv.getMetricSources = jest.fn().mockReturnValue([]);
mocks.dataSourceSrv.getList = jest.fn().mockReturnValue([]);
mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({
components: { VariableQueryEditor: editor },
});
@@ -352,12 +340,9 @@ describe('query actions', () => {
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [updateDatasources, setDatasource, setEditor] = actions;
const expectedNumberOfActions = 3;
const [setDatasource, setEditor] = actions;
const expectedNumberOfActions = 2;
expect(updateDatasources).toEqual(
changeVariableEditorExtended({ propName: 'dataSources', propValue: [defaultDatasource] })
);
expect(setDatasource).toEqual(
changeVariableEditorExtended({ propName: 'dataSource', propValue: mocks['datasource'] })
);
@@ -370,7 +355,6 @@ describe('query actions', () => {
describe('when initQueryVariableEditor is dispatched and variable dont have datasource', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ datasource: undefined });
const ds = { name: '', value: '', meta: {}, sort: '' };
const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getRootReducer())
@@ -378,10 +362,10 @@ describe('query actions', () => {
.whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true);
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
const [updateDatasources] = actions;
const [setDatasource] = actions;
const expectedNumberOfActions = 1;
expect(updateDatasources).toEqual(changeVariableEditorExtended({ propName: 'dataSources', propValue: [ds] }));
expect(setDatasource).toEqual(changeVariableEditorExtended({ propName: 'dataSource', propValue: undefined }));
return actions.length === expectedNumberOfActions;
});
});
+3 -16
View File
@@ -1,10 +1,8 @@
import { DataSourcePluginMeta, DataSourceSelectItem } from '@grafana/data';
import { toDataQueryError } from '@grafana/runtime';
import { updateOptions } from '../state/actions';
import { QueryVariableModel } from '../types';
import { ThunkResult } from '../../../types';
import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { getDataSourceSrv } from '@grafana/runtime';
import { getVariable } from '../state/selectors';
import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer';
import { changeVariableProp } from '../state/sharedReducer';
@@ -24,7 +22,7 @@ export const updateQueryVariableOptions = (
if (getState().templating.editor.id === variableInState.id) {
dispatch(removeVariableEditorError({ errorProp: 'update' }));
}
const datasource = await getDatasourceSrv().get(variableInState.datasource ?? '');
const datasource = await getDataSourceSrv().get(variableInState.datasource ?? '');
// we need to await the result from variableQueryRunner before moving on otherwise variables dependent on this
// variable will have the wrong current value as input
@@ -53,18 +51,7 @@ export const initQueryVariableEditor = (identifier: VariableIdentifier): ThunkRe
dispatch,
getState
) => {
const dataSources: DataSourceSelectItem[] = getDatasourceSrv()
.getMetricSources()
.filter(ds => !ds.meta.mixed && ds.value !== null);
const defaultDatasource: DataSourceSelectItem = { name: '', value: '', meta: {} as DataSourcePluginMeta, sort: '' };
const allDataSources = [defaultDatasource].concat(dataSources);
dispatch(changeVariableEditorExtended({ propName: 'dataSources', propValue: allDataSources }));
const variable = getVariable<QueryVariableModel>(identifier.id, getState());
if (!variable.datasource) {
return;
}
await dispatch(changeQueryVariableDataSource(toVariableIdentifier(variable), variable.datasource));
};
@@ -74,7 +61,7 @@ export const changeQueryVariableDataSource = (
): ThunkResult<void> => {
return async (dispatch, getState) => {
try {
const dataSource = await getDatasourceSrv().get(name ?? '');
const dataSource = await getDataSourceSrv().get(name ?? '');
dispatch(changeVariableEditorExtended({ propName: 'dataSource', propValue: dataSource }));
const VariableQueryEditor = await getVariableQueryEditor(dataSource);
@@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import _ from 'lodash';
import { DataSourceApi, DataSourceSelectItem, MetricFindValue, stringToJsRegex } from '@grafana/data';
import { DataSourceApi, MetricFindValue, stringToJsRegex } from '@grafana/data';
import {
initialVariableModelState,
@@ -29,7 +29,6 @@ interface VariableOptionsUpdate {
export interface QueryVariableEditorState {
VariableQueryEditor: VariableQueryEditorType;
dataSources: DataSourceSelectItem[];
dataSource: DataSourceApi | null;
}
@@ -61,6 +61,7 @@ import { expect } from '../../../../test/lib/common';
import { ConstantVariableModel, VariableRefresh } from '../types';
import { updateVariableOptions } from '../query/reducer';
import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner';
import { setDataSourceSrv } from '@grafana/runtime';
variableAdapters.setInit(() => [
createQueryVariableAdapter(),
@@ -82,12 +83,10 @@ jest.mock('app/features/dashboard/services/TimeSrv', () => ({
}),
}));
jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: jest.fn(() => ({
get: getDatasource,
getMetricSources,
})),
}));
setDataSourceSrv({
get: getDatasource,
getList: getMetricSources,
} as any);
describe('shared actions', () => {
describe('when initDashboardTemplating is dispatched', () => {
@@ -14,6 +14,7 @@ import { updateVariableOptions } from '../query/reducer';
import { customBuilder, queryBuilder } from '../shared/testing/builders';
import { variablesInitTransaction } from './transactionReducer';
import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner';
import { setDataSourceSrv } from '@grafana/runtime';
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
getTimeSrv: jest.fn().mockReturnValue({
@@ -28,39 +29,37 @@ jest.mock('app/features/dashboard/services/TimeSrv', () => ({
}),
}));
jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: () => ({
get: jest.fn().mockResolvedValue({
metricFindQuery: jest.fn().mockImplementation((query, options) => {
if (query === '$custom.*') {
return Promise.resolve([
{ value: 'AA', text: 'AA' },
{ value: 'AB', text: 'AB' },
{ value: 'AC', text: 'AC' },
]);
}
setDataSourceSrv({
get: jest.fn().mockResolvedValue({
metricFindQuery: jest.fn().mockImplementation((query, options) => {
if (query === '$custom.*') {
return Promise.resolve([
{ value: 'AA', text: 'AA' },
{ value: 'AB', text: 'AB' },
{ value: 'AC', text: 'AC' },
]);
}
if (query === '$custom.$queryDependsOnCustom.*') {
return Promise.resolve([
{ value: 'AAA', text: 'AAA' },
{ value: 'AAB', text: 'AAB' },
{ value: 'AAC', text: 'AAC' },
]);
}
if (query === '$custom.$queryDependsOnCustom.*') {
return Promise.resolve([
{ value: 'AAA', text: 'AAA' },
{ value: 'AAB', text: 'AAB' },
{ value: 'AAC', text: 'AAC' },
]);
}
if (query === '*') {
return Promise.resolve([
{ value: 'A', text: 'A' },
{ value: 'B', text: 'B' },
{ value: 'C', text: 'C' },
]);
}
if (query === '*') {
return Promise.resolve([
{ value: 'A', text: 'A' },
{ value: 'B', text: 'B' },
{ value: 'C', text: 'C' },
]);
}
return Promise.resolve([]);
}),
return Promise.resolve([]);
}),
}),
}));
} as any);
variableAdapters.setInit(() => [createCustomVariableAdapter(), createQueryVariableAdapter()]);
@@ -1,231 +0,0 @@
import coreModule from 'app/core/core_module';
import _ from 'lodash';
import * as queryDef from './query_def';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { CoreEvents } from 'app/types';
export class ElasticBucketAggCtrl {
/** @ngInject */
constructor($scope: any, uiSegmentSrv: any, $rootScope: GrafanaRootScope) {
const bucketAggs = $scope.target.bucketAggs;
$scope.orderByOptions = [];
$scope.getBucketAggTypes = () => {
return queryDef.bucketAggTypes;
};
$scope.getOrderOptions = () => {
return queryDef.orderOptions;
};
$scope.getSizeOptions = () => {
return queryDef.sizeOptions;
};
$rootScope.onAppEvent(
CoreEvents.elasticQueryUpdated,
() => {
$scope.validateModel();
},
$scope
);
$scope.init = () => {
$scope.agg = bucketAggs[$scope.index] || {};
$scope.validateModel();
};
$scope.onChangeInternal = () => {
$scope.onChange();
};
$scope.onTypeChanged = () => {
$scope.agg.settings = {};
$scope.showOptions = false;
switch ($scope.agg.type) {
case 'date_histogram':
case 'histogram':
case 'terms': {
delete $scope.agg.query;
$scope.agg.field = 'select field';
break;
}
case 'filters': {
delete $scope.agg.field;
$scope.agg.query = '*';
break;
}
case 'geohash_grid': {
$scope.agg.settings.precision = 3;
break;
}
}
$scope.validateModel();
$scope.onChange();
};
$scope.validateModel = () => {
$scope.index = _.indexOf(bucketAggs, $scope.agg);
$scope.isFirst = $scope.index === 0;
$scope.bucketAggCount = bucketAggs.length;
let settingsLinkText = '';
const settings = $scope.agg.settings || {};
switch ($scope.agg.type) {
case 'terms': {
settings.order = settings.order || 'desc';
settings.size = settings.size || '10';
settings.min_doc_count = settings.min_doc_count || 0;
settings.orderBy = settings.orderBy || '_term';
if (settings.size !== '0') {
settingsLinkText = queryDef.describeOrder(settings.order) + ' ' + settings.size + ', ';
}
if (settings.min_doc_count > 0) {
settingsLinkText += 'Min Doc Count: ' + settings.min_doc_count + ', ';
}
settingsLinkText += 'Order by: ' + queryDef.describeOrderBy(settings.orderBy, $scope.target);
if (settings.size === '0') {
settingsLinkText += ' (' + settings.order + ')';
}
break;
}
case 'filters': {
settings.filters = settings.filters || [{ query: '*' }];
settingsLinkText = _.reduce(
settings.filters,
(memo, value, index) => {
memo += 'Q' + (index + 1) + ' = ' + value.query + ' ';
return memo;
},
''
);
if (settingsLinkText.length > 50) {
settingsLinkText = settingsLinkText.substr(0, 50) + '...';
}
settingsLinkText = 'Filter Queries (' + settings.filters.length + ')';
break;
}
case 'date_histogram': {
settings.interval = settings.interval || 'auto';
settings.min_doc_count = settings.min_doc_count || 0;
$scope.agg.field = $scope.target.timeField;
settingsLinkText = 'Interval: ' + settings.interval;
if (settings.min_doc_count > 0) {
settingsLinkText += ', Min Doc Count: ' + settings.min_doc_count;
}
if (settings.trimEdges === undefined || settings.trimEdges < 0) {
settings.trimEdges = 0;
}
if (settings.trimEdges && settings.trimEdges > 0) {
settingsLinkText += ', Trim edges: ' + settings.trimEdges;
}
break;
}
case 'histogram': {
settings.interval = settings.interval || 1000;
settings.min_doc_count = _.defaultTo(settings.min_doc_count, 1);
settingsLinkText = 'Interval: ' + settings.interval;
if (settings.min_doc_count > 0) {
settingsLinkText += ', Min Doc Count: ' + settings.min_doc_count;
}
break;
}
case 'geohash_grid': {
// limit precision to 12
settings.precision = Math.max(Math.min(settings.precision, 12), 1);
settingsLinkText = 'Precision: ' + settings.precision;
break;
}
}
$scope.settingsLinkText = settingsLinkText;
$scope.agg.settings = settings;
return true;
};
$scope.addFiltersQuery = () => {
$scope.agg.settings.filters.push({ query: '*' });
};
$scope.removeFiltersQuery = (filter: any) => {
$scope.agg.settings.filters = _.without($scope.agg.settings.filters, filter);
};
$scope.toggleOptions = () => {
$scope.showOptions = !$scope.showOptions;
};
$scope.getOrderByOptions = () => {
return queryDef.getOrderByOptions($scope.target);
};
$scope.getFieldsInternal = () => {
if ($scope.agg.type === 'date_histogram') {
return $scope.getFields({ $fieldType: 'date' });
} else {
return $scope.getFields();
}
};
$scope.getIntervalOptions = () => {
return Promise.resolve(uiSegmentSrv.transformToSegments(true, 'interval')(queryDef.intervalOptions));
};
$scope.addBucketAgg = () => {
// if last is date histogram add it before
const lastBucket = bucketAggs[bucketAggs.length - 1];
let addIndex = bucketAggs.length - 1;
if (lastBucket && lastBucket.type === 'date_histogram') {
addIndex -= 1;
}
const id = _.reduce(
$scope.target.bucketAggs.concat($scope.target.metrics),
(max, val) => {
return parseInt(val.id, 10) > max ? parseInt(val.id, 10) : max;
},
0
);
bucketAggs.splice(addIndex, 0, { type: 'terms', field: 'select field', id: (id + 1).toString(), fake: true });
$scope.onChange();
};
$scope.removeBucketAgg = () => {
bucketAggs.splice($scope.index, 1);
$scope.onChange();
};
$scope.init();
}
}
export function elasticBucketAgg() {
return {
templateUrl: 'public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html',
controller: ElasticBucketAggCtrl,
restrict: 'E',
scope: {
target: '=',
index: '=',
onChange: '&',
getFields: '&',
},
};
}
coreModule.directive('elasticBucketAgg', elasticBucketAgg);
@@ -0,0 +1,42 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { AddRemove } from './AddRemove';
const noop = () => {};
const TestComponent = ({ items }: { items: any[] }) => (
<>
{items.map((_, index) => (
<AddRemove key={index} elements={items} index={index} onAdd={noop} onRemove={noop} />
))}
</>
);
describe('AddRemove Button', () => {
describe("When There's only one element in the list", () => {
it('Should only show the add button', () => {
render(<TestComponent items={['something']} />);
expect(screen.getByText('add')).toBeInTheDocument();
expect(screen.queryByText('remove')).not.toBeInTheDocument();
});
});
describe("When There's more than one element in the list", () => {
it('Should show the remove button on every element', () => {
const items = ['something', 'something else'];
render(<TestComponent items={items} />);
expect(screen.getAllByText('remove')).toHaveLength(items.length);
});
it('Should show the add button only once', () => {
const items = ['something', 'something else'];
render(<TestComponent items={items} />);
expect(screen.getAllByText('add')).toHaveLength(1);
});
});
});
@@ -0,0 +1,28 @@
import { css } from 'emotion';
import React, { FunctionComponent } from 'react';
import { IconButton } from './IconButton';
interface Props {
index: number;
elements: any[];
onAdd: () => void;
onRemove: () => void;
}
/**
* A component used to show add & remove buttons for mutable lists of values. Wether to show or not the add or the remove buttons
* depends on the `index` and `elements` props. This enforces a consistent experience whenever this pattern is used.
*/
export const AddRemove: FunctionComponent<Props> = ({ index, onAdd, onRemove, elements }) => {
return (
<div
className={css`
display: flex;
`}
>
{index === 0 && <IconButton iconName="plus" onClick={onAdd} label="add" />}
{elements.length >= 2 && <IconButton iconName="minus" onClick={onRemove} label="remove" />}
</div>
);
};
@@ -1,85 +0,0 @@
import _ from 'lodash';
import React from 'react';
import { QueryField, SlatePrism } from '@grafana/ui';
import { ExploreQueryFieldProps } from '@grafana/data';
import { ElasticDatasource } from '../datasource';
import { ElasticsearchOptions, ElasticsearchQuery } from '../types';
interface Props extends ExploreQueryFieldProps<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions> {}
interface State {
syntaxLoaded: boolean;
}
class ElasticsearchQueryField extends React.PureComponent<Props, State> {
plugins: any[];
constructor(props: Props, context: React.Context<any>) {
super(props, context);
this.plugins = [
SlatePrism({
onlyIn: (node: any) => node.type === 'code_block',
getSyntax: (node: any) => 'lucene',
}),
];
this.state = {
syntaxLoaded: false,
};
}
componentDidMount() {
if (!this.props.query.isLogsQuery) {
this.onChangeQuery('', true);
}
}
componentWillUnmount() {}
componentDidUpdate(prevProps: Props) {
// if query changed from the outside (i.e. cleared via explore toolbar)
if (!this.props.query.isLogsQuery) {
this.onChangeQuery('', true);
}
}
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { query, onChange, onRunQuery } = this.props;
if (onChange) {
const nextQuery: ElasticsearchQuery = { ...query, query: value, isLogsQuery: true };
onChange(nextQuery);
if (override && onRunQuery) {
onRunQuery();
}
}
};
render() {
const { query } = this.props;
const { syntaxLoaded } = this.state;
return (
<>
<div className="gf-form-inline gf-form-inline--nowrap">
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
additionalPlugins={this.plugins}
query={query.query}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
placeholder="Enter a Lucene query (run with Shift+Enter)"
portalOrigin="elasticsearch"
syntaxLoaded={syntaxLoaded}
/>
</div>
</div>
</>
);
}
}
export default ElasticsearchQueryField;
@@ -0,0 +1,33 @@
import { Icon } from '@grafana/ui';
import { cx, css } from 'emotion';
import React, { FunctionComponent, ComponentProps, ButtonHTMLAttributes } from 'react';
const SROnly = css`
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
`;
interface Props {
iconName: ComponentProps<typeof Icon>['name'];
onClick: () => void;
className?: string;
label: string;
}
export const IconButton: FunctionComponent<Props & ButtonHTMLAttributes<HTMLButtonElement>> = ({
iconName,
onClick,
className,
label,
...buttonProps
}) => (
<button className={cx('gf-form-label gf-form-label--btn query-part', className)} onClick={onClick} {...buttonProps}>
<span className={SROnly}>{label}</span>
<Icon name={iconName} aria-hidden="true" />
</button>
);
@@ -0,0 +1,34 @@
import React, { FunctionComponent } from 'react';
import { css, cx } from 'emotion';
import { SelectableValue } from '@grafana/data';
import { Segment } from '@grafana/ui';
import { describeMetric } from '../utils';
import { MetricAggregation } from './QueryEditor/MetricAggregationsEditor/aggregations';
const noWrap = css`
white-space: nowrap;
`;
const toOption = (metric: MetricAggregation) => ({
label: describeMetric(metric),
value: metric,
});
const toOptions = (metrics: MetricAggregation[]): Array<SelectableValue<MetricAggregation>> => metrics.map(toOption);
interface Props {
options: MetricAggregation[];
onChange: (e: SelectableValue<MetricAggregation>) => void;
className?: string;
value?: string;
}
export const MetricPicker: FunctionComponent<Props> = ({ options, onChange, className, value }) => (
<Segment
className={cx(className, noWrap)}
options={toOptions(options)}
onChange={onChange}
placeholder="Select Metric"
value={!!value ? toOption(options.find(option => option.id === value)!) : null}
/>
);
@@ -0,0 +1,76 @@
import { MetricFindValue, SelectableValue } from '@grafana/data';
import { Segment, SegmentAsync } from '@grafana/ui';
import React, { FunctionComponent } from 'react';
import { useDispatch } from '../../../hooks/useStatelessReducer';
import { useDatasource } from '../ElasticsearchQueryContext';
import { segmentStyles } from '../styles';
import { BucketAggregation, BucketAggregationType, isBucketAggregationWithField } from './aggregations';
import { SettingsEditor } from './SettingsEditor';
import { changeBucketAggregationField, changeBucketAggregationType } from './state/actions';
import { BucketAggregationAction } from './state/types';
import { bucketAggregationConfig } from './utils';
const bucketAggOptions: Array<SelectableValue<BucketAggregationType>> = Object.entries(bucketAggregationConfig).map(
([key, { label }]) => ({
label,
value: key as BucketAggregationType,
})
);
const toSelectableValue = ({ value, text }: MetricFindValue): SelectableValue<string> => ({
label: text,
value: `${value || text}`,
});
const toOption = (bucketAgg: BucketAggregation) => ({
label: bucketAggregationConfig[bucketAgg.type].label,
value: bucketAgg.type,
});
interface QueryMetricEditorProps {
value: BucketAggregation;
}
export const BucketAggregationEditor: FunctionComponent<QueryMetricEditorProps> = ({ value }) => {
const datasource = useDatasource();
const dispatch = useDispatch<BucketAggregationAction>();
// TODO: Move this in a separate hook (and simplify)
const getFields = async () => {
const get = () => {
switch (value.type) {
case 'date_histogram':
return datasource.getFields('date');
case 'geohash_grid':
return datasource.getFields('geo_point');
default:
return datasource.getFields();
}
};
return (await get()).map(toSelectableValue);
};
return (
<>
<Segment
className={segmentStyles}
options={bucketAggOptions}
onChange={e => dispatch(changeBucketAggregationType(value.id, e.value!))}
value={toOption(value)}
/>
{isBucketAggregationWithField(value) && (
<SegmentAsync
className={segmentStyles}
loadOptions={getFields}
onChange={e => dispatch(changeBucketAggregationField(value.id, e.value))}
placeholder="Select Field"
value={value.field}
/>
)}
<SettingsEditor bucketAgg={value} />
</>
);
};
@@ -0,0 +1,81 @@
import { InlineField, Input, QueryField } from '@grafana/ui';
import { css } from 'emotion';
import React, { FunctionComponent, useEffect } from 'react';
import { AddRemove } from '../../../../AddRemove';
import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer';
import { Filters } from '../../aggregations';
import { changeBucketAggregationSetting } from '../../state/actions';
import { BucketAggregationAction } from '../../state/types';
import { addFilter, changeFilter, removeFilter } from './state/actions';
import { reducer as filtersReducer } from './state/reducer';
interface Props {
value: Filters;
}
export const FiltersSettingsEditor: FunctionComponent<Props> = ({ value }) => {
const upperStateDispatch = useDispatch<BucketAggregationAction<Filters>>();
const dispatch = useStatelessReducer(
newState => upperStateDispatch(changeBucketAggregationSetting(value, 'filters', newState)),
value.settings?.filters,
filtersReducer
);
// The model might not have filters (or an empty array of filters) in it because of the way it was built in previous versions of the datasource.
// If this is the case we add a default one.
useEffect(() => {
if (!value.settings?.filters?.length) {
dispatch(addFilter());
}
}, []);
return (
<>
<div
className={css`
display: flex;
flex-direction: column;
`}
>
{value.settings?.filters!.map((filter, index) => (
<div
key={index}
className={css`
display: flex;
`}
>
<div
className={css`
width: 250px;
`}
>
<InlineField label="Query" labelWidth={10}>
<QueryField
placeholder="Lucene Query"
portalOrigin="elasticsearch"
onBlur={() => {}}
onChange={query => dispatch(changeFilter(index, { ...filter, query }))}
query={filter.query}
/>
</InlineField>
</div>
<InlineField label="Label" labelWidth={10}>
<Input
placeholder="Label"
onBlur={e => dispatch(changeFilter(index, { ...filter, label: e.target.value }))}
defaultValue={filter.label}
/>
</InlineField>
<AddRemove
index={index}
elements={value.settings?.filters || []}
onAdd={() => dispatch(addFilter())}
onRemove={() => dispatch(removeFilter(index))}
/>
</div>
))}
</div>
</>
);
};
@@ -0,0 +1,16 @@
import { Filter } from '../../../aggregations';
import { FilterAction, ADD_FILTER, REMOVE_FILTER, CHANGE_FILTER } from './types';
export const addFilter = (): FilterAction => ({
type: ADD_FILTER,
});
export const removeFilter = (index: number): FilterAction => ({
type: REMOVE_FILTER,
payload: { index },
});
export const changeFilter = (index: number, filter: Filter): FilterAction => ({
type: CHANGE_FILTER,
payload: { index, filter },
});
@@ -0,0 +1,52 @@
import { reducerTester } from 'test/core/redux/reducerTester';
import { Filter } from '../../../aggregations';
import { addFilter, changeFilter, removeFilter } from './actions';
import { reducer } from './reducer';
describe('Filters Bucket Aggregation Settings Reducer', () => {
it('Should correctly add new filter', () => {
reducerTester()
.givenReducer(reducer, [])
.whenActionIsDispatched(addFilter())
.thenStatePredicateShouldEqual((state: Filter[]) => state.length === 1);
});
it('Should correctly remove filters', () => {
const firstFilter: Filter = {
label: 'First',
query: '*',
};
const secondFilter: Filter = {
label: 'Second',
query: '*',
};
reducerTester()
.givenReducer(reducer, [firstFilter, secondFilter])
.whenActionIsDispatched(removeFilter(0))
.thenStateShouldEqual([secondFilter]);
});
it("Should correctly change filter's attributes", () => {
const firstFilter: Filter = {
label: 'First',
query: '*',
};
const secondFilter: Filter = {
label: 'Second',
query: '*',
};
const expectedSecondFilter: Filter = {
label: 'Changed label',
query: 'Changed query',
};
reducerTester()
.givenReducer(reducer, [firstFilter, secondFilter])
.whenActionIsDispatched(changeFilter(1, expectedSecondFilter))
.thenStateShouldEqual([firstFilter, expectedSecondFilter]);
});
});
@@ -0,0 +1,21 @@
import { Filter } from '../../../aggregations';
import { defaultFilter } from '../utils';
import { ADD_FILTER, CHANGE_FILTER, FilterAction, REMOVE_FILTER } from './types';
export const reducer = (state: Filter[] = [], action: FilterAction) => {
switch (action.type) {
case ADD_FILTER:
return [...state, defaultFilter()];
case REMOVE_FILTER:
return state.slice(0, action.payload.index).concat(state.slice(action.payload.index + 1));
case CHANGE_FILTER:
return state.map((filter, index) => {
if (index !== action.payload.index) {
return filter;
}
return action.payload.filter;
});
}
};
@@ -0,0 +1,22 @@
import { Action } from '../../../../../../hooks/useStatelessReducer';
import { Filter } from '../../../aggregations';
export const ADD_FILTER = '@bucketAggregations/filter/add';
export const REMOVE_FILTER = '@bucketAggregations/filter/remove';
export const CHANGE_FILTER = '@bucketAggregations/filter/change';
export type AddFilterAction = Action<typeof ADD_FILTER>;
export interface RemoveFilterAction extends Action<typeof REMOVE_FILTER> {
payload: {
index: number;
};
}
export interface ChangeFilterAction extends Action<typeof CHANGE_FILTER> {
payload: {
index: number;
filter: Filter;
};
}
export type FilterAction = AddFilterAction | RemoveFilterAction | ChangeFilterAction;
@@ -0,0 +1,3 @@
import { Filter } from '../../aggregations';
export const defaultFilter = (): Filter => ({ label: '', query: '*' });
@@ -0,0 +1,160 @@
import { InlineField, Input, Select } from '@grafana/ui';
import React, { ComponentProps, FunctionComponent } from 'react';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { SettingsEditorContainer } from '../../SettingsEditorContainer';
import { changeBucketAggregationSetting } from '../state/actions';
import { BucketAggregation } from '../aggregations';
import { bucketAggregationConfig, intervalOptions, orderByOptions, orderOptions, sizeOptions } from '../utils';
import { FiltersSettingsEditor } from './FiltersSettingsEditor';
import { useDescription } from './useDescription';
import { useQuery } from '../../ElasticsearchQueryContext';
import { describeMetric } from '../../../../utils';
const inlineFieldProps: Partial<ComponentProps<typeof InlineField>> = {
labelWidth: 16,
};
interface Props {
bucketAgg: BucketAggregation;
}
export const SettingsEditor: FunctionComponent<Props> = ({ bucketAgg }) => {
const dispatch = useDispatch();
const { metrics } = useQuery();
const settingsDescription = useDescription(bucketAgg);
const orderBy = [...orderByOptions, ...(metrics || []).map(m => ({ label: describeMetric(m), value: m.id }))];
return (
<SettingsEditorContainer label={settingsDescription}>
{bucketAgg.type === 'terms' && (
<>
<InlineField label="Order" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'order', e.value!))}
options={orderOptions}
value={bucketAgg.settings?.order || bucketAggregationConfig[bucketAgg.type].defaultSettings?.order}
/>
</InlineField>
<InlineField label="Size" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'size', e.value!))}
options={sizeOptions}
value={bucketAgg.settings?.size || bucketAggregationConfig[bucketAgg.type].defaultSettings?.size}
allowCustomValue
/>
</InlineField>
<InlineField label="Min Doc Count" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))}
defaultValue={
bucketAgg.settings?.min_doc_count ||
bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count
}
/>
</InlineField>
<InlineField label="Order By" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'orderBy', e.value!))}
options={orderBy}
value={bucketAgg.settings?.orderBy || bucketAggregationConfig[bucketAgg.type].defaultSettings?.orderBy}
/>
</InlineField>
<InlineField label="Missing" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'missing', e.target.value!))}
defaultValue={
bucketAgg.settings?.missing || bucketAggregationConfig[bucketAgg.type].defaultSettings?.missing
}
/>
</InlineField>
</>
)}
{bucketAgg.type === 'geohash_grid' && (
<InlineField label="Precision" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'precision', e.target.value!))}
defaultValue={
bucketAgg.settings?.precision || bucketAggregationConfig[bucketAgg.type].defaultSettings?.precision
}
/>
</InlineField>
)}
{bucketAgg.type === 'date_histogram' && (
<>
<InlineField label="Interval" {...inlineFieldProps}>
<Select
onChange={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'interval', e.value!))}
options={intervalOptions}
value={bucketAgg.settings?.interval || bucketAggregationConfig[bucketAgg.type].defaultSettings?.interval}
allowCustomValue
/>
</InlineField>
<InlineField label="Min Doc Count" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))}
defaultValue={
bucketAgg.settings?.min_doc_count ||
bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count
}
/>
</InlineField>
<InlineField label="Trim Edges" {...inlineFieldProps} tooltip="Trim the edges on the timeseries datapoints">
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'trimEdges', e.target.value!))}
defaultValue={
bucketAgg.settings?.trimEdges || bucketAggregationConfig[bucketAgg.type].defaultSettings?.trimEdges
}
/>
</InlineField>
<InlineField
label="Offset"
{...inlineFieldProps}
tooltip="Change the start value of each bucket by the specified positive (+) or negative offset (-) duration, such as 1h for an hour, or 1d for a day"
>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'offset', e.target.value!))}
defaultValue={
bucketAgg.settings?.offset || bucketAggregationConfig[bucketAgg.type].defaultSettings?.offset
}
/>
</InlineField>
</>
)}
{bucketAgg.type === 'histogram' && (
<>
<InlineField label="Interval" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'interval', e.target.value!))}
defaultValue={
bucketAgg.settings?.interval || bucketAggregationConfig[bucketAgg.type].defaultSettings?.interval
}
/>
</InlineField>
<InlineField label="Min Doc Count" {...inlineFieldProps}>
<Input
onBlur={e => dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))}
defaultValue={
bucketAgg.settings?.min_doc_count ||
bucketAggregationConfig[bucketAgg.type].defaultSettings?.min_doc_count
}
/>
</InlineField>
</>
)}
{bucketAgg.type === 'filters' && <FiltersSettingsEditor value={bucketAgg} />}
</SettingsEditorContainer>
);
};
@@ -0,0 +1,89 @@
import { describeMetric } from '../../../../utils';
import { useQuery } from '../../ElasticsearchQueryContext';
import { BucketAggregation } from '../aggregations';
import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils';
const hasValue = (value: string) => (object: { value: string }) => object.value === value;
// FIXME: We should apply the same defaults we have in bucketAggregationsConfig here instead of "custom" values
// as they might get out of sync.
// The reason we need them is that even though after the refactoring each setting is created with its default value,
// queries created with the old version might not have them.
export const useDescription = (bucketAgg: BucketAggregation): string => {
const { metrics } = useQuery();
switch (bucketAgg.type) {
case 'terms': {
const order = bucketAgg.settings?.order || 'desc';
const size = bucketAgg.settings?.size || '10';
const minDocCount = parseInt(bucketAgg.settings?.min_doc_count || '0', 10);
const orderBy = bucketAgg.settings?.orderBy || '_term';
let description = '';
if (size !== '0') {
const orderLabel = orderOptions.find(hasValue(order))?.label!;
description = `${orderLabel} ${size}, `;
}
if (minDocCount > 0) {
description += `Min Doc Count: ${minDocCount}, `;
}
description += 'Order by: ';
const orderByOption = orderByOptions.find(hasValue(orderBy));
if (orderByOption) {
description += orderByOption.label;
} else {
const metric = metrics?.find(m => m.id === orderBy);
if (metric) {
description += describeMetric(metric);
} else {
description += 'metric not found';
}
}
if (size === '0') {
description += ` (${order})`;
}
return description;
}
case 'histogram': {
const interval = bucketAgg.settings?.interval || 1000;
const minDocCount = bucketAgg.settings?.min_doc_count || 1;
return `Interval: ${interval}${minDocCount > 0 ? `, Min Doc Count: ${minDocCount}` : ''}`;
}
case 'filters': {
const filters = bucketAgg.settings?.filters || bucketAggregationConfig['filters'].defaultSettings?.filters;
return `Filter Queries (${filters!.length})`;
}
case 'geohash_grid': {
const precision = Math.max(Math.min(parseInt(bucketAgg.settings?.precision || '5', 10), 12), 1);
return `Precision: ${precision}`;
}
case 'date_histogram': {
const interval = bucketAgg.settings?.interval || 'auto';
const minDocCount = bucketAgg.settings?.min_doc_count || 0;
const trimEdges = bucketAgg.settings?.trimEdges || 0;
let description = `Interval: ${interval}`;
if (minDocCount > 0) {
description += `, Min Doc Count: ${minDocCount}`;
}
if (trimEdges > 0) {
description += `, Trim edges: ${trimEdges}`;
}
return description;
}
default:
return 'Settings';
}
};
@@ -0,0 +1,68 @@
import { bucketAggregationConfig } from './utils';
export type BucketAggregationType = 'terms' | 'filters' | 'geohash_grid' | 'date_histogram' | 'histogram';
interface BaseBucketAggregation {
id: string;
type: BucketAggregationType;
settings?: Record<string, unknown>;
}
export interface BucketAggregationWithField extends BaseBucketAggregation {
field?: string;
}
export interface DateHistogram extends BucketAggregationWithField {
type: 'date_histogram';
settings?: {
interval?: string;
min_doc_count?: string;
trimEdges?: string;
offset?: string;
};
}
export interface Histogram extends BucketAggregationWithField {
type: 'histogram';
settings?: {
interval?: string;
min_doc_count?: string;
};
}
type TermsOrder = 'desc' | 'asc';
export interface Terms extends BucketAggregationWithField {
type: 'terms';
settings?: {
order?: TermsOrder;
size?: string;
min_doc_count?: string;
orderBy?: string;
missing?: string;
};
}
export type Filter = {
query: string;
label: string;
};
export interface Filters extends BaseBucketAggregation {
type: 'filters';
settings?: {
filters?: Filter[];
};
}
interface GeoHashGrid extends BucketAggregationWithField {
type: 'geohash_grid';
settings?: {
precision?: string;
};
}
export type BucketAggregation = DateHistogram | Histogram | Terms | Filters | GeoHashGrid;
export const isBucketAggregationWithField = (
bucketAgg: BucketAggregation | BucketAggregationWithField
): bucketAgg is BucketAggregationWithField => bucketAggregationConfig[bucketAgg.type].requiresField;
@@ -0,0 +1,37 @@
import React, { FunctionComponent } from 'react';
import { BucketAggregationEditor } from './BucketAggregationEditor';
import { useDispatch } from '../../../hooks/useStatelessReducer';
import { addBucketAggregation, removeBucketAggregation } from './state/actions';
import { BucketAggregationAction } from './state/types';
import { BucketAggregation } from './aggregations';
import { useQuery } from '../ElasticsearchQueryContext';
import { QueryEditorRow } from '../QueryEditorRow';
import { IconButton } from '../../IconButton';
interface Props {
nextId: BucketAggregation['id'];
}
export const BucketAggregationsEditor: FunctionComponent<Props> = ({ nextId }) => {
const dispatch = useDispatch<BucketAggregationAction>();
const { bucketAggs } = useQuery();
const totalBucketAggs = bucketAggs?.length || 0;
return (
<>
{bucketAggs!.map((bucketAgg, index) => (
<QueryEditorRow
key={bucketAgg.id}
label={index === 0 ? 'Group By' : 'Then By'}
onRemoveClick={totalBucketAggs > 1 && (() => dispatch(removeBucketAggregation(bucketAgg.id)))}
>
<BucketAggregationEditor value={bucketAgg} />
{index === 0 && (
<IconButton iconName="plus" onClick={() => dispatch(addBucketAggregation(nextId))} label="add" />
)}
</QueryEditorRow>
))}
</>
);
};
@@ -0,0 +1,61 @@
import { SettingKeyOf } from '../../../types';
import { BucketAggregation, BucketAggregationWithField } from '../aggregations';
import {
ADD_BUCKET_AGG,
BucketAggregationAction,
REMOVE_BUCKET_AGG,
CHANGE_BUCKET_AGG_TYPE,
CHANGE_BUCKET_AGG_FIELD,
CHANGE_BUCKET_AGG_SETTING,
ChangeBucketAggregationSettingAction,
} from './types';
export const addBucketAggregation = (id: string): BucketAggregationAction => ({
type: ADD_BUCKET_AGG,
payload: {
id,
},
});
export const removeBucketAggregation = (id: BucketAggregation['id']): BucketAggregationAction => ({
type: REMOVE_BUCKET_AGG,
payload: {
id,
},
});
export const changeBucketAggregationType = (
id: BucketAggregation['id'],
newType: BucketAggregation['type']
): BucketAggregationAction => ({
type: CHANGE_BUCKET_AGG_TYPE,
payload: {
id,
newType,
},
});
export const changeBucketAggregationField = (
id: BucketAggregationWithField['id'],
newField: BucketAggregationWithField['field']
): BucketAggregationAction => ({
type: CHANGE_BUCKET_AGG_FIELD,
payload: {
id,
newField,
},
});
export const changeBucketAggregationSetting = <T extends BucketAggregation, K extends SettingKeyOf<T>>(
bucketAgg: T,
settingName: K,
// This could be inferred from T, but it's causing some troubles
newValue: string | string[] | any
): ChangeBucketAggregationSettingAction<T> => ({
type: CHANGE_BUCKET_AGG_SETTING,
payload: {
bucketAgg,
settingName,
newValue,
},
});
@@ -0,0 +1,143 @@
import { reducerTester } from 'test/core/redux/reducerTester';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
import { BucketAggregation, DateHistogram } from '../aggregations';
import { bucketAggregationConfig } from '../utils';
import {
addBucketAggregation,
changeBucketAggregationField,
changeBucketAggregationSetting,
changeBucketAggregationType,
removeBucketAggregation,
} from './actions';
import { reducer } from './reducer';
describe('Bucket Aggregations Reducer', () => {
it('Should correctly add new aggregations', () => {
const firstAggregation: BucketAggregation = {
id: '1',
type: 'terms',
settings: bucketAggregationConfig['terms'].defaultSettings,
};
const secondAggregation: BucketAggregation = {
id: '1',
type: 'terms',
settings: bucketAggregationConfig['terms'].defaultSettings,
};
reducerTester()
.givenReducer(reducer, [])
.whenActionIsDispatched(addBucketAggregation(firstAggregation.id))
.thenStateShouldEqual([firstAggregation])
.whenActionIsDispatched(addBucketAggregation(secondAggregation.id))
.thenStateShouldEqual([firstAggregation, secondAggregation]);
});
it('Should correctly remove aggregations', () => {
const firstAggregation: BucketAggregation = {
id: '1',
type: 'date_histogram',
};
const secondAggregation: BucketAggregation = {
id: '2',
type: 'date_histogram',
};
reducerTester()
.givenReducer(reducer, [firstAggregation, secondAggregation])
.whenActionIsDispatched(removeBucketAggregation(firstAggregation.id))
.thenStateShouldEqual([secondAggregation]);
});
it("Should correctly change aggregation's type", () => {
const firstAggregation: BucketAggregation = {
id: '1',
type: 'date_histogram',
};
const secondAggregation: BucketAggregation = {
id: '2',
type: 'date_histogram',
};
const expectedSecondAggregation: BucketAggregation = {
...secondAggregation,
type: 'histogram',
settings: bucketAggregationConfig['histogram'].defaultSettings,
};
reducerTester()
.givenReducer(reducer, [firstAggregation, secondAggregation])
.whenActionIsDispatched(changeBucketAggregationType(secondAggregation.id, expectedSecondAggregation.type))
.thenStateShouldEqual([firstAggregation, expectedSecondAggregation]);
});
it("Should correctly change aggregation's field", () => {
const firstAggregation: BucketAggregation = {
id: '1',
type: 'date_histogram',
};
const secondAggregation: BucketAggregation = {
id: '2',
type: 'date_histogram',
};
const expectedSecondAggregation = {
...secondAggregation,
field: 'new field',
};
reducerTester()
.givenReducer(reducer, [firstAggregation, secondAggregation])
.whenActionIsDispatched(changeBucketAggregationField(secondAggregation.id, expectedSecondAggregation.field))
.thenStateShouldEqual([firstAggregation, expectedSecondAggregation]);
});
describe("When changing a metric aggregation's type", () => {
it('Should remove and restore bucket aggregations correctly', () => {
const initialState: BucketAggregation[] = [
{
id: '1',
type: 'date_histogram',
},
];
reducerTester()
.givenReducer(reducer, initialState)
// If the new metric aggregation is `isSingleMetric` we should remove all bucket aggregations.
.whenActionIsDispatched(changeMetricType('Some id', 'raw_data'))
.thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 0)
// Switching back to another aggregation that is NOT `isSingleMetric` should bring back a bucket aggregation
.whenActionIsDispatched(changeMetricType('Some id', 'max'))
.thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 1)
// When none of the above is true state shouldn't change.
.whenActionIsDispatched(changeMetricType('Some id', 'min'))
.thenStatePredicateShouldEqual((newState: BucketAggregation[]) => newState.length === 1);
});
});
it("Should correctly change aggregation's settings", () => {
const firstAggregation: DateHistogram = {
id: '1',
type: 'date_histogram',
settings: {
min_doc_count: '0',
},
};
const secondAggregation: DateHistogram = {
id: '2',
type: 'date_histogram',
};
const expectedSettings: typeof firstAggregation['settings'] = {
min_doc_count: '1',
};
reducerTester()
.givenReducer(reducer, [firstAggregation, secondAggregation])
.whenActionIsDispatched(
changeBucketAggregationSetting(firstAggregation, 'min_doc_count', expectedSettings.min_doc_count!)
)
.thenStateShouldEqual([{ ...firstAggregation, settings: expectedSettings }, secondAggregation]);
});
});
@@ -0,0 +1,110 @@
import { defaultBucketAgg } from '../../../../query_def';
import { ElasticsearchQuery } from '../../../../types';
import { ChangeMetricTypeAction, CHANGE_METRIC_TYPE } from '../../MetricAggregationsEditor/state/types';
import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils';
import { BucketAggregation, Terms } from '../aggregations';
import { INIT, InitAction } from '../../state';
import {
ADD_BUCKET_AGG,
REMOVE_BUCKET_AGG,
CHANGE_BUCKET_AGG_TYPE,
CHANGE_BUCKET_AGG_FIELD,
CHANGE_BUCKET_AGG_SETTING,
BucketAggregationAction,
} from './types';
import { bucketAggregationConfig } from '../utils';
import { removeEmpty } from '../../../../utils';
export const reducer = (
state: BucketAggregation[],
action: BucketAggregationAction | ChangeMetricTypeAction | InitAction
): ElasticsearchQuery['bucketAggs'] => {
switch (action.type) {
case ADD_BUCKET_AGG:
const newAgg: Terms = {
id: action.payload.id,
type: 'terms',
settings: bucketAggregationConfig['terms'].defaultSettings,
};
// If the last bucket aggregation is a `date_histogram` we add the new one before it.
const lastAgg = state[state.length - 1];
if (lastAgg?.type === 'date_histogram') {
return [...state.slice(0, state.length - 1), newAgg, lastAgg];
}
return [...state, newAgg];
case REMOVE_BUCKET_AGG:
return state.filter(bucketAgg => bucketAgg.id !== action.payload.id);
case CHANGE_BUCKET_AGG_TYPE:
return state.map(bucketAgg => {
if (bucketAgg.id !== action.payload.id) {
return bucketAgg;
}
/*
TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations
in the new selected one (such as field or some settings).
It the future would be nice to have the same behavior but it's hard without a proper definition,
as Elasticsearch will error sometimes if some settings are not compatible.
*/
return {
id: bucketAgg.id,
type: action.payload.newType,
settings: bucketAggregationConfig[action.payload.newType].defaultSettings,
} as BucketAggregation;
});
case CHANGE_BUCKET_AGG_FIELD:
return state.map(bucketAgg => {
if (bucketAgg.id !== action.payload.id) {
return bucketAgg;
}
return {
...bucketAgg,
field: action.payload.newField,
};
});
case CHANGE_METRIC_TYPE:
// If we are switching to a metric which requires the absence of bucket aggregations
// we remove all of them.
if (metricAggregationConfig[action.payload.type].isSingleMetric) {
return [];
} else if (state.length === 0) {
// Else, if there are no bucket aggregations we restore a default one.
// This happens when switching from a metric that requires the absence of bucket aggregations to
// one that requires it.
return [defaultBucketAgg()];
}
return state;
case CHANGE_BUCKET_AGG_SETTING:
return state.map(bucketAgg => {
if (bucketAgg.id !== action.payload.bucketAgg.id) {
return bucketAgg;
}
const newSettings = removeEmpty({
...bucketAgg.settings,
[action.payload.settingName]: action.payload.newValue,
});
return {
...bucketAgg,
settings: {
...newSettings,
},
};
});
case INIT:
return [defaultBucketAgg()];
default:
return state;
}
};
@@ -0,0 +1,51 @@
import { Action } from '../../../../hooks/useStatelessReducer';
import { SettingKeyOf } from '../../../types';
import { BucketAggregation, BucketAggregationWithField } from '../aggregations';
export const ADD_BUCKET_AGG = '@bucketAggs/add';
export const REMOVE_BUCKET_AGG = '@bucketAggs/remove';
export const CHANGE_BUCKET_AGG_TYPE = '@bucketAggs/change_type';
export const CHANGE_BUCKET_AGG_FIELD = '@bucketAggs/change_field';
export const CHANGE_BUCKET_AGG_SETTING = '@bucketAggs/change_setting';
export interface AddBucketAggregationAction extends Action<typeof ADD_BUCKET_AGG> {
payload: {
id: BucketAggregation['id'];
};
}
export interface RemoveBucketAggregationAction extends Action<typeof REMOVE_BUCKET_AGG> {
payload: {
id: BucketAggregation['id'];
};
}
export interface ChangeBucketAggregationTypeAction extends Action<typeof CHANGE_BUCKET_AGG_TYPE> {
payload: {
id: BucketAggregation['id'];
newType: BucketAggregation['type'];
};
}
export interface ChangeBucketAggregationFieldAction extends Action<typeof CHANGE_BUCKET_AGG_FIELD> {
payload: {
id: BucketAggregation['id'];
newField: BucketAggregationWithField['field'];
};
}
export interface ChangeBucketAggregationSettingAction<T extends BucketAggregation>
extends Action<typeof CHANGE_BUCKET_AGG_SETTING> {
payload: {
bucketAgg: T;
settingName: SettingKeyOf<T>;
newValue: unknown;
};
}
export type BucketAggregationAction<T extends BucketAggregation = BucketAggregation> =
| AddBucketAggregationAction
| RemoveBucketAggregationAction
| ChangeBucketAggregationTypeAction
| ChangeBucketAggregationFieldAction
| ChangeBucketAggregationSettingAction<T>;
@@ -0,0 +1,79 @@
import { BucketsConfiguration } from '../../../types';
import { defaultFilter } from './SettingsEditor/FiltersSettingsEditor/utils';
export const bucketAggregationConfig: BucketsConfiguration = {
terms: {
label: 'Terms',
requiresField: true,
defaultSettings: {
min_doc_count: '0',
size: '10',
order: 'desc',
orderBy: '_term',
},
},
filters: {
label: 'Filters',
requiresField: false,
defaultSettings: {
filters: [defaultFilter()],
},
},
geohash_grid: {
label: 'Geo Hash Grid',
requiresField: true,
defaultSettings: {
precision: '3',
},
},
date_histogram: {
label: 'Date Histogram',
requiresField: true,
defaultSettings: {
interval: 'auto',
min_doc_count: '0',
trimEdges: '0',
},
},
histogram: {
label: 'Histogram',
requiresField: true,
defaultSettings: {
interval: '1000',
min_doc_count: '0',
},
},
};
// TODO: Define better types for the following
export const orderOptions = [
{ label: 'Top', value: 'desc' },
{ label: 'Bottom', value: 'asc' },
];
export const sizeOptions = [
{ label: 'No limit', value: '0' },
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '5', value: '5' },
{ label: '10', value: '10' },
{ label: '15', value: '15' },
{ label: '20', value: '20' },
];
export const orderByOptions = [
{ label: 'Term value', value: '_term' },
{ label: 'Doc Count', value: '_count' },
];
export const intervalOptions = [
{ label: 'auto', value: 'auto' },
{ label: '10s', value: '10s' },
{ label: '1m', value: '1m' },
{ label: '5m', value: '5m' },
{ label: '10m', value: '10m' },
{ label: '20m', value: '20m' },
{ label: '1h', value: '1h' },
{ label: '1d', value: '1d' },
];
@@ -0,0 +1,59 @@
import React, { FunctionComponent } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { ElasticsearchProvider, useDatasource, useQuery } from './ElasticsearchQueryContext';
import { ElasticsearchQuery } from '../../types';
import { ElasticDatasource } from '../../datasource';
const query: ElasticsearchQuery = {
refId: 'A',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
describe('ElasticsearchQueryContext', () => {
describe('useQuery Hook', () => {
it('Should throw when used outside of ElasticsearchQueryContext', () => {
const { result } = renderHook(() => useQuery());
expect(result.error).toBeTruthy();
});
it('Should return the current query object', () => {
const wrapper: FunctionComponent = ({ children }) => (
<ElasticsearchProvider datasource={{} as ElasticDatasource} query={query} onChange={() => {}}>
{children}
</ElasticsearchProvider>
);
const { result } = renderHook(() => useQuery(), {
wrapper,
});
expect(result.current).toBe(query);
});
});
describe('useDatasource Hook', () => {
it('Should throw when used outside of ElasticsearchQueryContext', () => {
const { result } = renderHook(() => useDatasource());
expect(result.error).toBeTruthy();
});
it('Should return the current datasource instance', () => {
const datasource = {} as ElasticDatasource;
const wrapper: FunctionComponent = ({ children }) => (
<ElasticsearchProvider datasource={datasource} query={query} onChange={() => {}}>
{children}
</ElasticsearchProvider>
);
const { result } = renderHook(() => useDatasource(), {
wrapper,
});
expect(result.current).toBe(datasource);
});
});
});
@@ -0,0 +1,63 @@
import React, { createContext, FunctionComponent, useContext } from 'react';
import { ElasticDatasource } from '../../datasource';
import { combineReducers, useStatelessReducer, DispatchContext } from '../../hooks/useStatelessReducer';
import { ElasticsearchQuery } from '../../types';
import { reducer as metricsReducer } from './MetricAggregationsEditor/state/reducer';
import { reducer as bucketAggsReducer } from './BucketAggregationsEditor/state/reducer';
import { aliasPatternReducer, queryReducer, initQuery } from './state';
const DatasourceContext = createContext<ElasticDatasource | undefined>(undefined);
const QueryContext = createContext<ElasticsearchQuery | undefined>(undefined);
interface Props {
query: ElasticsearchQuery;
onChange: (query: ElasticsearchQuery) => void;
datasource: ElasticDatasource;
}
export const ElasticsearchProvider: FunctionComponent<Props> = ({ children, onChange, query, datasource }) => {
const reducer = combineReducers({
query: queryReducer,
alias: aliasPatternReducer,
metrics: metricsReducer,
bucketAggs: bucketAggsReducer,
});
const dispatch = useStatelessReducer(newState => onChange({ ...query, ...newState }), query, reducer);
// This initializes the query by dispatching an init action to each reducer.
// useStatelessReducer will then call `onChange` with the newly generated query
if (!query.metrics && !query.bucketAggs) {
dispatch(initQuery());
return null;
}
return (
<DatasourceContext.Provider value={datasource}>
<QueryContext.Provider value={query}>
<DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
</QueryContext.Provider>
</DatasourceContext.Provider>
);
};
export const useQuery = (): ElasticsearchQuery => {
const query = useContext(QueryContext);
if (!query) {
throw new Error('use ElasticsearchProvider first.');
}
return query;
};
export const useDatasource = () => {
const datasource = useContext(DatasourceContext);
if (!datasource) {
throw new Error('use ElasticsearchProvider first.');
}
return datasource;
};
@@ -0,0 +1,120 @@
import { MetricFindValue, SelectableValue } from '@grafana/data';
import { Segment, SegmentAsync, useTheme } from '@grafana/ui';
import { cx } from 'emotion';
import React, { FunctionComponent } from 'react';
import { useDatasource, useQuery } from '../ElasticsearchQueryContext';
import { useDispatch } from '../../../hooks/useStatelessReducer';
import { getStyles } from './styles';
import { SettingsEditor } from './SettingsEditor';
import { MetricAggregationAction } from './state/types';
import { metricAggregationConfig } from './utils';
import { changeMetricField, changeMetricType } from './state/actions';
import { MetricPicker } from '../../MetricPicker';
import { segmentStyles } from '../styles';
import {
isMetricAggregationWithField,
isMetricAggregationWithSettings,
isPipelineAggregation,
isPipelineAggregationWithMultipleBucketPaths,
MetricAggregation,
MetricAggregationType,
} from './aggregations';
const toOption = (metric: MetricAggregation) => ({
label: metricAggregationConfig[metric.type].label,
value: metric.type,
});
const toSelectableValue = ({ value, text }: MetricFindValue): SelectableValue<string> => ({
label: text,
value: `${value || text}`,
});
interface Props {
value: MetricAggregation;
}
// If a metric is a Pipeline Aggregation (https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html)
// it doesn't make sense to show it in the type picker when there is no non-pipeline-aggregation previously selected
// as they work on the outputs produced from other aggregations rather than from documents or fields.
// This means we should filter them out from the type picker if there's no other "basic" aggregation before the current one.
const isBasicAggregation = (metric: MetricAggregation) => !metricAggregationConfig[metric.type].isPipelineAgg;
const getTypeOptions = (
previousMetrics: MetricAggregation[],
esVersion: number
): Array<SelectableValue<MetricAggregationType>> => {
// we'll include Pipeline Aggregations only if at least one previous metric is a "Basic" one
const includePipelineAggregations = previousMetrics.some(isBasicAggregation);
return (
Object.entries(metricAggregationConfig)
// Only showing metrics type supported by the configured version of ES
.filter(([_, { minVersion = 0, maxVersion = esVersion }]) => {
// TODO: Double check this
return esVersion >= minVersion && esVersion <= maxVersion;
})
// Filtering out Pipeline Aggregations if there's no basic metric selected before
.filter(([_, config]) => includePipelineAggregations || !config.isPipelineAgg)
.map(([key, { label }]) => ({
label,
value: key as MetricAggregationType,
}))
);
};
export const MetricEditor: FunctionComponent<Props> = ({ value }) => {
const styles = getStyles(useTheme(), !!value.hide);
const datasource = useDatasource();
const query = useQuery();
const dispatch = useDispatch<MetricAggregationAction>();
const previousMetrics = query.metrics!.slice(
0,
query.metrics!.findIndex(m => m.id === value.id)
);
// TODO: This could be common with the one in BucketAggregationEditor
const getFields = async () => {
const get = () => {
if (value.type === 'cardinality') {
return datasource.getFields();
}
return datasource.getFields('number');
};
return (await get()).map(toSelectableValue);
};
return (
<>
<Segment
className={cx(styles.color, segmentStyles)}
options={getTypeOptions(previousMetrics, datasource.esVersion)}
onChange={e => dispatch(changeMetricType(value.id, e.value!))}
value={toOption(value)}
/>
{isMetricAggregationWithField(value) && !isPipelineAggregation(value) && (
<SegmentAsync
className={cx(styles.color, segmentStyles)}
loadOptions={getFields}
onChange={e => dispatch(changeMetricField(value.id, e.value!))}
placeholder="Select Field"
value={value.field}
/>
)}
{isPipelineAggregation(value) && !isPipelineAggregationWithMultipleBucketPaths(value) && (
<MetricPicker
className={cx(styles.color, segmentStyles)}
onChange={e => dispatch(changeMetricField(value.id, e.value?.id!))}
options={previousMetrics}
value={value.field}
/>
)}
{isMetricAggregationWithSettings(value) && <SettingsEditor metric={value} previousMetrics={previousMetrics} />}
</>
);
};
@@ -0,0 +1,98 @@
import React, { Fragment, FunctionComponent, useEffect } from 'react';
import { Input, InlineLabel } from '@grafana/ui';
import { MetricAggregationAction } from '../../state/types';
import { changeMetricAttribute } from '../../state/actions';
import { css } from 'emotion';
import { AddRemove } from '../../../../AddRemove';
import { useStatelessReducer, useDispatch } from '../../../../../hooks/useStatelessReducer';
import { MetricPicker } from '../../../../MetricPicker';
import { reducer } from './state/reducer';
import {
addPipelineVariable,
removePipelineVariable,
renamePipelineVariable,
changePipelineVariableMetric,
} from './state/actions';
import { SettingField } from '../SettingField';
import { BucketScript, MetricAggregation } from '../../aggregations';
interface Props {
value: BucketScript;
previousMetrics: MetricAggregation[];
}
export const BucketScriptSettingsEditor: FunctionComponent<Props> = ({ value, previousMetrics }) => {
const upperStateDispatch = useDispatch<MetricAggregationAction<BucketScript>>();
const dispatch = useStatelessReducer(
newState => upperStateDispatch(changeMetricAttribute(value, 'pipelineVariables', newState)),
value.pipelineVariables,
reducer
);
// The model might not have pipeline variables (or an empty array of pipeline vars) in it because of the way it was built in previous versions of the datasource.
// If this is the case we add a default one.
useEffect(() => {
if (!value.pipelineVariables?.length) {
dispatch(addPipelineVariable());
}
}, []);
return (
<>
<div
className={css`
display: flex;
`}
>
<InlineLabel width={16}>Variables</InlineLabel>
<div
className={css`
display: grid;
grid-template-columns: 1fr auto;
row-gap: 4px;
margin-bottom: 4px;
`}
>
{value.pipelineVariables!.map((pipelineVar, index) => (
<Fragment key={pipelineVar.name}>
<div
className={css`
display: grid;
column-gap: 4px;
grid-template-columns: auto auto;
`}
>
<Input
defaultValue={pipelineVar.name}
placeholder="Variable Name"
onBlur={e => dispatch(renamePipelineVariable(e.target.value, index))}
/>
<MetricPicker
onChange={e => dispatch(changePipelineVariableMetric(e.value!.id, index))}
options={previousMetrics}
value={pipelineVar.pipelineAgg}
/>
</div>
<AddRemove
index={index}
elements={value.pipelineVariables || []}
onAdd={() => dispatch(addPipelineVariable())}
onRemove={() => dispatch(removePipelineVariable(index))}
/>
</Fragment>
))}
</div>
</div>
<SettingField
label="Script"
metric={value}
settingName="script"
tooltip="Elasticsearch v5.0 and above: Scripting language is Painless. Use params.<var> to reference a variable. Elasticsearch pre-v5.0: Scripting language is per default Groovy if not changed. For Groovy use <var> to reference a variable."
placeholder="params.var1 / params.var2"
/>
</>
);
};
@@ -0,0 +1,34 @@
import {
ADD_PIPELINE_VARIABLE,
REMOVE_PIPELINE_VARIABLE,
PipelineVariablesAction,
RENAME_PIPELINE_VARIABLE,
CHANGE_PIPELINE_VARIABLE_METRIC,
} from './types';
export const addPipelineVariable = (): PipelineVariablesAction => ({
type: ADD_PIPELINE_VARIABLE,
});
export const removePipelineVariable = (index: number): PipelineVariablesAction => ({
type: REMOVE_PIPELINE_VARIABLE,
payload: {
index,
},
});
export const renamePipelineVariable = (newName: string, index: number): PipelineVariablesAction => ({
type: RENAME_PIPELINE_VARIABLE,
payload: {
index,
newName,
},
});
export const changePipelineVariableMetric = (newMetric: string, index: number): PipelineVariablesAction => ({
type: CHANGE_PIPELINE_VARIABLE_METRIC,
payload: {
index,
newMetric,
},
});
@@ -0,0 +1,102 @@
import { reducerTester } from 'test/core/redux/reducerTester';
import { PipelineVariable } from '../../../aggregations';
import {
addPipelineVariable,
changePipelineVariableMetric,
removePipelineVariable,
renamePipelineVariable,
} from './actions';
import { reducer } from './reducer';
describe('BucketScript Settings Reducer', () => {
it('Should correctly add new pipeline variable', () => {
const expectedPipelineVar: PipelineVariable = {
name: 'var1',
pipelineAgg: '',
};
reducerTester()
.givenReducer(reducer, [])
.whenActionIsDispatched(addPipelineVariable())
.thenStateShouldEqual([expectedPipelineVar]);
});
it('Should correctly remove pipeline variables', () => {
const firstVar: PipelineVariable = {
name: 'var1',
pipelineAgg: '',
};
const secondVar: PipelineVariable = {
name: 'var2',
pipelineAgg: '',
};
reducerTester()
.givenReducer(reducer, [firstVar, secondVar])
.whenActionIsDispatched(removePipelineVariable(0))
.thenStateShouldEqual([secondVar]);
});
it('Should correctly rename pipeline variable', () => {
const firstVar: PipelineVariable = {
name: 'var1',
pipelineAgg: '',
};
const secondVar: PipelineVariable = {
name: 'var2',
pipelineAgg: '',
};
const expectedSecondVar: PipelineVariable = {
...secondVar,
name: 'new name',
};
reducerTester()
.givenReducer(reducer, [firstVar, secondVar])
.whenActionIsDispatched(renamePipelineVariable(expectedSecondVar.name, 1))
.thenStateShouldEqual([firstVar, expectedSecondVar]);
});
it('Should correctly change pipeline variable target metric', () => {
const firstVar: PipelineVariable = {
name: 'var1',
pipelineAgg: '',
};
const secondVar: PipelineVariable = {
name: 'var2',
pipelineAgg: 'some agg',
};
const expectedSecondVar: PipelineVariable = {
...secondVar,
pipelineAgg: 'some new agg',
};
reducerTester()
.givenReducer(reducer, [firstVar, secondVar])
.whenActionIsDispatched(changePipelineVariableMetric(expectedSecondVar.pipelineAgg, 1))
.thenStateShouldEqual([firstVar, expectedSecondVar]);
});
it('Should not change state with other action types', () => {
const initialState: PipelineVariable[] = [
{
name: 'var1',
pipelineAgg: '1',
},
{
name: 'var2',
pipelineAgg: '2',
},
];
reducerTester()
.givenReducer(reducer, initialState)
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
.thenStateShouldEqual(initialState);
});
});
@@ -0,0 +1,46 @@
import { PipelineVariable } from '../../../aggregations';
import { defaultPipelineVariable } from '../utils';
import {
PipelineVariablesAction,
REMOVE_PIPELINE_VARIABLE,
ADD_PIPELINE_VARIABLE,
RENAME_PIPELINE_VARIABLE,
CHANGE_PIPELINE_VARIABLE_METRIC,
} from './types';
export const reducer = (state: PipelineVariable[] = [], action: PipelineVariablesAction) => {
switch (action.type) {
case ADD_PIPELINE_VARIABLE:
return [...state, defaultPipelineVariable()];
case REMOVE_PIPELINE_VARIABLE:
return state.slice(0, action.payload.index).concat(state.slice(action.payload.index + 1));
case RENAME_PIPELINE_VARIABLE:
return state.map((pipelineVariable, index) => {
if (index !== action.payload.index) {
return pipelineVariable;
}
return {
...pipelineVariable,
name: action.payload.newName,
};
});
case CHANGE_PIPELINE_VARIABLE_METRIC:
return state.map((pipelineVariable, index) => {
if (index !== action.payload.index) {
return pipelineVariable;
}
return {
...pipelineVariable,
pipelineAgg: action.payload.newMetric,
};
});
default:
return state;
}
};
@@ -0,0 +1,34 @@
import { Action } from '../../../../../../hooks/useStatelessReducer';
export const ADD_PIPELINE_VARIABLE = '@pipelineVariables/add';
export const REMOVE_PIPELINE_VARIABLE = '@pipelineVariables/remove';
export const RENAME_PIPELINE_VARIABLE = '@pipelineVariables/rename';
export const CHANGE_PIPELINE_VARIABLE_METRIC = '@pipelineVariables/change_metric';
export type AddPipelineVariableAction = Action<typeof ADD_PIPELINE_VARIABLE>;
export interface RemovePipelineVariableAction extends Action<typeof REMOVE_PIPELINE_VARIABLE> {
payload: {
index: number;
};
}
export interface RenamePipelineVariableAction extends Action<typeof RENAME_PIPELINE_VARIABLE> {
payload: {
index: number;
newName: string;
};
}
export interface ChangePipelineVariableMetricAction extends Action<typeof CHANGE_PIPELINE_VARIABLE_METRIC> {
payload: {
index: number;
newMetric: string;
};
}
export type PipelineVariablesAction =
| AddPipelineVariableAction
| RemovePipelineVariableAction
| RenamePipelineVariableAction
| ChangePipelineVariableMetricAction;
@@ -0,0 +1,3 @@
import { PipelineVariable } from '../../aggregations';
export const defaultPipelineVariable = (): PipelineVariable => ({ name: 'var1', pipelineAgg: '' });
@@ -0,0 +1,178 @@
import { Input, InlineField, Select, Switch } from '@grafana/ui';
import React, { FunctionComponent } from 'react';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { movingAvgModelOptions } from '../../../../query_def';
import { isEWMAMovingAverage, isHoltMovingAverage, isHoltWintersMovingAverage, MovingAverage } from '../aggregations';
import { changeMetricSetting } from '../state/actions';
interface Props {
metric: MovingAverage;
}
// The way we handle changes for those settings is not ideal compared to the other components in the editor
export const MovingAverageSettingsEditor: FunctionComponent<Props> = ({ metric }) => {
const dispatch = useDispatch();
return (
<>
<InlineField label="Model">
<Select
onChange={value => dispatch(changeMetricSetting(metric, 'model', value.value!))}
options={movingAvgModelOptions}
value={metric.settings?.model}
/>
</InlineField>
<InlineField label="Window">
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'window', parseInt(e.target.value!, 10)))}
defaultValue={metric.settings?.window}
/>
</InlineField>
<InlineField label="Predict">
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'predict', parseInt(e.target.value!, 10)))}
defaultValue={metric.settings?.predict}
/>
</InlineField>
{isEWMAMovingAverage(metric) && (
<>
<InlineField label="Alpha">
<Input
onBlur={e => dispatch(changeMetricSetting(metric, 'alpha', parseInt(e.target.value!, 10)))}
defaultValue={metric.settings?.alpha}
/>
</InlineField>
<InlineField label="Minimize">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(changeMetricSetting(metric, 'minimize', e.target.checked))
}
checked={!!metric.settings?.minimize}
/>
</InlineField>
</>
)}
{isHoltMovingAverage(metric) && (
<>
<InlineField label="Alpha">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
alpha: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.alpha}
/>
</InlineField>
<InlineField label="Beta">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
beta: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.beta}
/>
</InlineField>
<InlineField label="Minimize">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(changeMetricSetting(metric, 'minimize', e.target.checked))
}
checked={!!metric.settings?.minimize}
/>
</InlineField>
</>
)}
{isHoltWintersMovingAverage(metric) && (
<>
<InlineField label="Alpha">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
alpha: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.alpha}
/>
</InlineField>
<InlineField label="Beta">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
beta: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.beta}
/>
</InlineField>
<InlineField label="Gamma">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
gamma: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.gamma}
/>
</InlineField>
<InlineField label="Period">
<Input
onBlur={e =>
dispatch(
changeMetricSetting(metric, 'settings', {
...metric.settings?.settings,
period: parseInt(e.target.value!, 10),
})
)
}
defaultValue={metric.settings?.settings?.period}
/>
</InlineField>
<InlineField label="Pad">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(
changeMetricSetting(metric, 'settings', { ...metric.settings?.settings, pad: e.target.checked })
)
}
checked={!!metric.settings?.settings?.pad}
/>
</InlineField>
<InlineField label="Minimize">
<Switch
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(changeMetricSetting(metric, 'minimize', e.target.checked))
}
checked={!!metric.settings?.minimize}
/>
</InlineField>
</>
)}
</>
);
};
@@ -0,0 +1,39 @@
import React, { ComponentProps, useState } from 'react';
import { InlineField, Input } from '@grafana/ui';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { changeMetricSetting } from '../state/actions';
import { ChangeMetricSettingAction } from '../state/types';
import { SettingKeyOf } from '../../../types';
import { MetricAggregationWithSettings } from '../aggregations';
import { uniqueId } from 'lodash';
interface Props<T extends MetricAggregationWithSettings, K extends SettingKeyOf<T>> {
label: string;
settingName: K;
metric: T;
placeholder?: ComponentProps<typeof Input>['placeholder'];
tooltip?: ComponentProps<typeof InlineField>['tooltip'];
}
export function SettingField<T extends MetricAggregationWithSettings, K extends SettingKeyOf<T>>({
label,
settingName,
metric,
placeholder,
tooltip,
}: Props<T, K>) {
const dispatch = useDispatch<ChangeMetricSettingAction<T>>();
const [id] = useState(uniqueId(`es-field-id-`));
const settings = metric.settings;
return (
<InlineField label={label} labelWidth={16} tooltip={tooltip}>
<Input
id={id}
placeholder={placeholder}
onBlur={e => dispatch(changeMetricSetting(metric, settingName, e.target.value as any))}
defaultValue={settings?.[settingName as keyof typeof settings]}
/>
</InlineField>
);
}

Some files were not shown because too many files have changed in this diff Show More