diff --git a/.drone.yml b/.drone.yml index 16caa00cb3a..bf831619285 100644 --- a/.drone.yml +++ b/.drone.yml @@ -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 diff --git a/docs/sources/developers/_index.md b/docs/sources/developers/_index.md index 6b523397bf2..2d27bc4e9fb 100644 --- a/docs/sources/developers/_index.md +++ b/docs/sources/developers/_index.md @@ -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. \ No newline at end of file +This section of the documentation contains pages with resources for Grafana developers. diff --git a/docs/sources/http_api/snapshot.md b/docs/sources/http_api/snapshot.md index 778087b620e..a602fa37bbb 100644 --- a/docs/sources/http_api/snapshot.md +++ b/docs/sources/http_api/snapshot.md @@ -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."} -``` \ No newline at end of file +{"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches.", "id": 1} +``` diff --git a/docs/sources/panels/transformations/types-options.md b/docs/sources/panels/transformations/types-options.md index dc3b1a18dfd..9f287e1f50a 100644 --- a/docs/sources/panels/transformations/types-options.md +++ b/docs/sources/panels/transformations/types-options.md @@ -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+. diff --git a/docs/sources/plugins/installation.md b/docs/sources/plugins/installation.md index 475672505e1..6066f2a88b1 100644 --- a/docs/sources/plugins/installation.md +++ b/docs/sources/plugins/installation.md @@ -1,6 +1,6 @@ +++ title = "Install plugins" -aliases = ["/docs/plugins/installation/"] +aliases = ["/docs/grafana/latest/plugins/installation/"] weight = 1 +++ diff --git a/docs/sources/plugins/plugin-signatures.md b/docs/sources/plugins/plugin-signatures.md index 628b68ecdea..2b0ff883e14 100644 --- a/docs/sources/plugins/plugin-signatures.md +++ b/docs/sources/plugins/plugin-signatures.md @@ -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 diff --git a/e2e/suite1/specs/variables/new-query-variable.ts b/e2e/suite1/specs/variables/new-query-variable.ts index 3955481b03a..48485efb702 100644 --- a/e2e/suite1/specs/variables/new-query-variable.ts +++ b/e2e/suite1/specs/variables/new-query-variable.ts @@ -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 => { diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index f6cfcdb874f..88ea58cb799 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -567,6 +567,7 @@ export interface DataSourceInstanceSettings `Panel editor option pane tab ${title}`, }, diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 2c753972131..1a238aff590 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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', diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 96758a0a4b2..1edc5b6f3b2 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -16,14 +16,9 @@ export interface DataSourceSrv { get(name?: string | null, scopedVars?: ScopedVars): Promise; /** - * 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; /** diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 7f42046639f..b4aea6280d7 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index f051c6e862d..d67f110b53d 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -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 { } 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 { 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`; diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.tsx index 41fdc774a3b..ade30ba069c 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.tsx @@ -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; diff --git a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx index 8ec540bee55..7e1e7bbbf42 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValueLayout.tsx @@ -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); diff --git a/packages/grafana-ui/src/components/Button/Button.story.tsx b/packages/grafana-ui/src/components/Button/Button.story.tsx index 16612418b99..c73414e90a5 100644 --- a/packages/grafana-ui/src/components/Button/Button.story.tsx +++ b/packages/grafana-ui/src/components/Button/Button.story.tsx @@ -20,17 +20,13 @@ export default { docs: { page: mdx, }, + knobs: { + disabled: true, + }, }, }; -export const Simple: Story = ({ disabled, icon, children, size, variant }) => { - return ( - - ); -}; - +export const Simple: Story = ({ children, ...args }) => ; Simple.args = { variant: 'primary', size: 'md', diff --git a/packages/grafana-ui/src/components/Forms/Field.story.tsx b/packages/grafana-ui/src/components/Forms/Field.story.tsx index 8fc8bdaf672..2a69c39ffd4 100644 --- a/packages/grafana-ui/src/components/Forms/Field.story.tsx +++ b/packages/grafana-ui/src/components/Forms/Field.story.tsx @@ -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 = args => ( +
+ + + +
+); - 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 ( -
- - - -
- ); -}; - -export const HorizontalLayout = () => { +export const HorizontalLayout: Story = args => { const [checked, setChecked] = useState(false); const onChange = useCallback(e => setChecked(e.currentTarget.checked), [setChecked]); - const { containerWidth, ...otherProps } = getKnobs(); return ( -
- +
+
); }; + +HorizontalLayout.args = { + label: 'Show labels', + description: 'Display threshold labels', + disabled: false, + invalid: false, + loading: false, + error: 'Not valid input', + horizontal: true, +}; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 2e98f8f7cd0..0372c5d9ae5 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -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; className?: string; } @@ -108,7 +110,7 @@ export class Gauge extends PureComponent { // 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 { } 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 {
{ } const defaultConfig: GraphFieldConfig = { - mode: GraphMode.Line, + drawStyle: DrawStyle.Line, points: PointMode.Auto, axisPlacement: AxisPlacement.Auto, }; @@ -134,11 +134,11 @@ export const GraphNG: React.FC = ({ 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 = ({ pointColor: seriesColor, fillOpacity: customConfig.fillOpacity, fillColor: seriesColor, + spanNulls: customConfig.spanNulls || false, }); if (hasLegend.current) { diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index b865a33f1e6..2d6376708f6 100755 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -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 = new Set(); let xNulls: Array> = [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 = 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]); + } } } } diff --git a/packages/grafana-ui/src/components/Layout/Layout.tsx b/packages/grafana-ui/src/components/Layout/Layout.tsx index ec2d6b36ff7..daebb434733 100644 --- a/packages/grafana-ui/src/components/Layout/Layout.tsx +++ b/packages/grafana-ui/src/components/Layout/Layout.tsx @@ -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'}; diff --git a/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx b/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx index e17cd4613ff..fe3f963412e 100644 --- a/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx +++ b/packages/grafana-ui/src/components/Segment/useExpandableLabel.tsx @@ -14,7 +14,6 @@ export const useExpandableLabel = ( const Label: React.FC = ({ Component, onClick }) => (
{ setExpanded(true); diff --git a/packages/grafana-ui/src/components/Select/SingleValue.tsx b/packages/grafana-ui/src/components/Select/SingleValue.tsx index 5a237b2456c..bbbb899c1b7 100644 --- a/packages/grafana-ui/src/components/Select/SingleValue.tsx +++ b/packages/grafana-ui/src/components/Select/SingleValue.tsx @@ -59,14 +59,7 @@ export const SingleValue = (props: Props) => { return ( -
+
{data.imgUrl ? ( ) : ( diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index 2b6cf375882..70afa3d81a1 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -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']; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx index a514f310b15..ea0a819853f 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -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, }); diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index 9da26df3227..a793a7ab472 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -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>, + drawStyle: [ + { label: 'Lines', value: DrawStyle.Line }, + { label: 'Bars', value: DrawStyle.Bars }, + { label: 'Points', value: DrawStyle.Points }, + ] as Array>, 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>, points: [ diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index 3973719f175..397e650ee8b 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -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, }, diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 24e48258454..30922ea3007 100755 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -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 { getConfig() { const { - mode, + drawStyle, lineInterpolation, lineColor, lineWidth, @@ -22,29 +22,39 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { fillColor, fillOpacity, scaleKey, + spanNulls, } = this.props; let lineConfig: Partial = {}; - 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 { // 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 { return { scale: scaleKey, + spanGaps: spanNulls, ...lineConfig, ...pointsConfig, ...areaConfig, diff --git a/packages/grafana-ui/src/components/uPlot/paths.ts b/packages/grafana-ui/src/components/uPlot/paths.ts index f0818b81306..3173dcbf86e 100644 --- a/packages/grafana-ui/src/components/uPlot/paths.ts +++ b/packages/grafana-ui/src/components/uPlot/paths.ts @@ -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, }; }; diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 2449522531b..2aa18b44c25 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -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 diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index 9689e02a215..6dbc39332a4 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -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, diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 914ac809291..b734f4dd78b 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -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 { diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index d0198150617..acefa9deb70 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -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) diff --git a/public/app/core/components/Select/DataSourcePicker.tsx b/public/app/core/components/Select/DataSourcePicker.tsx index a2e9eeb4dca..090ab2392ee 100644 --- a/public/app/core/components/Select/DataSourcePicker.tsx +++ b/public/app/core/components/Select/DataSourcePicker.tsx @@ -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 { +export interface State { + error?: string; +} + +export class DataSourcePicker extends PureComponent { + dataSourceSrv = getDataSourceSrv(); + static defaultProps: Partial = { autoFocus: false, openMenuOnFocus: false, placeholder: 'Select datasource', }; - searchInput: HTMLElement; + state: State = {}; constructor(props: Props) { super(props); } - onChange = (item: SelectableValue) => { - 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) => { + 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 (
@@ -87,9 +134,9 @@ export class DataSourcePicker extends PureComponent { 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 ( {o.label} @@ -103,5 +150,3 @@ export class DataSourcePicker extends PureComponent { ); } } - -export default DataSourcePicker; diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index 2ad5fb00f98..9ca3e60a699 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -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, }); diff --git a/public/app/features/alerting/getAlertingValidationMessage.test.ts b/public/app/features/alerting/getAlertingValidationMessage.test.ts index e98c5c071a3..ed505319381 100644 --- a/public/app/features/alerting/getAlertingValidationMessage.test.ts +++ b/public/app/features/alerting/getAlertingValidationMessage.test.ts @@ -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 []; }, }; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx index dfe8f45e0d1..f0f059fe786 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx @@ -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 { 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 { }; } - 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 { }); }; - 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 { return ( ); } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 2250c6de878..7d1726b5596 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -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 { - 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 { }); } - 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 { >
diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 46088b4e198..7ff974bb9b1 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -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', diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index b9ac8dc5f7c..e71976c7e58 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -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; diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 104b5c0974d..ba1e8f1cfc3 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -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 { return Promise.resolve((name ? dsSettings.find(d => d.api.name === name) : dsSettings[0])!.api); }, diff --git a/public/app/features/explore/state/explorePane.test.ts b/public/app/features/explore/state/explorePane.test.ts index c3b99c513bf..5856c202898 100644 --- a/public/app/features/explore/state/explorePane.test.ts +++ b/public/app/features/explore/state/explorePane.test.ts @@ -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; - -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) => { const exploreId = ExploreId.left; const containerWidth = 1920; diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 17e2f5dafa0..980d3cfb0c9 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -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 { return async (dispatch, getState) => { - const exploreDatasources = getExploreDatasources(); + const exploreDatasources = getDataSourceSrv().getList(); let instance = undefined; let history: HistoryItem[] = []; diff --git a/public/app/features/explore/state/selectors.ts b/public/app/features/explore/state/selectors.ts index f14c6ad7e7d..32e097b4f62 100644 --- a/public/app/features/explore/state/selectors.ts +++ b/public/app/features/explore/state/selectors.ts @@ -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) - ); -}; diff --git a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx index 0262ab2b186..dc93b5effb0 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx @@ -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, 'formState' | 'watch'> { +interface Props extends Omit, 'formState'> { uidReset: boolean; inputs: DashboardInputs; initialFolderId: number; @@ -36,8 +36,10 @@ export const ImportDashboardForm: FC = ({ 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 = ({ {inputs.dataSources && inputs.dataSources.map((input: DataSourceInput, index: number) => { const dataSourceOption = `dataSources[${index}]`; + const current = watchDataSources ?? []; return ( = ({ > { validateFieldsOnMount={['title', 'uid']} validateOn="onChange" > - {({ register, errors, control, getValues }) => ( + {({ register, errors, control, watch, getValues }) => ( { onCancel={this.onCancel} onUidReset={this.onUidReset} onSubmit={this.onSubmit} + watch={watch} initialFolderId={folder.id} /> )} diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 1033ea18ffe..fd71b3582d6 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -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 { 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) { diff --git a/public/app/features/manage-dashboards/state/reducers.ts b/public/app/features/manage-dashboards/state/reducers.ts index ee23a8bf380..724847f00a1 100644 --- a/public/app/features/manage-dashboards/state/reducers.ts +++ b/public/app/features/manage-dashboards/state/reducers.ts @@ -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 { diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 76d86c6000d..e82ed4f9313 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -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 = {}; @@ -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 => { diff --git a/public/app/features/plugins/specs/datasource_srv.test.ts b/public/app/features/plugins/specs/datasource_srv.test.ts index ccd3e631394..8817efa358f 100644 --- a/public/app/features/plugins/specs/datasource_srv.test.ts +++ b/public/app/features/plugins/specs/datasource_srv.test.ts @@ -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", + }, + ] + `); }); }); }); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index ae3423136c4..e0af8a410c5 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -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 { state: State = { datasource: null, - loadedDataSourceValue: undefined, hasTextEditMode: false, data: undefined, isOpen: true, @@ -89,27 +88,31 @@ export class QueryEditorRow extends PureComponent { }; } + 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 { } // 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 { if (!this.element || this.angularQueryEditor) { return; } + this.renderAngularQueryEditor(); } @@ -259,14 +263,14 @@ export class QueryEditorRow extends PureComponent { }; 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 ( this.onToggleEditMode(e, props)} diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx index 09d43dcacef..55d9e9805b4 100644 --- a/public/app/features/query/components/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -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 { }; render() { - const { props } = this; + const { dsSettings, data, queries } = this.props; return ( @@ -75,19 +75,18 @@ export class QueryEditorRows extends PureComponent { {provided => { return (
- {props.queries.map((query, index) => ( + {queries.map((query, index) => ( this.onChangeQuery(query, index)} onRemoveQuery={this.onRemoveQuery} onAddQuery={this.props.onAddQuery} onRunQuery={this.props.onRunQueries} - inMixedMode={props.datasource.meta.mixed} /> ))} {provided.placeholder} diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx index 14086ec55a9..8c4c5b65089 100644 --- a/public/app/features/query/components/QueryGroup.tsx +++ b/public/app/features/query/components/QueryGroup.tsx @@ -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 { - 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 { }; 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 { 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) { + 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 { 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 (
- - - -
-
-
-
- -
- {onOpenQueryInspector && ( -
- -
+ {dataSource && ( + <> +
+
+
+ +
+ {onOpenQueryInspector && ( +
+ +
+ )} + )}
@@ -220,13 +229,9 @@ export class QueryGroup extends PureComponent { }; 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 ( { }; onAddQuery = (query: Partial) => { - 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 { 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 ; + renderQueries(dsSettings: DataSourceInstanceSettings) { + const { options, onRunQueries } = this.props; + const { data } = this.state; + + if (isSharedDashboardQuery(dsSettings.name)) { + return ; } return (
{ ); } - 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 ( @@ -305,7 +314,7 @@ export class QueryGroup extends PureComponent { } 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 { >
{this.renderTopSection(styles)} -
{this.renderQueries()}
- {this.renderAddQueryRow()} - - {isHelpOpen && ( - - - + {dsSettings && ( + <> +
{this.renderQueries(dsSettings)}
+ {this.renderAddQueryRow(dsSettings)} + {isHelpOpen && ( + + + + )} + )}
diff --git a/public/app/features/query/components/QueryGroupOptions.tsx b/public/app/features/query/components/QueryGroupOptions.tsx index b4166436fa5..ca31fef73e2 100644 --- a/public/app/features/query/components/QueryGroupOptions.tsx +++ b/public/app/features/query/components/QueryGroupOptions.tsx @@ -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; diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index cc7cff6cb73..ecb8184013b 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -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(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 = () => {
@@ -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, }, }; diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts index a612e3fafb6..92deb2bd09b 100644 --- a/public/app/features/variables/adhoc/actions.test.ts +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -478,6 +478,5 @@ function createDatasource(name: string, selectable = true): DataSourceSelectItem meta: { mixed: !selectable, } as DataSourcePluginMeta, - sort: '', }; } diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index f652545aefe..93e3a6cfdfa 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -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: '', }, ]; diff --git a/public/app/features/variables/editor/VariableTextAreaField.tsx b/public/app/features/variables/editor/VariableTextAreaField.tsx index 30d0d01de0c..ebdc9ea89d3 100644 --- a/public/app/features/variables/editor/VariableTextAreaField.tsx +++ b/public/app/features/variables/editor/VariableTextAreaField.tsx @@ -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 ( - - - +