diff --git a/.dockerignore b/.dockerignore index c535fa427b5..c6fe3c070b2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,4 +15,11 @@ node_modules /local /tmp *.yml +!.yarnrc.yml *.md +.yarn/* +!.yarn/patches +!.yarn/releases +!.yarn/plugins +!.yarn/versions +!.yarn/cache diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ad210108d6c..a70dc3a8616 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -63,6 +63,7 @@ go.sum @grafana/backend-platform /pkg/plugins @grafana/plugins-platform-backend /pkg/services/datasourceproxy @grafana/plugins-platform-backend /pkg/services/datasources @grafana/plugins-platform-backend +/public/app/features/plugins @grafana/plugins-platform-frontend # Backend code docs /contribute/style-guides/backend.md @grafana/backend-platform diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 946a15c5bef..8356a79c902 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -4,7 +4,10 @@ ], "enabledManagers": ["npm"], "ignoreDeps": [ + "@types/systemjs", + "@types/d3-force", "d3", + "d3-force", // we should bump this once we move to esm modules "husky", "slate", "slate-plain-serializer", @@ -22,7 +25,7 @@ "matchPaths": ["grafana-toolkit/package.json"], "ignoreDeps": [ "copy-webpack-plugin", // need to wait for Grafana 9 to upgrade toolkit to webpack 5 - "css-loader", // need to wait for Grafana 9 to upgrade toolkit to webpack 5 + "css-loader", // need to wait for Grafana 9 to upgrade toolkit to webpack 5 ] } ], diff --git a/Dockerfile b/Dockerfile index 14e4b8761ab..b6bbd040d97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,12 @@ FROM node:16-alpine3.14 as js-builder WORKDIR /usr/src/app/ COPY package.json yarn.lock ./ +COPY .yarnrc.yml ./ +COPY .yarn .yarn COPY packages packages +COPY plugins-bundled plugins-bundled -RUN apk --no-cache add git -RUN yarn install --pure-lockfile --no-progress +RUN yarn install COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ COPY public public @@ -19,7 +21,7 @@ RUN yarn build FROM golang:1.17.0-alpine3.14 as go-builder -RUN apk add --no-cache gcc g++ +RUN apk add --no-cache gcc g++ make WORKDIR $GOPATH/src/github.com/grafana/grafana @@ -29,10 +31,11 @@ COPY cue.mod cue.mod COPY packages/grafana-schema packages/grafana-schema COPY public/app/plugins public/app/plugins COPY pkg pkg -COPY build.go package.json ./ +COPY .bingo .bingo +COPY Makefile build.go package.json ./ RUN go mod verify -RUN go run build.go build +RUN make build-go # Final stage FROM alpine:3.14.2 diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index 6552a6222fd..4010950743c 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -1,12 +1,14 @@ -FROM node:14.15.1-slim AS js-builder +FROM node:16-alpine3.14 as js-builder WORKDIR /usr/src/app/ COPY package.json yarn.lock ./ COPY packages packages +COPY .yarnrc.yml ./ +COPY .yarn .yarn +COPY plugins-bundled plugins-bundled -RUN apt-get update && apt-get install -yq git -RUN yarn install --pure-lockfile +RUN yarn install COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ COPY public public @@ -22,7 +24,8 @@ FROM golang:1.17.0 AS go-builder WORKDIR /src/grafana COPY go.mod go.sum embed.go ./ -COPY build.go package.json ./ +COPY Makefile build.go package.json ./ +COPY .bingo .bingo COPY pkg pkg/ COPY cue cue/ COPY cue.mod cue.mod/ @@ -30,7 +33,7 @@ COPY packages/grafana-schema packages/grafana-schema/ COPY public/app/plugins public/app/plugins/ RUN go mod verify -RUN go run build.go build +RUN make build-go FROM ubuntu:20.04 diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md index e33c8047e57..fbb2b79c0f2 100644 --- a/PLUGIN_DEV.md +++ b/PLUGIN_DEV.md @@ -6,7 +6,7 @@ upgrading Grafana please check here before creating an issue. ## Plugin development resources -- [Grafana plugin developer guide](http://docs.grafana.org/plugins/developing/development/) +- [Grafana plugin developer guide](https://grafana.com/docs/grafana/latest/developers/plugins/) - [Webpack Grafana plugin template project](https://github.com/CorpGlory/grafana-plugin-template-webpack) - [Simple JSON datasource plugin](https://github.com/grafana/simple-json-datasource) diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index f421d8df4de..19eaa6cad3f 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -1582,7 +1582,7 @@ We do _not_ recommend using this option. For more information, refer to [Plugin ### plugin_admin_enabled -Available to Grafana administrators only, the plugin admin app is set to `false` by default. Set it to `true` to enable the app. +Available to Grafana administrators only, the plugin admin app is set to `true` by default. Set it to `false` to disable the app. For more information, refer to [Plugin catalog]({{< relref "../plugins/catalog.md" >}}). diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 81243822598..effa8feabfd 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -446,11 +446,12 @@ The following sections detail the supported settings and secure settings for eac #### Alert notification `discord` -| Name | Secure setting | -| ---------- | -------------- | -| url | yes | -| avatar_url | | -| content | | +| Name | Secure setting | +| -------------------- | -------------- | +| url | yes | +| avatar_url | | +| content | | +| use_discord_username | | #### Alert notification `slack` diff --git a/docs/sources/alerting/old-alerting/notifications.md b/docs/sources/alerting/old-alerting/notifications.md index 4a1b06d7069..9bcf5bf025e 100644 --- a/docs/sources/alerting/old-alerting/notifications.md +++ b/docs/sources/alerting/old-alerting/notifications.md @@ -227,11 +227,12 @@ In DingTalk PC Client: To set up Discord, you must create a Discord channel webhook. For instructions on how to create the channel, refer to [Intro to Webhooks](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). -| Setting | Description | -| --------------- | --------------------------------------------------------------------------------- | -| Webhook URL | Discord webhook URL. | -| Message Content | Mention a group using @ or a user using <@ID> when notifying in a channel. | -| Avatar URL | Optionally, provide a URL to an image to use as the avatar for the bot's message. | +| Setting | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------- | +| Webhook URL | Discord webhook URL. | +| Message Content | Mention a group using @ or a user using <@ID> when notifying in a channel. | +| Avatar URL | Optionally, provide a URL to an image to use as the avatar for the bot's message. | +| Use Discord's Webhook Username | Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana.' | Alternately, use the [Slack](#slack) notifier by appending `/slack` to a Discord webhook URL. diff --git a/docs/sources/alerting/unified-alerting/opt-in.md b/docs/sources/alerting/unified-alerting/opt-in.md index 66af3a95f27..04d0a987346 100644 --- a/docs/sources/alerting/unified-alerting/opt-in.md +++ b/docs/sources/alerting/unified-alerting/opt-in.md @@ -8,7 +8,7 @@ weight = 128 This topic describes how to enable Grafana 8 alerts as well as the rules and restrictions that govern the migration of existing dashboard alerts to this new alerting system. You can also [disable Grafana 8 alerts]({{< relref "./opt-in.md#disable-grafana-8-alerts" >}}) if needed. -Before you begin, we recommend that you backup Grafana's database. If you are using PostgreSQL as the backend data source, then the minimum required version is 9.5. +Before you begin, we recommend that you backup Grafana's database. If you are using PostgreSQL as the backend database, then the minimum required version is 9.5. ## Enable Grafana 8 alerts diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index 4e950373b18..7da62401838 100755 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -56,7 +56,7 @@ docker run -d -p 3000:3000 grafana/grafana-enterprise ### Run a specific version of Grafana -> **Note:** If you are on a Linux system, you might need to add `sudo` before the command add your user to the `docker` group. +> **Note:** If you are on a Linux system, you might need to add `sudo` before the command or add your user to the `docker` group. ```bash docker run -d -p 3000:3000 --name grafana grafana/grafana-enterprise: diff --git a/docs/sources/panels/legend-options.md b/docs/sources/panels/legend-options.md index 9e8822f39ea..69a42224da6 100644 --- a/docs/sources/panels/legend-options.md +++ b/docs/sources/panels/legend-options.md @@ -10,8 +10,13 @@ Use the legend to adjust how a visualization displays series. This legend functi This topic currently applies to the following visualizations: +- [Bar chart panel]({{< relref "../visualizations/bar-chart.md">}}) +- [Histogram panel]({{< relref "../visualizations/histogram.md">}}) - [Pie chart panel]({{< relref "../visualizations/pie-chart-panel.md">}}) +- [State timeline panel]({{< relref "../visualizations/state-timeline.md">}}) +- [Status history panel]({{< relref "../visualizations/status-history.md">}}) - [Time series panel]({{< relref "../visualizations/time-series/_index.md" >}}) +- XY chart panel ## Toggle series @@ -34,3 +39,12 @@ This creates a system override that hides the other series. You can view this ov Click on the series icon (colored line beside the series label) in the legend to change selected series color. ![Change legend series color](/static/img/docs/legend/legend-series-color-7-5.png) + +## Sort series + +Change legend mode to **Table** and choose [calculations]({{< relref "./calculations-list.md" >}}) to be displayed in the legend. Click the calculation name header in the legend table to sort the values in the table in ascending or descending order. +The sort order affects the positions of the bars in the Bar chart panel as well as the order of stacked series in the Time series and Bar chart panels. + +> **Note:** This feature is only supported in these panels: Bar chart, Histogram, Time series, XY Chart. + +![Sort legend series](/static/img/docs/legend/legend-series-sort-8-3.png) diff --git a/docs/sources/whatsnew/whats-new-in-v8-2.md b/docs/sources/whatsnew/whats-new-in-v8-2.md index e7e9f5a3bb2..d682617613e 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-2.md +++ b/docs/sources/whatsnew/whats-new-in-v8-2.md @@ -46,7 +46,7 @@ We’ve continued to bolster the new, unified alerting system launched in Grafan ## Image Renderer performance improvements and measurement -You can use Grafana’s image renderer to generate JPEG and PDF images of panels and dashboards. Use these images for alert notifications, PDF exports, and reports sent by Grafana. We’ve added additional metrics to the image renderer to help you diagnose its performance, and [included guidance in our documentation](https://grafana.com/docs/grafana/next/image-rendering/#rendering-mode) to help you configure it for the best mix of performance and resource usage. Tests show that we have reduced image load time from the 95th percentile of 10 seconds to less than 3 seconds under normal load. +You can use Grafana’s image renderer to generate images of panels and dashboards. Grafana uses these images for alert notifications, PDF exports (Grafana Enterprise), and reports sent by Grafana (Grafana Enterprise). We’ve added additional metrics to the image renderer to help you diagnose its performance, and [included guidance in our documentation](https://grafana.com/docs/grafana/next/image-rendering/#rendering-mode) to help you configure it for the best mix of performance and resource usage. Tests show that we have reduced image load time from the 95th percentile of 10 seconds to less than 3 seconds under normal load. # Grafana Enterprise diff --git a/e2e/suite1/specs/visualization-suggestions.ts b/e2e/suite1/specs/visualization-suggestions.ts new file mode 100644 index 00000000000..79035dee674 --- /dev/null +++ b/e2e/suite1/specs/visualization-suggestions.ts @@ -0,0 +1,32 @@ +import { e2e } from '@grafana/e2e'; + +const PANEL_UNDER_TEST = 'Interpolation: linear'; + +e2e.scenario({ + describeName: 'Visualization suggestions', + itName: 'Should be shown and clickable', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: true, + scenario: () => { + e2e.flows.openDashboard({ uid: 'TkZXxlNG3' }); + e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); + + // Try visualization suggestions + e2e.components.PanelEditor.toggleVizPicker().click(); + e2e().contains('Suggestions').click(); + cy.wait(1000); + + // Verify we see suggestions + e2e.components.VisualizationPreview.card('Line chart').should('be.visible'); + + // Verify search works + e2e().get('[placeholder="Search for..."]').type('Table'); + // Should no longer see line chart + e2e.components.VisualizationPreview.card('Line chart').should('not.exist'); + + // Select a visualisation + e2e.components.VisualizationPreview.card('Table').click(); + e2e.components.Panels.Visualization.Table.header().should('be.visible'); + }, +}); diff --git a/go.mod b/go.mod index f198c5df6a5..7926698a08c 100644 --- a/go.mod +++ b/go.mod @@ -265,4 +265,4 @@ replace gopkg.in/macaron.v1 => ./pkg/macaron replace github.com/go-macaron/binding => ./pkg/macaron/binding -replace github.com/hashicorp/consul => github.com/hashicorp/consul v1.9.8 +replace github.com/hashicorp/consul => github.com/hashicorp/consul v1.10.2 diff --git a/package.json b/package.json index 403b4533b06..94921764ec1 100644 --- a/package.json +++ b/package.json @@ -160,8 +160,8 @@ "es6-promise": "4.2.8", "es6-shim": "0.35.5", "eslint": "7.21.0", - "eslint-config-prettier": "7.2.0", - "eslint-plugin-jsdoc": "36.1.0", + "eslint-config-prettier": "8.3.0", + "eslint-plugin-jsdoc": "37.0.0", "eslint-plugin-lodash": "^7.2.0", "eslint-plugin-no-only-tests": "2.4.0", "eslint-plugin-prettier": "3.3.1", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index ac296683eed..9a95d8f6b93 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -24,7 +24,7 @@ "dependencies": { "@braintree/sanitize-url": "5.0.2", "@grafana/schema": "8.3.0-pre", - "@types/d3-interpolate": "^1.3.1", + "@types/d3-interpolate": "^3.0.0", "date-fns": "2.21.3", "eventemitter3": "4.0.7", "lodash": "4.17.21", diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index 8ad6ea3ce18..a2efd5cb665 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -8,6 +8,7 @@ import { PanelTypeChangedHandler, FieldConfigProperty, PanelPluginDataSupport, + VisualizationSuggestionsSupplier, } from '../types'; import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; import { ComponentClass, ComponentType } from 'react'; @@ -104,6 +105,7 @@ export class PanelPlugin< }; private optionsSupplier?: PanelOptionsSupplier; + private suggestionsSupplier?: VisualizationSuggestionsSupplier; panel: ComponentType> | null; editor?: ComponentClass>; @@ -354,4 +356,21 @@ export class PanelPlugin< return this; } + + /** + * Sets function that can return visualization examples and suggestions. + * @alpha + */ + setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier) { + this.suggestionsSupplier = supplier; + return this; + } + + /** + * Returns the suggestions supplier + * @alpha + */ + getSuggestionsSupplier(): VisualizationSuggestionsSupplier | undefined { + return this.suggestionsSupplier; + } } diff --git a/packages/grafana-data/src/types/dashboard.ts b/packages/grafana-data/src/types/dashboard.ts index df1af6165a7..fc69f8f75c9 100644 --- a/packages/grafana-data/src/types/dashboard.ts +++ b/packages/grafana-data/src/types/dashboard.ts @@ -10,7 +10,7 @@ export enum DashboardCursorSync { /** * @public */ -export interface PanelModel { +export interface PanelModel { /** ID of the panel within the current dashboard */ id: number; diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 3b055ba84d9..0190e75fbfc 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -24,7 +24,7 @@ export enum FieldType { * * Plugins may extend this with additional properties. Something like series overrides */ -export interface FieldConfig { +export interface FieldConfig { /** * The display value for this field. This supports template variables blank is auto */ diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index 7a162599ab3..57532652ea2 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -49,7 +49,7 @@ export const isSystemOverride = (override: ConfigOverrideRule): override is Syst return typeof (override as SystemConfigOverrideRule)?.__systemRef === 'string'; }; -export interface FieldConfigSource { +export interface FieldConfigSource { // Defaults applied to all numeric fields defaults: FieldConfig; diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 6b9f6406091..a0f10643d44 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -2,7 +2,7 @@ import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource import { PluginMeta } from './plugin'; import { ScopedVars } from './ScopedVars'; import { LoadingState } from './data'; -import { DataFrame } from './dataFrame'; +import { DataFrame, FieldType } from './dataFrame'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; import { EventBus } from '../events'; import { FieldConfigSource } from './fieldOverrides'; @@ -12,6 +12,8 @@ import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; import { OptionEditorConfig } from './options'; import { AlertStateInfo } from './alerts'; import { PanelModel } from './dashboard'; +import { DataTransformerConfig } from './transformations'; +import { defaultsDeep } from 'lodash'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; @@ -58,7 +60,7 @@ export interface PanelData { timeRange: TimeRange; } -export interface PanelProps { +export interface PanelProps { /** ID of the panel within the current dashboard */ id: number; @@ -182,3 +184,137 @@ export interface PanelPluginDataSupport { annotations: boolean; alertStates: boolean; } + +/** + * @alpha + */ +export interface VisualizationSuggestion { + /** Name of suggestion */ + name: string; + /** Description */ + description?: string; + /** Panel plugin id */ + pluginId: string; + /** Panel plugin options */ + options?: Partial; + /** Panel plugin field options */ + fieldConfig?: FieldConfigSource>; + /** Data transformations */ + transformations?: DataTransformerConfig[]; + /** Tweak for small preview */ + previewModifier?: (suggestion: VisualizationSuggestion) => void; +} + +/** + * @alpha + */ +export interface PanelDataSummary { + hasData?: boolean; + rowCountTotal: number; + rowCountMax: number; + frameCount: number; + numberFieldCount: number; + timeFieldCount: number; + stringFieldCount: number; + hasNumberField?: boolean; + hasTimeField?: boolean; + hasStringField?: boolean; +} + +/** + * @alpha + */ +export class VisualizationSuggestionsBuilder { + /** Current data */ + data?: PanelData; + /** Current panel & options */ + panel?: PanelModel; + /** Summary stats for current data */ + dataSummary: PanelDataSummary; + + private list: VisualizationSuggestion[] = []; + + constructor(data?: PanelData, panel?: PanelModel) { + this.data = data; + this.panel = panel; + this.dataSummary = this.computeDataSummary(); + } + + getListAppender(defaults: VisualizationSuggestion) { + return new VisualizationSuggestionsListAppender(this.list, defaults); + } + + private computeDataSummary() { + const frames = this.data?.series || []; + + let numberFieldCount = 0; + let timeFieldCount = 0; + let stringFieldCount = 0; + let rowCountTotal = 0; + let rowCountMax = 0; + + for (const frame of frames) { + rowCountTotal += frame.length; + + for (const field of frame.fields) { + switch (field.type) { + case FieldType.number: + numberFieldCount += 1; + break; + case FieldType.time: + timeFieldCount += 1; + break; + case FieldType.string: + stringFieldCount += 1; + break; + } + } + + if (frame.length > rowCountMax) { + rowCountMax = frame.length; + } + } + + return { + numberFieldCount, + timeFieldCount, + stringFieldCount, + rowCountTotal, + rowCountMax, + frameCount: frames.length, + hasData: rowCountTotal > 0, + hasTimeField: timeFieldCount > 0, + hasNumberField: numberFieldCount > 0, + hasStringField: stringFieldCount > 0, + }; + } + + getList() { + return this.list; + } +} + +/** + * @alpha + */ +export type VisualizationSuggestionsSupplier = { + /** + * Adds good suitable suggestions for the current data + */ + getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void; +}; + +/** + * Helps with typings and defaults + * @alpha + */ +export class VisualizationSuggestionsListAppender { + constructor( + private list: VisualizationSuggestion[], + private defaults: VisualizationSuggestion + ) {} + + append(overrides: Partial>) { + this.list.push(defaultsDeep(overrides, this.defaults)); + } +} diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 037a7a04caa..36c3d3c6e7b 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -262,4 +262,7 @@ export const Components = { PanelAlertTabContent: { content: 'Unified alert editor tab content', }, + VisualizationPreview: { + card: (name: string) => `data-testid suggestion-${name}`, + }, }; diff --git a/packages/grafana-runtime/src/components/PanelRenderer.tsx b/packages/grafana-runtime/src/components/PanelRenderer.tsx index 6053c9d2070..a2f8cc341cd 100644 --- a/packages/grafana-runtime/src/components/PanelRenderer.tsx +++ b/packages/grafana-runtime/src/components/PanelRenderer.tsx @@ -13,10 +13,10 @@ export interface PanelRendererProps

; onOptionsChange?: (options: P) => void; onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void; - fieldConfig?: FieldConfigSource; + fieldConfig?: FieldConfigSource>; timeZone?: string; width: number; height: number; diff --git a/packages/grafana-schema/src/schema/graph.gen.ts b/packages/grafana-schema/src/schema/graph.gen.ts index 5d9004906c0..0c2df46280c 100644 --- a/packages/grafana-schema/src/schema/graph.gen.ts +++ b/packages/grafana-schema/src/schema/graph.gen.ts @@ -258,6 +258,8 @@ export interface VizLegendOptions { displayMode: LegendDisplayMode; isVisible?: boolean; placement: LegendPlacement; + sortBy?: string; + sortDesc?: boolean; } export enum BarGaugeDisplayMode { diff --git a/packages/grafana-schema/src/schema/legend.cue b/packages/grafana-schema/src/schema/legend.cue index 23986bd83ff..336ab2324b2 100644 --- a/packages/grafana-schema/src/schema/legend.cue +++ b/packages/grafana-schema/src/schema/legend.cue @@ -5,9 +5,11 @@ LegendPlacement: "bottom" | "right" @cuetsy(kind="type") LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(kind="enum") VizLegendOptions: { - displayMode: LegendDisplayMode - placement: LegendPlacement + displayMode: LegendDisplayMode + placement: LegendPlacement asTable?: bool isVisible?: bool - calcs: [...string] + sortBy?: string + sortDesc?: bool + calcs: [...string] } @cuetsy(kind="interface") diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index 4bb218fce31..d95243af5ce 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -51,12 +51,12 @@ "chalk": "^2.4.2", "command-exists": "^1.2.8", "commander": "^5.0.0", - "concurrently": "4.1.0", + "concurrently": "6.3.0", "copy-webpack-plugin": "5.1.2", "css-loader": "3.4.2", "eslint": "7.21.0", "execa": "^5.1.1", - "file-loader": "5.0.2", + "file-loader": "6.2.0", "fork-ts-checker-webpack-plugin": "1.0.0", "fs-extra": "^10.0.0", "globby": "^10.0.1", diff --git a/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx b/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx index ca993414cb8..d394ae335c1 100644 --- a/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx +++ b/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx @@ -1,47 +1,48 @@ -import React, { FC } from 'react'; +import React, { HTMLProps } from 'react'; import { escapeStringForRegex, unEscapeStringFromRegex } from '@grafana/data'; import { Button, Icon, Input } from '..'; -import { useFocus } from '../Input/utils'; +import { useCombinedRefs } from '../../utils/useCombinedRefs'; -export interface Props { +export interface Props extends Omit, 'onChange'> { value: string | undefined; - placeholder?: string; width?: number; onChange: (value: string) => void; - onKeyDown?: (event: React.KeyboardEvent) => void; - autoFocus?: boolean; } -export const FilterInput: FC = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => { - const [inputRef, setInputFocus] = useFocus(); - const suffix = - value !== '' ? ( - - ) : null; +export const FilterInput = React.forwardRef( + ({ value, width, onChange, ...restProps }, ref) => { + const innerRef = React.useRef(null); + const combinedRef = useCombinedRefs(ref, innerRef) as React.Ref; - return ( - } - ref={inputRef} - suffix={suffix} - width={width} - type="text" - value={value ? unEscapeStringFromRegex(value) : ''} - onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))} - onKeyDown={onKeyDown} - placeholder={placeholder} - /> - ); -}; + const suffix = + value !== '' ? ( + + ) : null; + + return ( + } + suffix={suffix} + width={width} + type="text" + value={value ? unEscapeStringFromRegex(value) : ''} + onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))} + {...restProps} + ref={combinedRef} + /> + ); + } +); + +FilterInput.displayName = 'FilterInput'; diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx index 2c265b83618..8989d8d36e6 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -10,7 +10,6 @@ import { Themeable2 } from '../../types'; import { CodeEditorProps, Monaco, MonacoEditor as MonacoEditorType, MonacoOptions } from './types'; import { registerSuggestions } from './suggestions'; -import defineThemes from './theme'; type Props = CodeEditorProps & Themeable2; @@ -85,8 +84,7 @@ class UnthemedCodeEditor extends React.PureComponent { handleBeforeMount = (monaco: Monaco) => { this.monaco = monaco; - const { language, theme, getSuggestions, onBeforeEditorMount } = this.props; - defineThemes(monaco, theme); + const { language, getSuggestions, onBeforeEditorMount } = this.props; if (getSuggestions) { this.completionCancel = registerSuggestions(monaco, language, getSuggestions); @@ -148,7 +146,6 @@ class UnthemedCodeEditor extends React.PureComponent { width={width} height={height} language={language} - theme={theme.isDark ? 'grafana-dark' : 'grafana-light'} value={value} options={{ ...options, diff --git a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx index 61de48cc74e..d0a00fa1172 100644 --- a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditor.tsx @@ -1,5 +1,8 @@ -import React from 'react'; -import MonacoEditor, { loader as monacoEditorLoader, EditorProps as MonacoEditorProps } from '@monaco-editor/react'; +import React, { useEffect } from 'react'; +import MonacoEditor, { loader as monacoEditorLoader, useMonaco } from '@monaco-editor/react'; +import defineThemes from './theme'; +import { useTheme2 } from '../../themes'; +import type { ReactMonacoEditorProps } from './types'; let initalized = false; function initMonaco() { @@ -13,9 +16,30 @@ function initMonaco() { }, }); initalized = true; + monacoEditorLoader.init().then((monaco) => { + // this call makes sure the themes exist. + // they will not have the correct colors, + // but we need them to exist since the beginning, + // because if we start a monaco instance with + // a theme that does not exist, it will not work well. + defineThemes(monaco); + }); } -export const ReactMonacoEditor = (props: MonacoEditorProps) => { +export const ReactMonacoEditor = (props: ReactMonacoEditorProps) => { + const theme = useTheme2(); + const monaco = useMonaco(); + + useEffect(() => { + // monaco can be null at the beginning, because it is loaded in asynchronously + if (monaco !== null) { + defineThemes(monaco, theme); + } + }, [monaco, theme]); + initMonaco(); - return ; + + const monacoTheme = theme.isDark ? 'grafana-dark' : 'grafana-light'; + + return ; }; diff --git a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx index 073b0d1d643..b8db38dac1c 100644 --- a/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx +++ b/packages/grafana-ui/src/components/Monaco/ReactMonacoEditorLazy.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { useAsyncDependency } from '../../utils/useAsyncDependency'; import { ErrorWithStack, LoadingPlaceholder } from '..'; // we only use import type so it will not be included in the bundle -import type { EditorProps } from '@monaco-editor/react'; +import type { ReactMonacoEditorProps } from './types'; /** * @internal * Experimental export **/ -export const ReactMonacoEditorLazy = (props: EditorProps) => { +export const ReactMonacoEditorLazy = (props: ReactMonacoEditorProps) => { const { loading, error, dependency } = useAsyncDependency( import(/* webpackChunkName: "react-monaco-editor" */ './ReactMonacoEditor') ); diff --git a/packages/grafana-ui/src/components/Monaco/theme.ts b/packages/grafana-ui/src/components/Monaco/theme.ts index d2d828c352c..fb5d911bed1 100644 --- a/packages/grafana-ui/src/components/Monaco/theme.ts +++ b/packages/grafana-ui/src/components/Monaco/theme.ts @@ -1,12 +1,22 @@ import { GrafanaTheme2 } from '@grafana/data'; -import { Monaco } from './types'; +import { Monaco, monacoTypes } from './types'; -export default function defineThemes(monaco: Monaco, theme: GrafanaTheme2) { +function getColors(theme?: GrafanaTheme2): monacoTypes.editor.IColors { + if (theme === undefined) { + return {}; + } else { + return { + 'editor.background': theme.components.input.background, + 'minimap.background': theme.colors.background.secondary, + }; + } +} + +// we support calling this without a theme, it will make sure the themes +// are registered in monaco, even if the colors are not perfect. +export default function defineThemes(monaco: Monaco, theme?: GrafanaTheme2) { // color tokens are defined here https://github.com/microsoft/vscode/blob/main/src/vs/platform/theme/common/colorRegistry.ts#L174 - const colors = { - 'editor.background': theme.components.input.background, - 'minimap.background': theme.colors.background.secondary, - }; + const colors = getColors(theme); monaco.editor.defineTheme('grafana-dark', { base: 'vs-dark', diff --git a/packages/grafana-ui/src/components/Monaco/types.ts b/packages/grafana-ui/src/components/Monaco/types.ts index a1d11565cf1..fc8918e8c2e 100644 --- a/packages/grafana-ui/src/components/Monaco/types.ts +++ b/packages/grafana-ui/src/components/Monaco/types.ts @@ -1,5 +1,14 @@ // We use `import type` to guarantee it'll be erased from the JS and it doesnt accidently bundle monaco import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; +import type { EditorProps } from '@monaco-editor/react'; + +// we do not allow customizing the theme. +// (theme is complicated in Monaco, right now there is +// a limitation where all monaco editors must have +// the same theme, see +// https://github.com/microsoft/monaco-editor/issues/338#issuecomment-274837186 +// ) +export type ReactMonacoEditorProps = Omit; export type CodeEditorChangeHandler = (value: string) => void; export type CodeEditorSuggestionProvider = () => CodeEditorSuggestionItem[]; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index 583552046ea..b1581a2414f 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -59,6 +59,11 @@ export interface PanelContext { /** Update instance state, this is only supported in dashboard panel context currently */ onInstanceStateChange?: (state: any) => void; + + /** + * Called when a panel is changing the sort order of the legends. + */ + onToggleLegendSort?: (sortBy: string) => void; } export const PanelContextRoot = React.createContext({ diff --git a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx index 67a2671df9b..f9da81aebdd 100644 --- a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx +++ b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx @@ -8,7 +8,7 @@ import { preparePlotConfigBuilder } from './utils'; import { withTheme2 } from '../../themes/ThemeContext'; import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext'; -const propsToDiff: string[] = []; +const propsToDiff: string[] = ['legend']; type TimeSeriesProps = Omit; @@ -18,7 +18,7 @@ export class UnthemedTimeSeries extends React.Component { prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { const { eventBus, sync } = this.context; - const { theme, timeZone } = this.props; + const { theme, timeZone, legend } = this.props; return preparePlotConfigBuilder({ frame: alignedFrame, @@ -28,6 +28,7 @@ export class UnthemedTimeSeries extends React.Component { eventBus, sync, allFrames, + legend, }); }; diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts index af1e092e96d..00b9a2356f7 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts @@ -23,8 +23,9 @@ import { VisibilityMode, ScaleDirection, ScaleOrientation, + VizLegendOptions, } from '@grafana/schema'; -import { collectStackingGroups, preparePlotData } from '../uPlot/utils'; +import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils'; import uPlot from 'uplot'; const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); @@ -35,7 +36,7 @@ const defaultConfig: GraphFieldConfig = { axisPlacement: AxisPlacement.Auto, }; -export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync }> = ({ +export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync; legend?: VizLegendOptions }> = ({ frame, theme, timeZone, @@ -43,10 +44,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor eventBus, sync, allFrames, + legend, }) => { const builder = new UPlotConfigBuilder(timeZone); - builder.setPrepData(preparePlotData); + builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend)); // X is the first field in the aligned frame const xField = frame.fields[0]; @@ -265,7 +267,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor if (stackingGroups.size !== 0) { builder.setStacking(true); - for (const [_, seriesIdxs] of stackingGroups.entries()) { + for (const [_, seriesIds] of stackingGroups.entries()) { + const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); for (let j = seriesIdxs.length - 1; j > 0; j--) { builder.addBand({ series: [seriesIdxs[j], seriesIdxs[j - 1]], diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx index e034f85f37d..c44a72924d2 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx @@ -23,7 +23,7 @@ export function VizLegend({ itemRenderer, readonly, }: LegendProps) { - const { eventBus, onToggleSeriesVisibility } = usePanelContext(); + const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext(); const onMouseEnter = useCallback( (item: VizLegendItem, event: React.MouseEvent) => { @@ -82,7 +82,7 @@ export function VizLegend({ sortBy={sortKey} sortDesc={sortDesc} onLabelClick={onLegendLabelClick} - onToggleSort={onToggleSort} + onToggleSort={onToggleSort || onToggleLegendSort} onLabelMouseEnter={onMouseEnter} onLabelMouseOut={onMouseOut} itemRenderer={itemRenderer} diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index dbe7fba1103..0bd289bd8f9 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css'; import { VizLegendTableProps } from './types'; import { Icon } from '../Icon/Icon'; import { useStyles2 } from '../../themes/ThemeContext'; -import { sortBy } from 'lodash'; +import { orderBy } from 'lodash'; import { LegendTableItem } from './VizLegendTableItem'; import { DisplayValue, GrafanaTheme2 } from '@grafana/data'; @@ -34,13 +34,17 @@ export const VizLegendTable = ({ } const sortedItems = sortKey - ? sortBy(items, (item) => { - if (item.getDisplayValues) { - const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0]; - return stat && stat.numeric; - } - return undefined; - }) + ? orderBy( + items, + (item) => { + if (item.getDisplayValues) { + const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0]; + return stat && stat.numeric; + } + return undefined; + }, + sortDesc ? 'desc' : 'asc' + ) : items; if (!itemRenderer) { @@ -68,7 +72,9 @@ export const VizLegendTable = ({ { if (onToggleSort) { onToggleSort(columnTitle); @@ -76,9 +82,7 @@ export const VizLegendTable = ({ }} > {columnTitle} - {sortKey === columnTitle && ( - - )} + {sortKey === columnTitle && } ); })} @@ -94,21 +98,23 @@ const getStyles = (theme: GrafanaTheme2) => ({ width: 100%; th:first-child { width: 100%; + border-bottom: 1px solid ${theme.colors.border.weak}; } `, header: css` color: ${theme.colors.primary.text}; font-weight: ${theme.typography.fontWeightMedium}; border-bottom: 1px solid ${theme.colors.border.weak}; - padding: ${theme.spacing(0.25, 1)}; + padding: ${theme.spacing(0.25, 2, 0.25, 1)}; font-size: ${theme.typography.bodySmall.fontSize}; - text-align: right; + text-align: left; white-space: nowrap; `, + // This needs to be padding-right - icon size(xs==12) to avoid jumping + withIcon: css` + padding-right: 4px; + `, headerSortable: css` cursor: pointer; `, - sortIcon: css` - margin-left: ${theme.spacing(1)}; - `, }); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 720396ceee5..f35f7bbb712 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -120,7 +120,7 @@ const getStyles = (theme: GrafanaTheme2) => { align-items: center; `, value: css` - text-align: right; + text-align: left; `, yAxisLabel: css` color: ${theme.colors.text.secondary}; diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx index 847a268870f..ae7117765b6 100644 --- a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx @@ -85,7 +85,13 @@ export const PlotLegend: React.FC = ({ return ( - + ); }; diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 68f9ffecd8f..3897377bba5 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,4 +1,4 @@ -import { preparePlotData, timeFormatToTemplate } from './utils'; +import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils'; import { FieldType, MutableDataFrame } from '@grafana/data'; import { StackingMode } from '@grafana/schema'; @@ -295,5 +295,113 @@ describe('preparePlotData', () => { ] `); }); + + describe('with legend sorted', () => { + it('should affect when single group', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, + { + name: 'a', + values: [-10, 20, 10], + state: { calcs: { max: 20 } }, + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + { + name: 'b', + values: [10, 10, 10], + state: { calcs: { max: 10 } }, + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + { + name: 'c', + values: [20, 20, 20], + state: { calcs: { max: 20 } }, + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + ], + }); + + expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: false } as any)).toMatchInlineSnapshot(` + Array [ + Array [ + 9997, + 9998, + 9999, + ], + Array [ + 0, + 30, + 20, + ], + Array [ + 10, + 10, + 10, + ], + Array [ + 20, + 50, + 40, + ], + ] + `); + expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: true } as any)).toMatchInlineSnapshot(` + Array [ + Array [ + 9997, + 9998, + 9999, + ], + Array [ + -10, + 20, + 10, + ], + Array [ + 20, + 50, + 40, + ], + Array [ + 10, + 40, + 30, + ], + ] + `); + }); + }); + }); +}); + +describe('orderIdsByCalcs', () => { + const ids = [1, 2, 3, 4]; + const frame = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, + { name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } }, + { name: 'b', values: [20, 20, 20], state: { calcs: { min: 20 } } }, + { name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } }, + { name: 'd', values: [30, 30, 30] }, + ], + }); + + it.each([ + { legend: undefined }, + { legend: { sortBy: 'Min' } }, + { legend: { sortDesc: false } }, + { legend: {} }, + { sortBy: 'Mik', sortDesc: true }, + ])('should return without ordering if legend option is %o', (legend: any) => { + const result = orderIdsByCalcs({ ids, frame, legend }); + expect(result).toEqual([1, 2, 3, 4]); + }); + + it('should order the ids based on the frame stat', () => { + const resultDesc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: true } as any }); + expect(resultDesc).toEqual([4, 2, 3, 1]); + const resultAsc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: false } as any }); + expect(resultAsc).toEqual([1, 3, 2, 4]); }); }); diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index aaa496f0654..58f8ae37517 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -1,8 +1,9 @@ import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data'; -import { StackingMode } from '@grafana/schema'; -import { createLogger } from '../../utils/logger'; -import { attachDebugger } from '../../utils'; +import { StackingMode, VizLegendOptions } from '@grafana/schema'; +import { orderBy } from 'lodash'; import { AlignedData, Options, PaddingSide } from 'uplot'; +import { attachDebugger } from '../../utils'; +import { createLogger } from '../../utils/logger'; const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; @@ -39,7 +40,11 @@ interface StackMeta { } /** @internal */ -export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackMeta) => void): AlignedData { +export function preparePlotData( + frames: DataFrame[], + onStackMeta?: (meta: StackMeta) => void, + legend?: VizLegendOptions +): AlignedData { const frame = frames[0]; const result: any[] = []; const stackingGroups: Map = new Map(); @@ -67,7 +72,9 @@ export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackM alignedTotals[0] = null; // array or stacking groups - for (const [_, seriesIdxs] of stackingGroups.entries()) { + for (const [_, seriesIds] of stackingGroups.entries()) { + const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); + const groupTotals = byPct ? Array(dataLength).fill(0) : null; if (byPct) { @@ -184,3 +191,23 @@ export const pluginLogger = createLogger('uPlot'); export const pluginLog = pluginLogger.logger; // pluginLogger.enable(); attachDebugger('graphng', undefined, pluginLogger); + +type OrderIdsByCalcsOptions = { + legend?: VizLegendOptions; + ids: number[]; + frame: DataFrame; +}; +export function orderIdsByCalcs({ legend, ids, frame }: OrderIdsByCalcsOptions) { + if (!legend?.sortBy || legend.sortDesc == null) { + return ids; + } + const orderedIds = orderBy( + ids, + (id) => { + return frame.fields[id].state?.calcs?.[legend.sortBy!.toLowerCase()]; + }, + legend.sortDesc ? 'desc' : 'asc' + ); + + return orderedIds; +} diff --git a/packages/grafana-ui/src/utils/useCombinedRefs.ts b/packages/grafana-ui/src/utils/useCombinedRefs.ts new file mode 100644 index 00000000000..e158a9630db --- /dev/null +++ b/packages/grafana-ui/src/utils/useCombinedRefs.ts @@ -0,0 +1,21 @@ +import React from 'react'; + +export function useCombinedRefs(...refs: any) { + const targetRef = React.useRef(null); + + React.useEffect(() => { + refs.forEach((ref: any) => { + if (!ref) { + return; + } + + if (typeof ref === 'function') { + ref(targetRef.current); + } else { + ref.current = targetRef.current; + } + }); + }, [refs]); + + return targetRef; +} diff --git a/packages/grafana-ui/src/utils/useForceUpdate.ts b/packages/grafana-ui/src/utils/useForceUpdate.ts index e1e17230f44..6f9a08b8b9d 100644 --- a/packages/grafana-ui/src/utils/useForceUpdate.ts +++ b/packages/grafana-ui/src/utils/useForceUpdate.ts @@ -2,6 +2,6 @@ import { useState } from 'react'; /** @internal */ export function useForceUpdate() { - const [value, setValue] = useState(0); // integer state - return () => setValue(value + 1); // update the state to force render + const [_, setValue] = useState(0); // integer state + return () => setValue((prevState) => prevState + 1); // update the state to force render } diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 5a6ff63013d..026bd67ab48 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -51,7 +51,7 @@ func (hs *HTTPServer) initAppPluginRoutes(r *web.Mux) { for _, method := range strings.Split(route.Method, ",") { r.Handle(strings.TrimSpace(method), url, handlers) } - log.Debugf("Plugins: Adding proxy route %s", url) + log.Debug("Plugins: Adding proxy route", "url", url) } } } diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 3d98f14b297..50634cd677e 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -95,7 +95,7 @@ func (a *CacheServer) Handler(ctx *models.ReqContext) { if avatar.Expired() { // The cache item is either expired or newly created, update it from the server if err := avatar.Update(); err != nil { - log.Tracef("avatar update error: %v", err) + log.Debug("avatar update", "err", err) avatar = a.notFound } } @@ -104,7 +104,7 @@ func (a *CacheServer) Handler(ctx *models.ReqContext) { avatar = a.notFound } else if !exists { if err := a.cache.Add(hash, avatar, gocache.DefaultExpiration); err != nil { - log.Tracef("Error adding avatar to cache: %s", err) + log.Debug("add avatar to cache", "err", err) } } @@ -117,7 +117,7 @@ func (a *CacheServer) Handler(ctx *models.ReqContext) { ctx.Resp.Header().Set("Cache-Control", "private, max-age=3600") if err := avatar.Encode(ctx.Resp); err != nil { - log.Warnf("avatar encode error: %v", err) + log.Warn("avatar encode error:", "err", err) ctx.Resp.WriteHeader(500) } } @@ -142,7 +142,7 @@ func newNotFound(cfg *setting.Cfg) *Avatar { // variable. // nolint:gosec if data, err := ioutil.ReadFile(path); err != nil { - log.Errorf(3, "Failed to read user_profile.png, %v", path) + log.Error("Failed to read user_profile.png", "path", path) } else { avatar.data = bytes.NewBuffer(data) } @@ -215,7 +215,7 @@ var client = &http.Client{ func (a *thunderTask) fetch() error { a.Avatar.timestamp = time.Now() - log.Debugf("avatar.fetch(fetch new avatar): %s", a.Url) + log.Debug("avatar.fetch(fetch new avatar)", "url", a.Url) req, err := http.NewRequest("GET", a.Url, nil) if err != nil { return err diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index 46407afcd66..d63af30f088 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -65,7 +65,7 @@ func GetGravatarUrl(text string) string { hasher := md5.New() if _, err := hasher.Write([]byte(strings.ToLower(text))); err != nil { - log.Warnf("Failed to hash text: %s", err) + log.Warn("Failed to hash text", "err", err) } return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hasher.Sum(nil)) } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 4c766f30473..f60b1156d65 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -63,7 +63,7 @@ func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins *plu meta, exists := enabledPlugins.DataSources[ds.Type] if !exists { - log.Errorf(3, "Could not find plugin definition for data source: %v", ds.Type) + log.Error("Could not find plugin definition for data source", "datasource_type", ds.Type) continue } dsMap["meta"] = meta diff --git a/pkg/api/login.go b/pkg/api/login.go index ccb4cc4c5ff..2089838e1c6 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -131,7 +131,7 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { if err := hs.ValidateRedirectTo(redirectTo); err != nil { // the user is already logged so instead of rendering the login page with error // it should be redirected to the home page. - log.Debugf("Ignored invalid redirect_to cookie value: %v", redirectTo) + log.Debug("Ignored invalid redirect_to cookie value", "redirect_to", redirectTo) redirectTo = hs.Cfg.AppSubURL + "/" } cookies.DeleteCookie(c.Resp, "redirect_to", hs.CookieOptionsFromCfg) @@ -152,12 +152,12 @@ func (hs *HTTPServer) tryOAuthAutoLogin(c *models.ReqContext) bool { } oauthInfos := hs.SocialService.GetOAuthInfoProviders() if len(oauthInfos) != 1 { - log.Warnf("Skipping OAuth auto login because multiple OAuth providers are configured") + log.Warn("Skipping OAuth auto login because multiple OAuth providers are configured") return false } for key := range oauthInfos { redirectUrl := hs.Cfg.AppSubURL + "/login/" + key - log.Infof("OAuth auto login enabled. Redirecting to " + redirectUrl) + log.Info("OAuth auto login enabled. Redirecting to " + redirectUrl) c.Redirect(redirectUrl, 307) return true } @@ -249,7 +249,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { if err := hs.ValidateRedirectTo(redirectTo); err == nil { result["redirectUrl"] = redirectTo } else { - log.Infof("Ignored invalid redirect_to cookie value: %v", redirectTo) + log.Info("Ignored invalid redirect_to cookie value.", "url", redirectTo) } cookies.DeleteCookie(c.Resp, "redirect_to", hs.CookieOptionsFromCfg) } diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 5a513569183..2cbbe84e4e5 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -256,7 +256,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { ctx.Redirect(redirectTo) return } - log.Debugf("Ignored invalid redirect_to cookie value: %v", redirectTo) + log.Debug("Ignored invalid redirect_to cookie value", "redirect_to", redirectTo) } ctx.Redirect(setting.AppSubUrl + "/") diff --git a/pkg/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go index d0b8b251740..87fb81ab4c7 100644 --- a/pkg/components/imguploader/s3uploader.go +++ b/pkg/components/imguploader/s3uploader.go @@ -74,7 +74,7 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string, return "", err } key := u.path + rand + pngExt - log.Debugf("Uploading image to s3. bucket = %s, path = %s", u.bucket, key) + log.Debug("Uploading image to s3.", "bucket", u.bucket, "path", key) // We can ignore the gosec G304 warning on this one because `imageDiskPath` comes // from alert notifiers and is only used to upload images generated by alerting. diff --git a/pkg/infra/httpclient/count_bytes_reader.go b/pkg/infra/httpclient/count_bytes_reader.go new file mode 100644 index 00000000000..278a0b378c3 --- /dev/null +++ b/pkg/infra/httpclient/count_bytes_reader.go @@ -0,0 +1,39 @@ +package httpclient + +import ( + "io" +) + +type CloseCallbackFunc func(bytesRead int64) + +// CountBytesReader counts the total amount of bytes read from the underlying reader. +// +// The provided callback func will be called before the underlying reader is closed. +func CountBytesReader(reader io.ReadCloser, callback CloseCallbackFunc) io.ReadCloser { + if reader == nil { + panic("reader cannot be nil") + } + + if callback == nil { + panic("callback cannot be nil") + } + + return &countBytesReader{reader: reader, callback: callback} +} + +type countBytesReader struct { + reader io.ReadCloser + callback CloseCallbackFunc + counter int64 +} + +func (r *countBytesReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + r.counter += int64(n) + return n, err +} + +func (r *countBytesReader) Close() error { + r.callback(r.counter) + return r.reader.Close() +} diff --git a/pkg/infra/httpclient/count_bytes_reader_test.go b/pkg/infra/httpclient/count_bytes_reader_test.go new file mode 100644 index 00000000000..d8cb077328d --- /dev/null +++ b/pkg/infra/httpclient/count_bytes_reader_test.go @@ -0,0 +1,38 @@ +package httpclient + +import ( + "fmt" + "io/ioutil" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCountBytesReader(t *testing.T) { + tcs := []struct { + body string + expectedBytesCount int64 + }{ + {body: "d", expectedBytesCount: 1}, + {body: "dummy", expectedBytesCount: 5}, + } + + for index, tc := range tcs { + t.Run(fmt.Sprintf("Test CountBytesReader %d", index), func(t *testing.T) { + body := ioutil.NopCloser(strings.NewReader(tc.body)) + var actualBytesRead int64 + + readCloser := CountBytesReader(body, func(bytesRead int64) { + actualBytesRead = bytesRead + }) + + bodyBytes, err := ioutil.ReadAll(readCloser) + require.NoError(t, err) + err = readCloser.Close() + require.NoError(t, err) + require.Equal(t, tc.expectedBytesCount, actualBytesRead) + require.Equal(t, string(bodyBytes), tc.body) + }) + } +} diff --git a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go index cdf2addcf11..2709ed6ab76 100644 --- a/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go +++ b/pkg/infra/httpclient/httpclientprovider/datasource_metrics_middleware.go @@ -3,7 +3,8 @@ package httpclientprovider import ( "net/http" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -56,8 +57,8 @@ const DataSourceMetricsMiddlewareName = "metrics" var executeMiddlewareFunc = executeMiddleware -func DataSourceMetricsMiddleware() httpclient.Middleware { - return httpclient.NamedMiddlewareFunc(DataSourceMetricsMiddlewareName, func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { +func DataSourceMetricsMiddleware() sdkhttpclient.Middleware { + return sdkhttpclient.NamedMiddlewareFunc(DataSourceMetricsMiddlewareName, func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { if opts.Labels == nil { return next } @@ -81,7 +82,7 @@ func DataSourceMetricsMiddleware() httpclient.Middleware { } func executeMiddleware(next http.RoundTripper, datasourceLabel prometheus.Labels) http.RoundTripper { - return httpclient.RoundTripperFunc(func(r *http.Request) (*http.Response, error) { + return sdkhttpclient.RoundTripperFunc(func(r *http.Request) (*http.Response, error) { requestCounter := datasourceRequestCounter.MustCurryWith(datasourceLabel) requestSummary := datasourceRequestSummary.MustCurryWith(datasourceLabel) requestInFlight := datasourceRequestsInFlight.With(datasourceLabel) @@ -94,10 +95,11 @@ func executeMiddleware(next http.RoundTripper, datasourceLabel prometheus.Labels if err != nil { return nil, err } - // we avoid measuring contentlength less than zero because it indicates - // that the content size is unknown. https://godoc.org/github.com/badu/http#Response - if res != nil && res.ContentLength > 0 { - responseSizeSummary.Observe(float64(res.ContentLength)) + + if res != nil { + res.Body = httpclient.CountBytesReader(res.Body, func(bytesRead int64) { + responseSizeSummary.Observe(float64(bytesRead)) + }) } return res, nil diff --git a/pkg/infra/kvstore/kvstore_test.go b/pkg/infra/kvstore/kvstore_test.go index 2a7b2087176..90211cc3fe5 100644 --- a/pkg/infra/kvstore/kvstore_test.go +++ b/pkg/infra/kvstore/kvstore_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + package kvstore import ( diff --git a/pkg/infra/kvstore/sql.go b/pkg/infra/kvstore/sql.go index 829cabeaa83..32287f0eecf 100644 --- a/pkg/infra/kvstore/sql.go +++ b/pkg/infra/kvstore/sql.go @@ -2,6 +2,7 @@ package kvstore import ( "context" + "fmt" "time" "github.com/grafana/grafana/pkg/infra/log" @@ -88,7 +89,8 @@ func (kv *kvStoreSQL) Set(ctx context.Context, orgId int64, namespace string, ke // Del deletes an item from the store. func (kv *kvStoreSQL) Del(ctx context.Context, orgId int64, namespace string, key string) error { err := kv.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { - _, err := dbSession.Exec("DELETE FROM kv_store WHERE org_id=? and namespace=? and key=?", orgId, namespace, key) + query := fmt.Sprintf("DELETE FROM kv_store WHERE org_id=? and namespace=? and %s=?", kv.sqlStore.Quote("key")) + _, err := dbSession.Exec(query, orgId, namespace, key) return err }) return err @@ -99,7 +101,7 @@ func (kv *kvStoreSQL) Del(ctx context.Context, orgId int64, namespace string, ke func (kv *kvStoreSQL) Keys(ctx context.Context, orgId int64, namespace string, keyPrefix string) ([]Key, error) { var keys []Key err := kv.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { - query := dbSession.Where("namespace = ?", namespace).And("\"key\" LIKE ?", keyPrefix+"%") + query := dbSession.Where("namespace = ?", namespace).And(fmt.Sprintf("%s LIKE ?", kv.sqlStore.Quote("key")), keyPrefix+"%") if orgId != AllOrganizations { query.And("org_id = ?", orgId) } diff --git a/pkg/infra/log/log.go b/pkg/infra/log/log.go index 188baf71a40..e923912073c 100644 --- a/pkg/infra/log/log.go +++ b/pkg/infra/log/log.go @@ -84,6 +84,14 @@ func Warnf(format string, v ...interface{}) { Root.Warn(message) } +func Debug(msg string, args ...interface{}) { + Root.Debug(msg, args...) +} + +func Info(msg string, args ...interface{}) { + Root.Info(msg, args...) +} + func Error(msg string, args ...interface{}) { Root.Error(msg, args...) } diff --git a/pkg/login/social/common.go b/pkg/login/social/common.go index 376406cf86c..1f2a497b8b1 100644 --- a/pkg/login/social/common.go +++ b/pkg/login/social/common.go @@ -68,7 +68,7 @@ func (s *SocialBase) httpGet(client *http.Client, url string) (response httpGetR return } - log.Tracef("HTTP GET %s: %s %s", url, r.Status, string(response.Body)) + log.Debug("HTTP GET", "url", url, "status", r.Status, "response_body", string(response.Body)) err = nil return diff --git a/pkg/plugins/manager/update_checker.go b/pkg/plugins/manager/update_checker.go index 3b20af0ad4f..26e21727d5c 100644 --- a/pkg/plugins/manager/update_checker.go +++ b/pkg/plugins/manager/update_checker.go @@ -49,7 +49,7 @@ func (pm *PluginManager) checkForUpdates() { pluginSlugs := pm.getAllExternalPluginSlugs() resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion) if err != nil { - log.Tracef("Failed to get plugins repo from grafana.com, %v", err.Error()) + log.Debug("Failed to get plugins repo from grafana.com", "error", err.Error()) return } defer func() { @@ -60,14 +60,14 @@ func (pm *PluginManager) checkForUpdates() { body, err := ioutil.ReadAll(resp.Body) if err != nil { - log.Tracef("Update check failed, reading response from grafana.com, %v", err.Error()) + log.Debug("Update check failed, reading response from grafana.com", "error", err.Error()) return } gNetPlugins := []grafanaNetPlugin{} err = json.Unmarshal(body, &gNetPlugins) if err != nil { - log.Tracef("Failed to unmarshal plugin repo, reading response from grafana.com, %v", err.Error()) + log.Debug("Failed to unmarshal plugin repo, reading response from grafana.com", "error", err.Error()) return } @@ -90,7 +90,7 @@ func (pm *PluginManager) checkForUpdates() { resp2, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/main/latest.json") if err != nil { - log.Tracef("Failed to get latest.json repo from github.com: %v", err.Error()) + log.Debug("Failed to get latest.json repo from github.com", "error", err.Error()) return } defer func() { @@ -100,14 +100,14 @@ func (pm *PluginManager) checkForUpdates() { }() body, err = ioutil.ReadAll(resp2.Body) if err != nil { - log.Tracef("Update check failed, reading response from github.com, %v", err.Error()) + log.Debug("Update check failed, reading response from github.com", "error", err.Error()) return } var latest gitHubLatest err = json.Unmarshal(body, &latest) if err != nil { - log.Tracef("Failed to unmarshal github.com latest, reading response from github.com: %v", err.Error()) + log.Debug("Failed to unmarshal github.com latest, reading response from github.com", "error", err.Error()) return } diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index 9ad49c05313..e90171a1c66 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -134,7 +134,7 @@ func (ac *OSSAccessControlService) saveFixedRole(role accesscontrol.RoleDTO) { // needs to be increased. Hence, we don't overwrite a role with a // greater version. if storedRole.Version >= role.Version { - log.Debugf("role %v has already been stored in a greater version, skipping registration", role.Name) + log.Debug("the has already been stored in a greater version, skipping registration", "role", role.Name) return } } @@ -150,7 +150,7 @@ func (ac *OSSAccessControlService) assignFixedRole(role accesscontrol.RoleDTO, b if ok { for _, assignedRole := range assignments { if assignedRole == role.Name { - log.Debugf("role %v has already been assigned to %v", role.Name, builtInRole) + log.Debug("the role has already been assigned", "rolename", role.Name, "build_in_role", builtInRole) alreadyAssigned = true } } diff --git a/pkg/services/alerting/conditions/evaluator_test.go b/pkg/services/alerting/conditions/evaluator_test.go index ce3100b59e4..f7188f8a49c 100644 --- a/pkg/services/alerting/conditions/evaluator_test.go +++ b/pkg/services/alerting/conditions/evaluator_test.go @@ -3,60 +3,59 @@ package conditions import ( "testing" - . "github.com/smartystreets/goconvey/convey" - "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/stretchr/testify/require" ) -func evaluatorScenario(json string, reducedValue float64, datapoints ...float64) bool { +func evaluatorScenario(t *testing.T, json string, reducedValue float64, datapoints ...float64) bool { jsonModel, err := simplejson.NewJson([]byte(json)) - So(err, ShouldBeNil) + require.NoError(t, err) evaluator, err := NewAlertEvaluator(jsonModel) - So(err, ShouldBeNil) + require.NoError(t, err) return evaluator.Eval(null.FloatFrom(reducedValue)) } func TestEvaluators(t *testing.T) { - Convey("greater then", t, func() { - So(evaluatorScenario(`{"type": "gt", "params": [1] }`, 3), ShouldBeTrue) - So(evaluatorScenario(`{"type": "gt", "params": [3] }`, 1), ShouldBeFalse) + t.Run("greater then", func(t *testing.T) { + require.True(t, evaluatorScenario(t, `{"type": "gt", "params": [1] }`, 3)) + require.False(t, evaluatorScenario(t, `{"type": "gt", "params": [3] }`, 1)) }) - Convey("less then", t, func() { - So(evaluatorScenario(`{"type": "lt", "params": [1] }`, 3), ShouldBeFalse) - So(evaluatorScenario(`{"type": "lt", "params": [3] }`, 1), ShouldBeTrue) + t.Run("less then", func(t *testing.T) { + require.False(t, evaluatorScenario(t, `{"type": "lt", "params": [1] }`, 3)) + require.True(t, evaluatorScenario(t, `{"type": "lt", "params": [3] }`, 1)) }) - Convey("within_range", t, func() { - So(evaluatorScenario(`{"type": "within_range", "params": [1, 100] }`, 3), ShouldBeTrue) - So(evaluatorScenario(`{"type": "within_range", "params": [1, 100] }`, 300), ShouldBeFalse) - So(evaluatorScenario(`{"type": "within_range", "params": [100, 1] }`, 3), ShouldBeTrue) - So(evaluatorScenario(`{"type": "within_range", "params": [100, 1] }`, 300), ShouldBeFalse) + t.Run("within_range", func(t *testing.T) { + require.True(t, evaluatorScenario(t, `{"type": "within_range", "params": [1, 100] }`, 3)) + require.False(t, evaluatorScenario(t, `{"type": "within_range", "params": [1, 100] }`, 300)) + require.True(t, evaluatorScenario(t, `{"type": "within_range", "params": [100, 1] }`, 3)) + require.False(t, evaluatorScenario(t, `{"type": "within_range", "params": [100, 1] }`, 300)) }) - Convey("outside_range", t, func() { - So(evaluatorScenario(`{"type": "outside_range", "params": [1, 100] }`, 1000), ShouldBeTrue) - So(evaluatorScenario(`{"type": "outside_range", "params": [1, 100] }`, 50), ShouldBeFalse) - So(evaluatorScenario(`{"type": "outside_range", "params": [100, 1] }`, 1000), ShouldBeTrue) - So(evaluatorScenario(`{"type": "outside_range", "params": [100, 1] }`, 50), ShouldBeFalse) + t.Run("outside_range", func(t *testing.T) { + require.True(t, evaluatorScenario(t, `{"type": "outside_range", "params": [1, 100] }`, 1000)) + require.False(t, evaluatorScenario(t, `{"type": "outside_range", "params": [1, 100] }`, 50)) + require.True(t, evaluatorScenario(t, `{"type": "outside_range", "params": [100, 1] }`, 1000)) + require.False(t, evaluatorScenario(t, `{"type": "outside_range", "params": [100, 1] }`, 50)) }) - Convey("no_value", t, func() { - Convey("should be false if series have values", func() { - So(evaluatorScenario(`{"type": "no_value", "params": [] }`, 50), ShouldBeFalse) + t.Run("no_value", func(t *testing.T) { + t.Run("should be false if series have values", func(t *testing.T) { + require.False(t, evaluatorScenario(t, `{"type": "no_value", "params": [] }`, 50)) }) - Convey("should be true when the series have no value", func() { + t.Run("should be true when the series have no value", func(t *testing.T) { jsonModel, err := simplejson.NewJson([]byte(`{"type": "no_value", "params": [] }`)) - So(err, ShouldBeNil) + require.NoError(t, err) evaluator, err := NewAlertEvaluator(jsonModel) - So(err, ShouldBeNil) + require.NoError(t, err) - So(evaluator.Eval(null.FloatFromPtr(nil)), ShouldBeTrue) + require.True(t, evaluator.Eval(null.FloatFromPtr(nil))) }) }) } diff --git a/pkg/services/alerting/conditions/query_interval_test.go b/pkg/services/alerting/conditions/query_interval_test.go index c3e6876d79e..33b06c8a497 100644 --- a/pkg/services/alerting/conditions/query_interval_test.go +++ b/pkg/services/alerting/conditions/query_interval_test.go @@ -12,12 +12,13 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/alerting" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) func TestQueryInterval(t *testing.T) { - Convey("When evaluating query condition, regarding the interval value", t, func() { - Convey("Can handle interval-calculation with no panel-min-interval and no datasource-min-interval", func() { + t.Run("When evaluating query condition, regarding the interval value", func(t *testing.T) { + t.Run("Can handle interval-calculation with no panel-min-interval and no datasource-min-interval", func(t *testing.T) { // no panel-min-interval in the queryModel queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` @@ -29,13 +30,13 @@ func TestQueryInterval(t *testing.T) { verifier := func(query plugins.DataSubQuery) { // 5minutes timerange = 300000milliseconds; default-resolution is 1500pixels, // so we should have 300000/1500 = 200milliseconds here - So(query.IntervalMS, ShouldEqual, 200) - So(query.MaxDataPoints, ShouldEqual, interval.DefaultRes) + require.Equal(t, int64(200), query.IntervalMS) + require.Equal(t, interval.DefaultRes, query.MaxDataPoints) } - applyScenario(timeRange, dataSourceJson, queryModel, verifier) + applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) }) - Convey("Can handle interval-calculation with panel-min-interval and no datasource-min-interval", func() { + t.Run("Can handle interval-calculation with panel-min-interval and no datasource-min-interval", func(t *testing.T) { // panel-min-interval in the queryModel queryModel := `{"interval":"123s", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` @@ -45,13 +46,13 @@ func TestQueryInterval(t *testing.T) { timeRange := "5m" verifier := func(query plugins.DataSubQuery) { - So(query.IntervalMS, ShouldEqual, 123000) - So(query.MaxDataPoints, ShouldEqual, interval.DefaultRes) + require.Equal(t, int64(123000), query.IntervalMS) + require.Equal(t, interval.DefaultRes, query.MaxDataPoints) } - applyScenario(timeRange, dataSourceJson, queryModel, verifier) + applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) }) - Convey("Can handle interval-calculation with no panel-min-interval and datasource-min-interval", func() { + t.Run("Can handle interval-calculation with no panel-min-interval and datasource-min-interval", func(t *testing.T) { // no panel-min-interval in the queryModel queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` @@ -59,18 +60,18 @@ func TestQueryInterval(t *testing.T) { dataSourceJson, err := simplejson.NewJson([]byte(`{ "timeInterval": "71s" }`)) - So(err, ShouldBeNil) + require.Nil(t, err) timeRange := "5m" verifier := func(query plugins.DataSubQuery) { - So(query.IntervalMS, ShouldEqual, 71000) - So(query.MaxDataPoints, ShouldEqual, interval.DefaultRes) + require.Equal(t, int64(71000), query.IntervalMS) + require.Equal(t, interval.DefaultRes, query.MaxDataPoints) } - applyScenario(timeRange, dataSourceJson, queryModel, verifier) + applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) }) - Convey("Can handle interval-calculation with both panel-min-interval and datasource-min-interval", func() { + t.Run("Can handle interval-calculation with both panel-min-interval and datasource-min-interval", func(t *testing.T) { // panel-min-interval in the queryModel queryModel := `{"interval":"19s", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` @@ -78,21 +79,21 @@ func TestQueryInterval(t *testing.T) { dataSourceJson, err := simplejson.NewJson([]byte(`{ "timeInterval": "71s" }`)) - So(err, ShouldBeNil) + require.Nil(t, err) timeRange := "5m" verifier := func(query plugins.DataSubQuery) { // when both panel-min-interval and datasource-min-interval exists, // panel-min-interval is used - So(query.IntervalMS, ShouldEqual, 19000) - So(query.MaxDataPoints, ShouldEqual, interval.DefaultRes) + require.Equal(t, int64(19000), query.IntervalMS) + require.Equal(t, interval.DefaultRes, query.MaxDataPoints) } - applyScenario(timeRange, dataSourceJson, queryModel, verifier) + applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) }) - Convey("Can handle no min-interval, and very small time-ranges, where the default-min-interval=1ms applies", func() { + t.Run("Can handle no min-interval, and very small time-ranges, where the default-min-interval=1ms applies", func(t *testing.T) { // no panel-min-interval in the queryModel queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}` @@ -104,11 +105,11 @@ func TestQueryInterval(t *testing.T) { verifier := func(query plugins.DataSubQuery) { // no min-interval exists, the default-min-interval will be used, // and for such a short time-range this will cause the value to be 1millisecond. - So(query.IntervalMS, ShouldEqual, 1) - So(query.MaxDataPoints, ShouldEqual, interval.DefaultRes) + require.Equal(t, int64(1), query.IntervalMS) + require.Equal(t, interval.DefaultRes, query.MaxDataPoints) } - applyScenario(timeRange, dataSourceJson, queryModel, verifier) + applyScenario(t, timeRange, dataSourceJson, queryModel, verifier) }) }) } @@ -135,8 +136,8 @@ func (rh fakeIntervalTestReqHandler) HandleRequest(ctx context.Context, dsInfo * } //nolint: staticcheck // plugins.DataResponse deprecated -func applyScenario(timeRange string, dataSourceJsonData *simplejson.Json, queryModel string, verifier func(query plugins.DataSubQuery)) { - Convey("desc", func() { +func applyScenario(t *testing.T, timeRange string, dataSourceJsonData *simplejson.Json, queryModel string, verifier func(query plugins.DataSubQuery)) { + t.Run("desc", func(t *testing.T) { bus.AddHandlerCtx("test", func(ctx context.Context, query *models.GetDataSourceQuery) error { query.Result = &models.DataSource{Id: 1, Type: "graphite", JsonData: dataSourceJsonData} return nil @@ -159,10 +160,10 @@ func applyScenario(timeRange string, dataSourceJsonData *simplejson.Json, queryM "reducer":{"type": "avg"}, "evaluator":{"type": "gt", "params": [100]} }`)) - So(err, ShouldBeNil) + require.Nil(t, err) condition, err := newQueryCondition(jsonModel, 0) - So(err, ShouldBeNil) + require.Nil(t, err) ctx.condition = condition @@ -179,6 +180,6 @@ func applyScenario(timeRange string, dataSourceJsonData *simplejson.Json, queryM _, err = condition.Eval(ctx.result, reqHandler) - So(err, ShouldBeNil) + require.Nil(t, err) }) } diff --git a/pkg/services/alerting/conditions/query_test.go b/pkg/services/alerting/conditions/query_test.go index 1021ba61fb0..7b88c6d0749 100644 --- a/pkg/services/alerting/conditions/query_test.go +++ b/pkg/services/alerting/conditions/query_test.go @@ -17,7 +17,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/alerting" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" "github.com/xorcare/pointer" ) @@ -33,147 +33,165 @@ func newTimeSeriesPointsFromArgs(values ...float64) plugins.DataTimeSeriesPoints } func TestQueryCondition(t *testing.T) { - Convey("when evaluating query condition", t, func() { - queryConditionScenario("Given avg() and > 100", func(ctx *queryConditionTestContext) { - ctx.reducer = `{"type": "avg"}` - ctx.evaluator = `{"type": "gt", "params": [100]}` + setup := func() *queryConditionTestContext { + ctx := &queryConditionTestContext{} + bus.AddHandlerCtx("test", func(ctx context.Context, query *models.GetDataSourceQuery) error { + query.Result = &models.DataSource{Id: 1, Type: "graphite"} + return nil + }) - Convey("Can read query condition from json model", func() { - _, err := ctx.exec() - So(err, ShouldBeNil) + ctx.reducer = `{"type":"avg"}` + ctx.evaluator = `{"type":"gt","params":[100]}` + ctx.result = &alerting.EvalContext{ + Ctx: context.Background(), + Rule: &alerting.Rule{}, + RequestValidator: &validations.OSSPluginRequestValidator{}, + } + return ctx + } - So(ctx.condition.Query.From, ShouldEqual, "5m") - So(ctx.condition.Query.To, ShouldEqual, "now") - So(ctx.condition.Query.DatasourceID, ShouldEqual, 1) + t.Run("Can read query condition from json model", func(t *testing.T) { + ctx := setup() + _, err := ctx.exec(t) + require.Nil(t, err) - Convey("Can read query reducer", func() { - reducer := ctx.condition.Reducer - So(reducer.Type, ShouldEqual, "avg") - }) + require.Equal(t, "5m", ctx.condition.Query.From) + require.Equal(t, "now", ctx.condition.Query.To) + require.Equal(t, int64(1), ctx.condition.Query.DatasourceID) - Convey("Can read evaluator", func() { - evaluator, ok := ctx.condition.Evaluator.(*thresholdEvaluator) - So(ok, ShouldBeTrue) - So(evaluator.Type, ShouldEqual, "gt") - }) - }) + t.Run("Can read query reducer", func(t *testing.T) { + reducer := ctx.condition.Reducer + require.Equal(t, "avg", reducer.Type) + }) - Convey("should fire when avg is above 100", func() { - points := newTimeSeriesPointsFromArgs(120, 0) - ctx.series = plugins.DataTimeSeriesSlice{plugins.DataTimeSeries{Name: "test1", Points: points}} - cr, err := ctx.exec() + t.Run("Can read evaluator", func(t *testing.T) { + evaluator, ok := ctx.condition.Evaluator.(*thresholdEvaluator) + require.True(t, ok) + require.Equal(t, "gt", evaluator.Type) + }) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeTrue) - }) + t.Run("should fire when avg is above 100", func(t *testing.T) { + ctx := setup() + points := newTimeSeriesPointsFromArgs(120, 0) + ctx.series = plugins.DataTimeSeriesSlice{plugins.DataTimeSeries{Name: "test1", Points: points}} + cr, err := ctx.exec(t) - Convey("should fire when avg is above 100 on dataframe", func() { - ctx.frame = data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Now(), time.Now()}), - data.NewField("val", nil, []int64{120, 150}), - ) - cr, err := ctx.exec() + require.Nil(t, err) + require.True(t, cr.Firing) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeTrue) - }) + t.Run("should fire when avg is above 100 on dataframe", func(t *testing.T) { + ctx := setup() + ctx.frame = data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Now(), time.Now()}), + data.NewField("val", nil, []int64{120, 150}), + ) + cr, err := ctx.exec(t) - Convey("Should not fire when avg is below 100", func() { - points := newTimeSeriesPointsFromArgs(90, 0) - ctx.series = plugins.DataTimeSeriesSlice{plugins.DataTimeSeries{Name: "test1", Points: points}} - cr, err := ctx.exec() + require.Nil(t, err) + require.True(t, cr.Firing) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeFalse) - }) + t.Run("Should not fire when avg is below 100", func(t *testing.T) { + ctx := setup() + points := newTimeSeriesPointsFromArgs(90, 0) + ctx.series = plugins.DataTimeSeriesSlice{plugins.DataTimeSeries{Name: "test1", Points: points}} + cr, err := ctx.exec(t) - Convey("Should not fire when avg is below 100 on dataframe", func() { - ctx.frame = data.NewFrame("", - data.NewField("time", nil, []time.Time{time.Now(), time.Now()}), - data.NewField("val", nil, []int64{12, 47}), - ) - cr, err := ctx.exec() + require.Nil(t, err) + require.False(t, cr.Firing) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeFalse) - }) + t.Run("Should not fire when avg is below 100 on dataframe", func(t *testing.T) { + ctx := setup() + ctx.frame = data.NewFrame("", + data.NewField("time", nil, []time.Time{time.Now(), time.Now()}), + data.NewField("val", nil, []int64{12, 47}), + ) + cr, err := ctx.exec(t) - Convey("Should fire if only first series matches", func() { - ctx.series = plugins.DataTimeSeriesSlice{ - plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs(120, 0)}, - plugins.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(0, 0)}, - } - cr, err := ctx.exec() + require.Nil(t, err) + require.False(t, cr.Firing) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeTrue) - }) + t.Run("Should fire if only first series matches", func(t *testing.T) { + ctx := setup() + ctx.series = plugins.DataTimeSeriesSlice{ + plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs(120, 0)}, + plugins.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(0, 0)}, + } + cr, err := ctx.exec(t) - Convey("No series", func() { - Convey("Should set NoDataFound when condition is gt", func() { - ctx.series = plugins.DataTimeSeriesSlice{} - cr, err := ctx.exec() + require.Nil(t, err) + require.True(t, cr.Firing) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeFalse) - So(cr.NoDataFound, ShouldBeTrue) - }) + t.Run("No series", func(t *testing.T) { + ctx := setup() + t.Run("Should set NoDataFound when condition is gt", func(t *testing.T) { + ctx.series = plugins.DataTimeSeriesSlice{} + cr, err := ctx.exec(t) - Convey("Should be firing when condition is no_value", func() { - ctx.evaluator = `{"type": "no_value", "params": []}` - ctx.series = plugins.DataTimeSeriesSlice{} - cr, err := ctx.exec() + require.Nil(t, err) + require.False(t, cr.Firing) + require.True(t, cr.NoDataFound) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeTrue) - }) - }) + t.Run("Should be firing when condition is no_value", func(t *testing.T) { + ctx.evaluator = `{"type": "no_value", "params": []}` + ctx.series = plugins.DataTimeSeriesSlice{} + cr, err := ctx.exec(t) - Convey("Empty series", func() { - Convey("Should set Firing if eval match", func() { - ctx.evaluator = `{"type": "no_value", "params": []}` - ctx.series = plugins.DataTimeSeriesSlice{ - plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, - } - cr, err := ctx.exec() + require.Nil(t, err) + require.True(t, cr.Firing) + }) + }) - So(err, ShouldBeNil) - So(cr.Firing, ShouldBeTrue) - }) + t.Run("Empty series", func(t *testing.T) { + ctx := setup() + t.Run("Should set Firing if eval match", func(t *testing.T) { + ctx.evaluator = `{"type": "no_value", "params": []}` + ctx.series = plugins.DataTimeSeriesSlice{ + plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, + } + cr, err := ctx.exec(t) - Convey("Should set NoDataFound both series are empty", func() { - ctx.series = plugins.DataTimeSeriesSlice{ - plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, - plugins.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs()}, - } - cr, err := ctx.exec() + require.Nil(t, err) + require.True(t, cr.Firing) + }) - So(err, ShouldBeNil) - So(cr.NoDataFound, ShouldBeTrue) - }) + t.Run("Should set NoDataFound both series are empty", func(t *testing.T) { + ctx.series = plugins.DataTimeSeriesSlice{ + plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, + plugins.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs()}, + } + cr, err := ctx.exec(t) - Convey("Should set NoDataFound both series contains null", func() { - ctx.series = plugins.DataTimeSeriesSlice{ - plugins.DataTimeSeries{Name: "test1", Points: plugins.DataTimeSeriesPoints{plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}}, - plugins.DataTimeSeries{Name: "test2", Points: plugins.DataTimeSeriesPoints{plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}}, - } - cr, err := ctx.exec() + require.Nil(t, err) + require.True(t, cr.NoDataFound) + }) - So(err, ShouldBeNil) - So(cr.NoDataFound, ShouldBeTrue) - }) + t.Run("Should set NoDataFound both series contains null", func(t *testing.T) { + ctx.series = plugins.DataTimeSeriesSlice{ + plugins.DataTimeSeries{Name: "test1", Points: plugins.DataTimeSeriesPoints{plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}}, + plugins.DataTimeSeries{Name: "test2", Points: plugins.DataTimeSeriesPoints{plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}}, + } + cr, err := ctx.exec(t) - Convey("Should not set NoDataFound if one series is empty", func() { - ctx.series = plugins.DataTimeSeriesSlice{ - plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, - plugins.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(120, 0)}, - } - cr, err := ctx.exec() + require.Nil(t, err) + require.True(t, cr.NoDataFound) + }) - So(err, ShouldBeNil) - So(cr.NoDataFound, ShouldBeFalse) - }) - }) + t.Run("Should not set NoDataFound if one series is empty", func(t *testing.T) { + ctx.series = plugins.DataTimeSeriesSlice{ + plugins.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()}, + plugins.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(120, 0)}, + } + cr, err := ctx.exec(t) + + require.Nil(t, err) + require.False(t, cr.NoDataFound) }) }) } @@ -187,10 +205,8 @@ type queryConditionTestContext struct { condition *QueryCondition } -type queryConditionScenarioFunc func(c *queryConditionTestContext) - //nolint: staticcheck // plugins.DataPlugin deprecated -func (ctx *queryConditionTestContext) exec() (*alerting.ConditionResult, error) { +func (ctx *queryConditionTestContext) exec(t *testing.T) (*alerting.ConditionResult, error) { jsonModel, err := simplejson.NewJson([]byte(`{ "type": "query", "query": { @@ -201,10 +217,10 @@ func (ctx *queryConditionTestContext) exec() (*alerting.ConditionResult, error) "reducer":` + ctx.reducer + `, "evaluator":` + ctx.evaluator + ` }`)) - So(err, ShouldBeNil) + require.Nil(t, err) condition, err := newQueryCondition(jsonModel, 0) - So(err, ShouldBeNil) + require.Nil(t, err) ctx.condition = condition @@ -239,24 +255,6 @@ func (rh fakeReqHandler) HandleRequest(context.Context, *models.DataSource, plug return rh.response, nil } -func queryConditionScenario(desc string, fn queryConditionScenarioFunc) { - Convey(desc, func() { - bus.AddHandlerCtx("test", func(ctx context.Context, query *models.GetDataSourceQuery) error { - query.Result = &models.DataSource{Id: 1, Type: "graphite"} - return nil - }) - - ctx := &queryConditionTestContext{} - ctx.result = &alerting.EvalContext{ - Ctx: context.Background(), - Rule: &alerting.Rule{}, - RequestValidator: &validations.OSSPluginRequestValidator{}, - } - - fn(ctx) - }) -} - func TestFrameToSeriesSlice(t *testing.T) { tests := []struct { name string diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go index baa46925785..f60c78d2db1 100644 --- a/pkg/services/alerting/conditions/reducer_test.go +++ b/pkg/services/alerting/conditions/reducer_test.go @@ -4,183 +4,103 @@ import ( "math" "testing" - . "github.com/smartystreets/goconvey/convey" - "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/plugins" + "github.com/stretchr/testify/require" ) func TestSimpleReducer(t *testing.T) { - Convey("Test simple reducer by calculating", t, func() { - Convey("sum", func() { - result := testReducer("sum", 1, 2, 3) - So(result, ShouldEqual, float64(6)) - }) + t.Run("sum", func(t *testing.T) { + result := testReducer("sum", 1, 2, 3) + require.Equal(t, float64(6), result) + }) - Convey("min", func() { - result := testReducer("min", 3, 2, 1) - So(result, ShouldEqual, float64(1)) - }) + t.Run("min", func(t *testing.T) { + result := testReducer("min", 3, 2, 1) + require.Equal(t, float64(1), result) + }) - Convey("max", func() { - result := testReducer("max", 1, 2, 3) - So(result, ShouldEqual, float64(3)) - }) + t.Run("max", func(t *testing.T) { + result := testReducer("max", 1, 2, 3) + require.Equal(t, float64(3), result) + }) - Convey("count", func() { - result := testReducer("count", 1, 2, 3000) - So(result, ShouldEqual, float64(3)) - }) + t.Run("count", func(t *testing.T) { + result := testReducer("count", 1, 2, 3000) + require.Equal(t, float64(3), result) + }) - Convey("last", func() { - result := testReducer("last", 1, 2, 3000) - So(result, ShouldEqual, float64(3000)) - }) + t.Run("last", func(t *testing.T) { + result := testReducer("last", 1, 2, 3000) + require.Equal(t, float64(3000), result) + }) - Convey("median odd amount of numbers", func() { - result := testReducer("median", 1, 2, 3000) - So(result, ShouldEqual, float64(2)) - }) + t.Run("median odd amount of numbers", func(t *testing.T) { + result := testReducer("median", 1, 2, 3000) + require.Equal(t, float64(2), result) + }) - Convey("median even amount of numbers", func() { - result := testReducer("median", 1, 2, 4, 3000) - So(result, ShouldEqual, float64(3)) - }) + t.Run("median even amount of numbers", func(t *testing.T) { + result := testReducer("median", 1, 2, 4, 3000) + require.Equal(t, float64(3), result) + }) - Convey("median with one values", func() { - result := testReducer("median", 1) - So(result, ShouldEqual, float64(1)) - }) + t.Run("median with one values", func(t *testing.T) { + result := testReducer("median", 1) + require.Equal(t, float64(1), result) + }) - Convey("median should ignore null values", func() { - reducer := newSimpleReducer("median") + t.Run("median should ignore null values", func(t *testing.T) { + reducer := newSimpleReducer("median") + series := plugins.DataTimeSeries{ + Name: "test time series", + } + + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(float64(1)), null.FloatFrom(4)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(float64(2)), null.FloatFrom(5)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(float64(3)), null.FloatFrom(6)}) + + result := reducer.Reduce(series) + require.Equal(t, true, result.Valid) + require.Equal(t, float64(2), result.Float64) + }) + + t.Run("avg", func(t *testing.T) { + result := testReducer("avg", 1, 2, 3) + require.Equal(t, float64(2), result) + }) + + t.Run("avg with only nulls", func(t *testing.T) { + reducer := newSimpleReducer("avg") + series := plugins.DataTimeSeries{ + Name: "test time series", + } + + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) + require.Equal(t, false, reducer.Reduce(series).Valid) + }) + + t.Run("count_non_null", func(t *testing.T) { + t.Run("with null values and real values", func(t *testing.T) { + reducer := newSimpleReducer("count_non_null") series := plugins.DataTimeSeries{ Name: "test time series", } series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(float64(1)), null.FloatFrom(4)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(float64(2)), null.FloatFrom(5)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(float64(3)), null.FloatFrom(6)}) - - result := reducer.Reduce(series) - So(result.Valid, ShouldEqual, true) - So(result.Float64, ShouldEqual, float64(2)) - }) - - Convey("avg", func() { - result := testReducer("avg", 1, 2, 3) - So(result, ShouldEqual, float64(2)) - }) - - Convey("avg with only nulls", func() { - reducer := newSimpleReducer("avg") - series := plugins.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - So(reducer.Reduce(series).Valid, ShouldEqual, false) - }) - - Convey("count_non_null", func() { - Convey("with null values and real values", func() { - reducer := newSimpleReducer("count_non_null") - series := plugins.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(3), null.FloatFrom(3)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(3), null.FloatFrom(4)}) - - So(reducer.Reduce(series).Valid, ShouldEqual, true) - So(reducer.Reduce(series).Float64, ShouldEqual, 2) - }) - - Convey("with null values", func() { - reducer := newSimpleReducer("count_non_null") - series := plugins.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - - So(reducer.Reduce(series).Valid, ShouldEqual, false) - }) - }) - - Convey("avg of number values and null values should ignore nulls", func() { - reducer := newSimpleReducer("avg") - series := plugins.DataTimeSeries{ - Name: "test time series", - } - - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(3), null.FloatFrom(1)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(3), null.FloatFrom(3)}) series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(3), null.FloatFrom(4)}) - So(reducer.Reduce(series).Float64, ShouldEqual, float64(3)) + require.Equal(t, true, reducer.Reduce(series).Valid) + require.Equal(t, 2.0, reducer.Reduce(series).Float64) }) - // diff function Test Suite - Convey("diff of one positive point", func() { - result := testReducer("diff", 30) - So(result, ShouldEqual, float64(0)) - }) - - Convey("diff of one negative point", func() { - result := testReducer("diff", -30) - So(result, ShouldEqual, float64(0)) - }) - - Convey("diff of two positive points[1]", func() { - result := testReducer("diff", 30, 40) - So(result, ShouldEqual, float64(10)) - }) - - Convey("diff of two positive points[2]", func() { - result := testReducer("diff", 30, 20) - So(result, ShouldEqual, float64(-10)) - }) - - Convey("diff of two negative points[1]", func() { - result := testReducer("diff", -30, -40) - So(result, ShouldEqual, float64(-10)) - }) - - Convey("diff of two negative points[2]", func() { - result := testReducer("diff", -30, -10) - So(result, ShouldEqual, float64(20)) - }) - - Convey("diff of one positive and one negative point", func() { - result := testReducer("diff", 30, -40) - So(result, ShouldEqual, float64(-70)) - }) - - Convey("diff of one negative and one positive point", func() { - result := testReducer("diff", -30, 40) - So(result, ShouldEqual, float64(70)) - }) - - Convey("diff of three positive points", func() { - result := testReducer("diff", 30, 40, 50) - So(result, ShouldEqual, float64(20)) - }) - - Convey("diff of three negative points", func() { - result := testReducer("diff", -30, -40, -50) - So(result, ShouldEqual, float64(-20)) - }) - - Convey("diff with only nulls", func() { - reducer := newSimpleReducer("diff") + t.Run("with null values", func(t *testing.T) { + reducer := newSimpleReducer("count_non_null") series := plugins.DataTimeSeries{ Name: "test time series", } @@ -188,212 +108,289 @@ func TestSimpleReducer(t *testing.T) { series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - So(reducer.Reduce(series).Valid, ShouldEqual, false) + require.Equal(t, false, reducer.Reduce(series).Valid) }) + }) - // diff_abs function Test Suite - Convey("diff_abs of one positive point", func() { - result := testReducer("diff_abs", 30) - So(result, ShouldEqual, float64(0)) - }) + t.Run("avg of number values and null values should ignore nulls", func(t *testing.T) { + reducer := newSimpleReducer("avg") + series := plugins.DataTimeSeries{ + Name: "test time series", + } - Convey("diff_abs of one negative point", func() { - result := testReducer("diff_abs", -30) - So(result, ShouldEqual, float64(0)) - }) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(3), null.FloatFrom(1)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFrom(3), null.FloatFrom(4)}) - Convey("diff_abs of two positive points[1]", func() { - result := testReducer("diff_abs", 30, 40) - So(result, ShouldEqual, float64(10)) - }) + require.Equal(t, float64(3), reducer.Reduce(series).Float64) + }) - Convey("diff_abs of two positive points[2]", func() { - result := testReducer("diff_abs", 30, 20) - So(result, ShouldEqual, float64(10)) - }) + // diff function Test Suite + t.Run("diff of one positive point", func(t *testing.T) { + result := testReducer("diff", 30) + require.Equal(t, float64(0), result) + }) - Convey("diff_abs of two negative points[1]", func() { - result := testReducer("diff_abs", -30, -40) - So(result, ShouldEqual, float64(10)) - }) + t.Run("diff of one negative point", func(t *testing.T) { + result := testReducer("diff", -30) + require.Equal(t, float64(0), result) + }) - Convey("diff_abs of two negative points[2]", func() { - result := testReducer("diff_abs", -30, -10) - So(result, ShouldEqual, float64(20)) - }) + t.Run("diff of two positive points[1]", func(t *testing.T) { + result := testReducer("diff", 30, 40) + require.Equal(t, float64(10), result) + }) - Convey("diff_abs of one positive and one negative point", func() { - result := testReducer("diff_abs", 30, -40) - So(result, ShouldEqual, float64(70)) - }) + t.Run("diff of two positive points[2]", func(t *testing.T) { + result := testReducer("diff", 30, 20) + require.Equal(t, float64(-10), result) + }) - Convey("diff_abs of one negative and one positive point", func() { - result := testReducer("diff_abs", -30, 40) - So(result, ShouldEqual, float64(70)) - }) + t.Run("diff of two negative points[1]", func(t *testing.T) { + result := testReducer("diff", -30, -40) + require.Equal(t, float64(-10), result) + }) - Convey("diff_abs of three positive points", func() { - result := testReducer("diff_abs", 30, 40, 50) - So(result, ShouldEqual, float64(20)) - }) + t.Run("diff of two negative points[2]", func(t *testing.T) { + result := testReducer("diff", -30, -10) + require.Equal(t, float64(20), result) + }) - Convey("diff_abs of three negative points", func() { - result := testReducer("diff_abs", -30, -40, -50) - So(result, ShouldEqual, float64(20)) - }) + t.Run("diff of one positive and one negative point", func(t *testing.T) { + result := testReducer("diff", 30, -40) + require.Equal(t, float64(-70), result) + }) - Convey("diff_abs with only nulls", func() { - reducer := newSimpleReducer("diff_abs") - series := plugins.DataTimeSeries{ - Name: "test time series", - } + t.Run("diff of one negative and one positive point", func(t *testing.T) { + result := testReducer("diff", -30, 40) + require.Equal(t, float64(70), result) + }) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) + t.Run("diff of three positive points", func(t *testing.T) { + result := testReducer("diff", 30, 40, 50) + require.Equal(t, float64(20), result) + }) - So(reducer.Reduce(series).Valid, ShouldEqual, false) - }) + t.Run("diff of three negative points", func(t *testing.T) { + result := testReducer("diff", -30, -40, -50) + require.Equal(t, float64(-20), result) + }) - // percent_diff function Test Suite - Convey("percent_diff of one positive point", func() { - result := testReducer("percent_diff", 30) - So(result, ShouldEqual, float64(0)) - }) + t.Run("diff with only nulls", func(t *testing.T) { + reducer := newSimpleReducer("diff") + series := plugins.DataTimeSeries{ + Name: "test time series", + } - Convey("percent_diff of one negative point", func() { - result := testReducer("percent_diff", -30) - So(result, ShouldEqual, float64(0)) - }) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - Convey("percent_diff of two positive points[1]", func() { - result := testReducer("percent_diff", 30, 40) - So(result, ShouldEqual, float64(33.33333333333333)) - }) + require.Equal(t, false, reducer.Reduce(series).Valid) + }) - Convey("percent_diff of two positive points[2]", func() { - result := testReducer("percent_diff", 30, 20) - So(result, ShouldEqual, float64(-33.33333333333333)) - }) + // diff_abs function Test Suite + t.Run("diff_abs of one positive point", func(t *testing.T) { + result := testReducer("diff_abs", 30) + require.Equal(t, float64(0), result) + }) - Convey("percent_diff of two negative points[1]", func() { - result := testReducer("percent_diff", -30, -40) - So(result, ShouldEqual, float64(-33.33333333333333)) - }) + t.Run("diff_abs of one negative point", func(t *testing.T) { + result := testReducer("diff_abs", -30) + require.Equal(t, float64(0), result) + }) - Convey("percent_diff of two negative points[2]", func() { - result := testReducer("percent_diff", -30, -10) - So(result, ShouldEqual, float64(66.66666666666666)) - }) + t.Run("diff_abs of two positive points[1]", func(t *testing.T) { + result := testReducer("diff_abs", 30, 40) + require.Equal(t, float64(10), result) + }) - Convey("percent_diff of one positive and one negative point", func() { - result := testReducer("percent_diff", 30, -40) - So(result, ShouldEqual, float64(-233.33333333333334)) - }) + t.Run("diff_abs of two positive points[2]", func(t *testing.T) { + result := testReducer("diff_abs", 30, 20) + require.Equal(t, float64(10), result) + }) - Convey("percent_diff of one negative and one positive point", func() { - result := testReducer("percent_diff", -30, 40) - So(result, ShouldEqual, float64(233.33333333333334)) - }) + t.Run("diff_abs of two negative points[1]", func(t *testing.T) { + result := testReducer("diff_abs", -30, -40) + require.Equal(t, float64(10), result) + }) - Convey("percent_diff of three positive points", func() { - result := testReducer("percent_diff", 30, 40, 50) - So(result, ShouldEqual, float64(66.66666666666666)) - }) + t.Run("diff_abs of two negative points[2]", func(t *testing.T) { + result := testReducer("diff_abs", -30, -10) + require.Equal(t, float64(20), result) + }) - Convey("percent_diff of three negative points", func() { - result := testReducer("percent_diff", -30, -40, -50) - So(result, ShouldEqual, float64(-66.66666666666666)) - }) + t.Run("diff_abs of one positive and one negative point", func(t *testing.T) { + result := testReducer("diff_abs", 30, -40) + require.Equal(t, float64(70), result) + }) - Convey("percent_diff with only nulls", func() { - reducer := newSimpleReducer("percent_diff") - series := plugins.DataTimeSeries{ - Name: "test time series", - } + t.Run("diff_abs of one negative and one positive point", func(t *testing.T) { + result := testReducer("diff_abs", -30, 40) + require.Equal(t, float64(70), result) + }) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) + t.Run("diff_abs of three positive points", func(t *testing.T) { + result := testReducer("diff_abs", 30, 40, 50) + require.Equal(t, float64(20), result) + }) - So(reducer.Reduce(series).Valid, ShouldEqual, false) - }) + t.Run("diff_abs of three negative points", func(t *testing.T) { + result := testReducer("diff_abs", -30, -40, -50) + require.Equal(t, float64(20), result) + }) - // percent_diff_abs function Test Suite - Convey("percent_diff_abs_abs of one positive point", func() { - result := testReducer("percent_diff_abs", 30) - So(result, ShouldEqual, float64(0)) - }) + t.Run("diff_abs with only nulls", func(t *testing.T) { + reducer := newSimpleReducer("diff_abs") + series := plugins.DataTimeSeries{ + Name: "test time series", + } - Convey("percent_diff_abs of one negative point", func() { - result := testReducer("percent_diff_abs", -30) - So(result, ShouldEqual, float64(0)) - }) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - Convey("percent_diff_abs of two positive points[1]", func() { - result := testReducer("percent_diff_abs", 30, 40) - So(result, ShouldEqual, float64(33.33333333333333)) - }) + require.Equal(t, false, reducer.Reduce(series).Valid) + }) - Convey("percent_diff_abs of two positive points[2]", func() { - result := testReducer("percent_diff_abs", 30, 20) - So(result, ShouldEqual, float64(33.33333333333333)) - }) + // percent_diff function Test Suite + t.Run("percent_diff of one positive point", func(t *testing.T) { + result := testReducer("percent_diff", 30) + require.Equal(t, float64(0), result) + }) - Convey("percent_diff_abs of two negative points[1]", func() { - result := testReducer("percent_diff_abs", -30, -40) - So(result, ShouldEqual, float64(33.33333333333333)) - }) + t.Run("percent_diff of one negative point", func(t *testing.T) { + result := testReducer("percent_diff", -30) + require.Equal(t, float64(0), result) + }) - Convey("percent_diff_abs of two negative points[2]", func() { - result := testReducer("percent_diff_abs", -30, -10) - So(result, ShouldEqual, float64(66.66666666666666)) - }) + t.Run("percent_diff of two positive points[1]", func(t *testing.T) { + result := testReducer("percent_diff", 30, 40) + require.Equal(t, float64(33.33333333333333), result) + }) - Convey("percent_diff_abs of one positive and one negative point", func() { - result := testReducer("percent_diff_abs", 30, -40) - So(result, ShouldEqual, float64(233.33333333333334)) - }) + t.Run("percent_diff of two positive points[2]", func(t *testing.T) { + result := testReducer("percent_diff", 30, 20) + require.Equal(t, float64(-33.33333333333333), result) + }) - Convey("percent_diff_abs of one negative and one positive point", func() { - result := testReducer("percent_diff_abs", -30, 40) - So(result, ShouldEqual, float64(233.33333333333334)) - }) + t.Run("percent_diff of two negative points[1]", func(t *testing.T) { + result := testReducer("percent_diff", -30, -40) + require.Equal(t, float64(-33.33333333333333), result) + }) - Convey("percent_diff_abs of three positive points", func() { - result := testReducer("percent_diff_abs", 30, 40, 50) - So(result, ShouldEqual, float64(66.66666666666666)) - }) + t.Run("percent_diff of two negative points[2]", func(t *testing.T) { + result := testReducer("percent_diff", -30, -10) + require.Equal(t, float64(66.66666666666666), result) + }) - Convey("percent_diff_abs of three negative points", func() { - result := testReducer("percent_diff_abs", -30, -40, -50) - So(result, ShouldEqual, float64(66.66666666666666)) - }) + t.Run("percent_diff of one positive and one negative point", func(t *testing.T) { + result := testReducer("percent_diff", 30, -40) + require.Equal(t, float64(-233.33333333333334), result) + }) - Convey("percent_diff_abs with only nulls", func() { - reducer := newSimpleReducer("percent_diff_abs") - series := plugins.DataTimeSeries{ - Name: "test time series", - } + t.Run("percent_diff of one negative and one positive point", func(t *testing.T) { + result := testReducer("percent_diff", -30, 40) + require.Equal(t, float64(233.33333333333334), result) + }) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) - series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) + t.Run("percent_diff of three positive points", func(t *testing.T) { + result := testReducer("percent_diff", 30, 40, 50) + require.Equal(t, float64(66.66666666666666), result) + }) - So(reducer.Reduce(series).Valid, ShouldEqual, false) - }) + t.Run("percent_diff of three negative points", func(t *testing.T) { + result := testReducer("percent_diff", -30, -40, -50) + require.Equal(t, float64(-66.66666666666666), result) + }) - Convey("min should work with NaNs", func() { - result := testReducer("min", math.NaN(), math.NaN(), math.NaN()) - So(result, ShouldEqual, float64(0)) - }) + t.Run("percent_diff with only nulls", func(t *testing.T) { + reducer := newSimpleReducer("percent_diff") + series := plugins.DataTimeSeries{ + Name: "test time series", + } - Convey("isValid should treat NaN as invalid", func() { - result := isValid(null.FloatFrom(math.NaN())) - So(result, ShouldBeFalse) - }) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) - Convey("isValid should treat invalid null.Float as invalid", func() { - result := isValid(null.FloatFromPtr(nil)) - So(result, ShouldBeFalse) - }) + require.Equal(t, false, reducer.Reduce(series).Valid) + }) + + // percent_diff_abs function Test Suite + t.Run("percent_diff_abs_abs of one positive point", func(t *testing.T) { + result := testReducer("percent_diff_abs", 30) + require.Equal(t, float64(0), result) + }) + + t.Run("percent_diff_abs of one negative point", func(t *testing.T) { + result := testReducer("percent_diff_abs", -30) + require.Equal(t, float64(0), result) + }) + + t.Run("percent_diff_abs of two positive points[1]", func(t *testing.T) { + result := testReducer("percent_diff_abs", 30, 40) + require.Equal(t, float64(33.33333333333333), result) + }) + + t.Run("percent_diff_abs of two positive points[2]", func(t *testing.T) { + result := testReducer("percent_diff_abs", 30, 20) + require.Equal(t, float64(33.33333333333333), result) + }) + + t.Run("percent_diff_abs of two negative points[1]", func(t *testing.T) { + result := testReducer("percent_diff_abs", -30, -40) + require.Equal(t, float64(33.33333333333333), result) + }) + + t.Run("percent_diff_abs of two negative points[2]", func(t *testing.T) { + result := testReducer("percent_diff_abs", -30, -10) + require.Equal(t, float64(66.66666666666666), result) + }) + + t.Run("percent_diff_abs of one positive and one negative point", func(t *testing.T) { + result := testReducer("percent_diff_abs", 30, -40) + require.Equal(t, float64(233.33333333333334), result) + }) + + t.Run("percent_diff_abs of one negative and one positive point", func(t *testing.T) { + result := testReducer("percent_diff_abs", -30, 40) + require.Equal(t, float64(233.33333333333334), result) + }) + + t.Run("percent_diff_abs of three positive points", func(t *testing.T) { + result := testReducer("percent_diff_abs", 30, 40, 50) + require.Equal(t, float64(66.66666666666666), result) + }) + + t.Run("percent_diff_abs of three negative points", func(t *testing.T) { + result := testReducer("percent_diff_abs", -30, -40, -50) + require.Equal(t, float64(66.66666666666666), result) + }) + + t.Run("percent_diff_abs with only nulls", func(t *testing.T) { + reducer := newSimpleReducer("percent_diff_abs") + series := plugins.DataTimeSeries{ + Name: "test time series", + } + + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)}) + series.Points = append(series.Points, plugins.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)}) + + require.Equal(t, false, reducer.Reduce(series).Valid) + }) + + t.Run("min should work with NaNs", func(t *testing.T) { + result := testReducer("min", math.NaN(), math.NaN(), math.NaN()) + require.Equal(t, float64(0), result) + }) + + t.Run("isValid should treat NaN as invalid", func(t *testing.T) { + result := isValid(null.FloatFrom(math.NaN())) + require.False(t, result) + }) + + t.Run("isValid should treat invalid null.Float as invalid", func(t *testing.T) { + result := isValid(null.FloatFromPtr(nil)) + require.False(t, result) }) } diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go index d604bc1e7d2..8f321e072ec 100644 --- a/pkg/services/alerting/engine_integration_test.go +++ b/pkg/services/alerting/engine_integration_test.go @@ -15,40 +15,39 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" "github.com/grafana/grafana/pkg/setting" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) func TestEngineTimeouts(t *testing.T) { - Convey("Alerting engine timeout tests", t, func() { - usMock := &usagestats.UsageStatsMock{T: t} - engine := ProvideAlertEngine(nil, nil, nil, nil, usMock, ossencryption.ProvideService(), setting.NewCfg()) - setting.AlertingNotificationTimeout = 30 * time.Second - setting.AlertingMaxAttempts = 3 - engine.resultHandler = &FakeResultHandler{} - job := &Job{running: true, Rule: &Rule{}} + usMock := &usagestats.UsageStatsMock{T: t} + engine := ProvideAlertEngine(nil, nil, nil, nil, usMock, ossencryption.ProvideService(), setting.NewCfg()) + setting.AlertingNotificationTimeout = 30 * time.Second + setting.AlertingMaxAttempts = 3 + engine.resultHandler = &FakeResultHandler{} + job := &Job{running: true, Rule: &Rule{}} - Convey("Should trigger as many retries as needed", func() { - Convey("pended alert for datasource -> result handler should be worked", func() { - // reduce alert timeout to test quickly - setting.AlertingEvaluationTimeout = 30 * time.Second - transportTimeoutInterval := 2 * time.Second - serverBusySleepDuration := 1 * time.Second + t.Run("Should trigger as many retries as needed", func(t *testing.T) { + t.Run("pended alert for datasource -> result handler should be worked", func(t *testing.T) { + // reduce alert timeout to test quickly + setting.AlertingEvaluationTimeout = 30 * time.Second + transportTimeoutInterval := 2 * time.Second + serverBusySleepDuration := 1 * time.Second - evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) - resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) - engine.evalHandler = evalHandler - engine.resultHandler = resultHandler + evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) + resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration) + engine.evalHandler = evalHandler + engine.resultHandler = resultHandler - err := engine.processJobWithRetry(context.TODO(), job) - So(err, ShouldBeNil) + err := engine.processJobWithRetry(context.TODO(), job) + require.Nil(t, err) - So(evalHandler.EvalSucceed, ShouldEqual, true) - So(resultHandler.ResultHandleSucceed, ShouldEqual, true) + require.Equal(t, true, evalHandler.EvalSucceed) + require.Equal(t, true, resultHandler.ResultHandleSucceed) - // initialize for other tests. - setting.AlertingEvaluationTimeout = 2 * time.Second - engine.resultHandler = &FakeResultHandler{} - }) + // initialize for other tests. + setting.AlertingEvaluationTimeout = 2 * time.Second + engine.resultHandler = &FakeResultHandler{} }) }) } diff --git a/pkg/services/alerting/engine_test.go b/pkg/services/alerting/engine_test.go index 703f892338c..f2a92af840d 100644 --- a/pkg/services/alerting/engine_test.go +++ b/pkg/services/alerting/engine_test.go @@ -13,7 +13,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" "github.com/grafana/grafana/pkg/setting" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) type FakeEvalHandler struct { @@ -42,112 +43,110 @@ func (handler *FakeResultHandler) handle(evalContext *EvalContext) error { } func TestEngineProcessJob(t *testing.T) { - Convey("Alerting engine job processing", t, func() { - bus := bus.New() - usMock := &usagestats.UsageStatsMock{T: t} - engine := ProvideAlertEngine(nil, bus, nil, nil, usMock, ossencryption.ProvideService(), setting.NewCfg()) - setting.AlertingEvaluationTimeout = 30 * time.Second - setting.AlertingNotificationTimeout = 30 * time.Second - setting.AlertingMaxAttempts = 3 - engine.resultHandler = &FakeResultHandler{} - job := &Job{running: true, Rule: &Rule{}} + bus := bus.New() + usMock := &usagestats.UsageStatsMock{T: t} + engine := ProvideAlertEngine(nil, bus, nil, nil, usMock, ossencryption.ProvideService(), setting.NewCfg()) + setting.AlertingEvaluationTimeout = 30 * time.Second + setting.AlertingNotificationTimeout = 30 * time.Second + setting.AlertingMaxAttempts = 3 + engine.resultHandler = &FakeResultHandler{} + job := &Job{running: true, Rule: &Rule{}} - Convey("Should register usage metrics func", func() { - bus.AddHandler(func(q *models.GetAllAlertsQuery) error { - settings, err := simplejson.NewJson([]byte(`{"conditions": [{"query": { "datasourceId": 1}}]}`)) - if err != nil { - return err - } - q.Result = []*models.Alert{{Settings: settings}} - return nil - }) - - bus.AddHandler(func(q *models.GetDataSourceQuery) error { - q.Result = &models.DataSource{Id: 1, Type: models.DS_PROMETHEUS} - return nil - }) - - report, err := usMock.GetUsageReport(context.Background()) - So(err, ShouldBeNil) - - So(report.Metrics["stats.alerting.ds.prometheus.count"], ShouldEqual, 1) - So(report.Metrics["stats.alerting.ds.other.count"], ShouldEqual, 0) + t.Run("Should register usage metrics func", func(t *testing.T) { + bus.AddHandler(func(q *models.GetAllAlertsQuery) error { + settings, err := simplejson.NewJson([]byte(`{"conditions": [{"query": { "datasourceId": 1}}]}`)) + if err != nil { + return err + } + q.Result = []*models.Alert{{Settings: settings}} + return nil }) - Convey("Should trigger retry if needed", func() { - Convey("error + not last attempt -> retry", func() { - engine.evalHandler = NewFakeEvalHandler(0) + bus.AddHandler(func(q *models.GetDataSourceQuery) error { + q.Result = &models.DataSource{Id: 1, Type: models.DS_PROMETHEUS} + return nil + }) - for i := 1; i < setting.AlertingMaxAttempts; i++ { - attemptChan := make(chan int, 1) - cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) + report, err := usMock.GetUsageReport(context.Background()) + require.Nil(t, err) - engine.processJob(i, attemptChan, cancelChan, job) - nextAttemptID, more := <-attemptChan + require.Equal(t, 1, report.Metrics["stats.alerting.ds.prometheus.count"]) + require.Equal(t, 0, report.Metrics["stats.alerting.ds.other.count"]) + }) - So(nextAttemptID, ShouldEqual, i+1) - So(more, ShouldEqual, true) - So(<-cancelChan, ShouldNotBeNil) - } - }) + t.Run("Should trigger retry if needed", func(t *testing.T) { + t.Run("error + not last attempt -> retry", func(t *testing.T) { + engine.evalHandler = NewFakeEvalHandler(0) - Convey("error + last attempt -> no retry", func() { - engine.evalHandler = NewFakeEvalHandler(0) + for i := 1; i < setting.AlertingMaxAttempts; i++ { attemptChan := make(chan int, 1) cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) - engine.processJob(setting.AlertingMaxAttempts, attemptChan, cancelChan, job) + engine.processJob(i, attemptChan, cancelChan, job) nextAttemptID, more := <-attemptChan - So(nextAttemptID, ShouldEqual, 0) - So(more, ShouldEqual, false) - So(<-cancelChan, ShouldNotBeNil) - }) - - Convey("no error -> no retry", func() { - engine.evalHandler = NewFakeEvalHandler(1) - attemptChan := make(chan int, 1) - cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) - - engine.processJob(1, attemptChan, cancelChan, job) - nextAttemptID, more := <-attemptChan - - So(nextAttemptID, ShouldEqual, 0) - So(more, ShouldEqual, false) - So(<-cancelChan, ShouldNotBeNil) - }) + require.Equal(t, i+1, nextAttemptID) + require.Equal(t, true, more) + require.NotNil(t, <-cancelChan) + } }) - Convey("Should trigger as many retries as needed", func() { - Convey("never success -> max retries number", func() { - expectedAttempts := setting.AlertingMaxAttempts - evalHandler := NewFakeEvalHandler(0) - engine.evalHandler = evalHandler + t.Run("error + last attempt -> no retry", func(t *testing.T) { + engine.evalHandler = NewFakeEvalHandler(0) + attemptChan := make(chan int, 1) + cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) - err := engine.processJobWithRetry(context.TODO(), job) - So(err, ShouldBeNil) - So(evalHandler.CallNb, ShouldEqual, expectedAttempts) - }) + engine.processJob(setting.AlertingMaxAttempts, attemptChan, cancelChan, job) + nextAttemptID, more := <-attemptChan - Convey("always success -> never retry", func() { - expectedAttempts := 1 - evalHandler := NewFakeEvalHandler(1) - engine.evalHandler = evalHandler + require.Equal(t, 0, nextAttemptID) + require.Equal(t, false, more) + require.NotNil(t, <-cancelChan) + }) - err := engine.processJobWithRetry(context.TODO(), job) - So(err, ShouldBeNil) - So(evalHandler.CallNb, ShouldEqual, expectedAttempts) - }) + t.Run("no error -> no retry", func(t *testing.T) { + engine.evalHandler = NewFakeEvalHandler(1) + attemptChan := make(chan int, 1) + cancelChan := make(chan context.CancelFunc, setting.AlertingMaxAttempts) - Convey("some errors before success -> some retries", func() { - expectedAttempts := int(math.Ceil(float64(setting.AlertingMaxAttempts) / 2)) - evalHandler := NewFakeEvalHandler(expectedAttempts) - engine.evalHandler = evalHandler + engine.processJob(1, attemptChan, cancelChan, job) + nextAttemptID, more := <-attemptChan - err := engine.processJobWithRetry(context.TODO(), job) - So(err, ShouldBeNil) - So(evalHandler.CallNb, ShouldEqual, expectedAttempts) - }) + require.Equal(t, 0, nextAttemptID) + require.Equal(t, false, more) + require.NotNil(t, <-cancelChan) + }) + }) + + t.Run("Should trigger as many retries as needed", func(t *testing.T) { + t.Run("never success -> max retries number", func(t *testing.T) { + expectedAttempts := setting.AlertingMaxAttempts + evalHandler := NewFakeEvalHandler(0) + engine.evalHandler = evalHandler + + err := engine.processJobWithRetry(context.TODO(), job) + require.Nil(t, err) + require.Equal(t, expectedAttempts, evalHandler.CallNb) + }) + + t.Run("always success -> never retry", func(t *testing.T) { + expectedAttempts := 1 + evalHandler := NewFakeEvalHandler(1) + engine.evalHandler = evalHandler + + err := engine.processJobWithRetry(context.TODO(), job) + require.Nil(t, err) + require.Equal(t, expectedAttempts, evalHandler.CallNb) + }) + + t.Run("some errors before success -> some retries", func(t *testing.T) { + expectedAttempts := int(math.Ceil(float64(setting.AlertingMaxAttempts) / 2)) + evalHandler := NewFakeEvalHandler(expectedAttempts) + engine.evalHandler = evalHandler + + err := engine.processJobWithRetry(context.TODO(), job) + require.Nil(t, err) + require.Equal(t, expectedAttempts, evalHandler.CallNb) }) }) } diff --git a/pkg/services/alerting/eval_handler_test.go b/pkg/services/alerting/eval_handler_test.go index 271d51c385d..07e36ed0497 100644 --- a/pkg/services/alerting/eval_handler_test.go +++ b/pkg/services/alerting/eval_handler_test.go @@ -7,7 +7,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/validations" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) type conditionStub struct { @@ -22,188 +22,186 @@ func (c *conditionStub) Eval(context *EvalContext, reqHandler plugins.DataReques } func TestAlertingEvaluationHandler(t *testing.T) { - Convey("Test alert evaluation handler", t, func() { - handler := NewEvalHandler(nil) + handler := NewEvalHandler(nil) - Convey("Show return triggered with single passing condition", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{&conditionStub{ - firing: true, - }}, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return triggered with single passing condition", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{&conditionStub{ + firing: true, + }}, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, true) - So(context.ConditionEvals, ShouldEqual, "true = true") - }) + handler.Eval(context) + require.Equal(t, true, context.Firing) + require.Equal(t, "true = true", context.ConditionEvals) + }) - Convey("Show return triggered with single passing condition2", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{&conditionStub{firing: true, operator: "and"}}, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return triggered with single passing condition2", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{&conditionStub{firing: true, operator: "and"}}, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, true) - So(context.ConditionEvals, ShouldEqual, "true = true") - }) + handler.Eval(context) + require.Equal(t, true, context.Firing) + require.Equal(t, "true = true", context.ConditionEvals) + }) - Convey("Show return false with not passing asdf", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and", matches: []*EvalMatch{{}, {}}}, - &conditionStub{firing: false, operator: "and"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return false with not passing asdf", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: true, operator: "and", matches: []*EvalMatch{{}, {}}}, + &conditionStub{firing: false, operator: "and"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, false) - So(context.ConditionEvals, ShouldEqual, "[true AND false] = false") - }) + handler.Eval(context) + require.Equal(t, false, context.Firing) + require.Equal(t, "[true AND false] = false", context.ConditionEvals) + }) - Convey("Show return true if any of the condition is passing with OR operator", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return true if any of the condition is passing with OR operator", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: true, operator: "and"}, + &conditionStub{firing: false, operator: "or"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, true) - So(context.ConditionEvals, ShouldEqual, "[true OR false] = true") - }) + handler.Eval(context) + require.Equal(t, true, context.Firing) + require.Equal(t, "[true OR false] = true", context.ConditionEvals) + }) - Convey("Show return false if any of the condition is failing with AND operator", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "and"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return false if any of the condition is failing with AND operator", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: true, operator: "and"}, + &conditionStub{firing: false, operator: "and"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, false) - So(context.ConditionEvals, ShouldEqual, "[true AND false] = false") - }) + handler.Eval(context) + require.Equal(t, false, context.Firing) + require.Equal(t, "[true AND false] = false", context.ConditionEvals) + }) - Convey("Show return true if one condition is failing with nested OR operator", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return true if one condition is failing with nested OR operator", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: true, operator: "and"}, + &conditionStub{firing: true, operator: "and"}, + &conditionStub{firing: false, operator: "or"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, true) - So(context.ConditionEvals, ShouldEqual, "[[true AND true] OR false] = true") - }) + handler.Eval(context) + require.Equal(t, true, context.Firing) + require.Equal(t, "[[true AND true] OR false] = true", context.ConditionEvals) + }) - Convey("Show return false if one condition is passing with nested OR operator", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return false if one condition is passing with nested OR operator", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: true, operator: "and"}, + &conditionStub{firing: false, operator: "and"}, + &conditionStub{firing: false, operator: "or"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, false) - So(context.ConditionEvals, ShouldEqual, "[[true AND false] OR false] = false") - }) + handler.Eval(context) + require.Equal(t, false, context.Firing) + require.Equal(t, "[[true AND false] OR false] = false", context.ConditionEvals) + }) - Convey("Show return false if a condition is failing with nested AND operator", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "and"}, - &conditionStub{firing: true, operator: "and"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return false if a condition is failing with nested AND operator", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: true, operator: "and"}, + &conditionStub{firing: false, operator: "and"}, + &conditionStub{firing: true, operator: "and"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, false) - So(context.ConditionEvals, ShouldEqual, "[[true AND false] AND true] = false") - }) + handler.Eval(context) + require.Equal(t, false, context.Firing) + require.Equal(t, "[[true AND false] AND true] = false", context.ConditionEvals) + }) - Convey("Show return true if a condition is passing with nested OR operator", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: true, operator: "and"}, - &conditionStub{firing: false, operator: "or"}, - &conditionStub{firing: true, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Show return true if a condition is passing with nested OR operator", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: true, operator: "and"}, + &conditionStub{firing: false, operator: "or"}, + &conditionStub{firing: true, operator: "or"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, true) - So(context.ConditionEvals, ShouldEqual, "[[true OR false] OR true] = true") - }) + handler.Eval(context) + require.Equal(t, true, context.Firing) + require.Equal(t, "[[true OR false] OR true] = true", context.ConditionEvals) + }) - Convey("Should return false if no condition is firing using OR operator", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{firing: false, operator: "or"}, - &conditionStub{firing: false, operator: "or"}, - &conditionStub{firing: false, operator: "or"}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Should return false if no condition is firing using OR operator", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{firing: false, operator: "or"}, + &conditionStub{firing: false, operator: "or"}, + &conditionStub{firing: false, operator: "or"}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, false) - So(context.ConditionEvals, ShouldEqual, "[[false OR false] OR false] = false") - }) + handler.Eval(context) + require.Equal(t, false, context.Firing) + require.Equal(t, "[[false OR false] OR false] = false", context.ConditionEvals) + }) - // FIXME: What should the actual test case name be here? - Convey("Should not return NoDataFound if all conditions have data and using OR", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "or", noData: false}, - &conditionStub{operator: "or", noData: false}, - &conditionStub{operator: "or", noData: false}, - }, - }, &validations.OSSPluginRequestValidator{}) + // FIXME: What should the actual test case name be here? + t.Run("Should not return NoDataFound if all conditions have data and using OR", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{operator: "or", noData: false}, + &conditionStub{operator: "or", noData: false}, + &conditionStub{operator: "or", noData: false}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.NoDataFound, ShouldBeFalse) - }) + handler.Eval(context) + require.False(t, context.NoDataFound) + }) - Convey("Should return NoDataFound if one condition has no data", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "and", noData: true}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Should return NoDataFound if one condition has no data", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{operator: "and", noData: true}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.Firing, ShouldEqual, false) - So(context.NoDataFound, ShouldBeTrue) - }) + handler.Eval(context) + require.Equal(t, false, context.Firing) + require.True(t, context.NoDataFound) + }) - Convey("Should not return no data if at least one condition has no data and using AND", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "and", noData: true}, - &conditionStub{operator: "and", noData: false}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Should not return no data if at least one condition has no data and using AND", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{operator: "and", noData: true}, + &conditionStub{operator: "and", noData: false}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.NoDataFound, ShouldBeFalse) - }) + handler.Eval(context) + require.False(t, context.NoDataFound) + }) - Convey("Should return no data if at least one condition has no data and using OR", func() { - context := NewEvalContext(context.TODO(), &Rule{ - Conditions: []Condition{ - &conditionStub{operator: "or", noData: true}, - &conditionStub{operator: "or", noData: false}, - }, - }, &validations.OSSPluginRequestValidator{}) + t.Run("Should return no data if at least one condition has no data and using OR", func(t *testing.T) { + context := NewEvalContext(context.TODO(), &Rule{ + Conditions: []Condition{ + &conditionStub{operator: "or", noData: true}, + &conditionStub{operator: "or", noData: false}, + }, + }, &validations.OSSPluginRequestValidator{}) - handler.Eval(context) - So(context.NoDataFound, ShouldBeTrue) - }) + handler.Eval(context) + require.True(t, context.NoDataFound) }) } diff --git a/pkg/services/alerting/notifiers/discord.go b/pkg/services/alerting/notifiers/discord.go index 72534fdeee8..d0ac7036f7f 100644 --- a/pkg/services/alerting/notifiers/discord.go +++ b/pkg/services/alerting/notifiers/discord.go @@ -47,6 +47,12 @@ func init() { PropertyName: "url", Required: true, }, + { + Label: "Use Discord's Webhook Username", + Description: "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", + Element: alerting.ElementTypeCheckbox, + PropertyName: "use_discord_username", + }, }, }) } @@ -58,13 +64,15 @@ func newDiscordNotifier(model *models.AlertNotification, _ alerting.GetDecrypted if url == "" { return nil, alerting.ValidationError{Reason: "Could not find webhook url property in settings"} } + useDiscordUsername := model.Settings.Get("use_discord_username").MustBool(false) return &DiscordNotifier{ - NotifierBase: NewNotifierBase(model), - Content: content, - AvatarURL: avatar, - WebhookURL: url, - log: log.New("alerting.notifier.discord"), + NotifierBase: NewNotifierBase(model), + Content: content, + AvatarURL: avatar, + WebhookURL: url, + log: log.New("alerting.notifier.discord"), + UseDiscordUsername: useDiscordUsername, }, nil } @@ -72,10 +80,11 @@ func newDiscordNotifier(model *models.AlertNotification, _ alerting.GetDecrypted // notifications to discord. type DiscordNotifier struct { NotifierBase - Content string - AvatarURL string - WebhookURL string - log log.Logger + Content string + AvatarURL string + WebhookURL string + log log.Logger + UseDiscordUsername bool } // Notify send an alert notification to Discord. @@ -89,7 +98,9 @@ func (dn *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error { } bodyJSON := simplejson.New() - bodyJSON.Set("username", "Grafana") + if !dn.UseDiscordUsername { + bodyJSON.Set("username", "Grafana") + } if dn.Content != "" { bodyJSON.Set("content", dn.Content) diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 20c4bcfb656..5bfa036121d 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -254,7 +254,7 @@ func appendIfPossible(message string, extra string, sizeLimit int) string { if len(extra)+len(message) <= sizeLimit { return message + extra } - log.Debugf("Line too long for image caption. value: %s", extra) + log.Debug("Line too long for image caption.", "value", extra) return message } diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index cf673755d17..49e0256aab1 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -148,7 +148,7 @@ func (h *ContextHandler) initContextWithAnonymousUser(reqContext *models.ReqCont org, err := h.SQLStore.GetOrgByName(h.Cfg.AnonymousOrgName) if err != nil { - log.Errorf(3, "Anonymous access organization error: '%s': %s", h.Cfg.AnonymousOrgName, err) + log.Error("Anonymous access organization error.", "org_name", h.Cfg.AnonymousOrgName, "error", err) return false } diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index a453848b677..764dfd7b805 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -56,13 +56,13 @@ func (ls *Implementation) UpsertUser(cmd *models.UpsertUserCommand) error { return err } if !cmd.SignupAllowed { - log.Warnf("Not allowing %s login, user not found in internal user database and allow signup = false", extUser.AuthModule) + log.Warn("Not allowing login, user not found in internal user database and allow signup = false", "authmode", extUser.AuthModule) return login.ErrInvalidCredentials } limitReached, err := ls.QuotaService.QuotaReached(cmd.ReqContext, "user") if err != nil { - log.Warnf("Error getting user quota. error: %v", err) + log.Warn("Error getting user quota.", "error", err) return login.ErrGettingUserQuota } if limitReached { diff --git a/pkg/services/ngalert/notifier/available_channels.go b/pkg/services/ngalert/notifier/available_channels.go index ddc79549b85..a3c23d87e98 100644 --- a/pkg/services/ngalert/notifier/available_channels.go +++ b/pkg/services/ngalert/notifier/available_channels.go @@ -694,6 +694,12 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { InputType: alerting.InputTypeText, PropertyName: "avatar_url", }, + { + Label: "Use Discord's Webhook Username", + Description: "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", + Element: alerting.ElementTypeCheckbox, + PropertyName: "use_discord_username", + }, }, }, { diff --git a/pkg/services/ngalert/notifier/channels/discord.go b/pkg/services/ngalert/notifier/channels/discord.go index faa1b53093a..5842445180d 100644 --- a/pkg/services/ngalert/notifier/channels/discord.go +++ b/pkg/services/ngalert/notifier/channels/discord.go @@ -18,11 +18,12 @@ import ( type DiscordNotifier struct { *Base - log log.Logger - tmpl *template.Template - Content string - AvatarURL string - WebhookURL string + log log.Logger + tmpl *template.Template + Content string + AvatarURL string + WebhookURL string + UseDiscordUsername bool } func NewDiscordNotifier(model *NotificationChannelConfig, t *template.Template) (*DiscordNotifier, error) { @@ -37,6 +38,8 @@ func NewDiscordNotifier(model *NotificationChannelConfig, t *template.Template) return nil, receiverInitError{Reason: "could not find webhook url property in settings", Cfg: *model} } + useDiscordUsername := model.Settings.Get("use_discord_username").MustBool(false) + content := model.Settings.Get("message").MustString(`{{ template "default.message" . }}`) return &DiscordNotifier{ @@ -48,11 +51,12 @@ func NewDiscordNotifier(model *NotificationChannelConfig, t *template.Template) Settings: model.Settings, SecureSettings: model.SecureSettings, }), - Content: content, - AvatarURL: avatarURL, - WebhookURL: discordURL, - log: log.New("alerting.notifier.discord"), - tmpl: t, + Content: content, + AvatarURL: avatarURL, + WebhookURL: discordURL, + log: log.New("alerting.notifier.discord"), + tmpl: t, + UseDiscordUsername: useDiscordUsername, }, nil } @@ -60,7 +64,10 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, alerts := types.Alerts(as...) bodyJSON := simplejson.New() - bodyJSON.Set("username", "Grafana") + + if !d.UseDiscordUsername { + bodyJSON.Set("username", "Grafana") + } var tmplErr error tmpl, _ := TmplText(ctx, d.tmpl, as, d.log, &tmplErr) diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index 5c0965b810d..9fc1b947a92 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -100,6 +100,35 @@ func TestDiscordNotifier(t *testing.T) { settings: `{}`, expInitError: `failed to validate receiver "discord_testing" of type "discord": could not find webhook url property in settings`, }, + { + name: "Default config with one alert, use default discord username", + settings: `{ + "url": "http://localhost", + "use_discord_username": true + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "content": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "embeds": []interface{}{map[string]interface{}{ + "color": 1.4037554e+07, + "footer": map[string]interface{}{ + "icon_url": "https://grafana.com/assets/img/fav32.png", + "text": "Grafana v", + }, + "title": "[FIRING:1] (val1)", + "url": "http://localhost/alerting/list", + "type": "rich", + }}, + }, + expMsgError: nil, + }, } for _, c := range cases { diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 363a9b853e1..13cd281e78f 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -10,21 +10,23 @@ import ( "time" "github.com/benbjohnson/clock" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/log" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/sender" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" ) func TestSendingToExternalAlertmanager(t *testing.T) { @@ -35,7 +37,7 @@ func TestSendingToExternalAlertmanager(t *testing.T) { fakeAdminConfigStore := newFakeAdminConfigStore(t) // create alert rule with one second interval - alertRule := CreateTestAlertRule(t, fakeRuleStore, 1, 1) + alertRule := CreateTestAlertRule(t, fakeRuleStore, 1, 1, eval.Alerting) // First, let's create an admin configuration that holds an alertmanager. adminConfig := &models.AdminConfiguration{OrgID: 1, Alertmanagers: []string{fakeAM.server.URL}} @@ -233,6 +235,325 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) { }, 10*time.Second, 200*time.Millisecond, "Alertmanager for org 1 and 2 were never removed") } +func TestSchedule_ruleRoutine(t *testing.T) { + createSchedule := func( + evalAppliedChan chan time.Time, + ) (*schedule, *fakeRuleStore, *fakeInstanceStore, *fakeAdminConfigStore) { + ruleStore := newFakeRuleStore(t) + instanceStore := &fakeInstanceStore{} + adminConfigStore := newFakeAdminConfigStore(t) + + sch, _ := setupScheduler(t, ruleStore, instanceStore, adminConfigStore) + + sch.evalAppliedFunc = func(key models.AlertRuleKey, t time.Time) { + evalAppliedChan <- t + } + return sch, ruleStore, instanceStore, adminConfigStore + } + + // normal states do not include NoData and Error because currently it is not possible to perform any sensible test + normalStates := []eval.State{eval.Normal, eval.Alerting, eval.Pending} + randomNormalState := func() eval.State { + // pick only supported cases + return normalStates[rand.Intn(3)] + } + + for _, evalState := range normalStates { + // TODO rewrite when we are able to mock/fake state manager + t.Run(fmt.Sprintf("when rule evaluation happens (evaluation state %s)", evalState), func(t *testing.T) { + evalChan := make(chan *evalContext) + evalAppliedChan := make(chan time.Time) + + sch, ruleStore, instanceStore, _ := createSchedule(evalAppliedChan) + + rule := CreateTestAlertRule(t, ruleStore, 10, rand.Int63(), evalState) + + go func() { + stop := make(chan struct{}) + t.Cleanup(func() { + close(stop) + }) + _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + }() + + expectedTime := time.UnixMicro(rand.Int63()) + + evalChan <- &evalContext{ + now: expectedTime, + version: rule.Version, + } + + actualTime := waitForTimeChannel(t, evalAppliedChan) + require.Equal(t, expectedTime, actualTime) + + t.Run("it should get rule from database when run the first time", func(t *testing.T) { + queries := make([]models.GetAlertRuleByUIDQuery, 0) + for _, op := range ruleStore.recordedOps { + switch q := op.(type) { + case models.GetAlertRuleByUIDQuery: + queries = append(queries, q) + } + } + require.NotEmptyf(t, queries, "Expected a %T request to rule store but nothing was recorded", models.GetAlertRuleByUIDQuery{}) + require.Len(t, queries, 1, "Expected exactly one request of %T but got %d", models.GetAlertRuleByUIDQuery{}, len(queries)) + require.Equal(t, rule.UID, queries[0].UID) + require.Equal(t, rule.OrgID, queries[0].OrgID) + }) + t.Run("it should process evaluation results via state manager", func(t *testing.T) { + // TODO rewrite when we are able to mock/fake state manager + states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + require.Len(t, states, 1) + s := states[0] + t.Logf("State: %v", s) + require.Equal(t, rule.UID, s.AlertRuleUID) + require.Len(t, s.Results, 1) + var expectedStatus = evalState + if evalState == eval.Pending { + expectedStatus = eval.Alerting + } + require.Equal(t, expectedStatus.String(), s.Results[0].EvaluationState.String()) + require.Equal(t, expectedTime, s.Results[0].EvaluationTime) + }) + t.Run("it should save alert instances to storage", func(t *testing.T) { + // TODO rewrite when we are able to mock/fake state manager + states := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) + require.Len(t, states, 1) + s := states[0] + + var cmd *models.SaveAlertInstanceCommand + for _, op := range instanceStore.recordedOps { + switch q := op.(type) { + case models.SaveAlertInstanceCommand: + cmd = &q + } + if cmd != nil { + break + } + } + + require.NotNil(t, cmd) + t.Logf("Saved alert instance: %v", cmd) + require.Equal(t, rule.OrgID, cmd.RuleOrgID) + require.Equal(t, expectedTime, cmd.LastEvalTime) + require.Equal(t, cmd.RuleUID, cmd.RuleUID) + require.Equal(t, evalState.String(), string(cmd.State)) + require.Equal(t, s.Labels, data.Labels(cmd.Labels)) + }) + t.Run("it reports metrics", func(t *testing.T) { + // TODO fix it when we update the way we use metrics + t.Skip() + }) + }) + } + + t.Run("should exit", func(t *testing.T) { + t.Run("when we signal it to stop", func(t *testing.T) { + stopChan := make(chan struct{}) + stoppedChan := make(chan error) + + sch, _, _, _ := createSchedule(make(chan time.Time)) + + go func() { + err := sch.ruleRoutine(context.Background(), models.AlertRuleKey{}, make(chan *evalContext), stopChan) + stoppedChan <- err + }() + + stopChan <- struct{}{} + err := waitForErrChannel(t, stoppedChan) + require.NoError(t, err) + }) + + t.Run("when context is cancelled", func(t *testing.T) { + stoppedChan := make(chan error) + sch, _, _, _ := createSchedule(make(chan time.Time)) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evalContext), make(chan struct{})) + stoppedChan <- err + }() + + cancel() + err := waitForErrChannel(t, stoppedChan) + require.ErrorIs(t, err, context.Canceled) + }) + }) + + t.Run("should fetch rule from database only if new version is greater than current", func(t *testing.T) { + evalChan := make(chan *evalContext) + evalAppliedChan := make(chan time.Time) + + sch, ruleStore, _, _ := createSchedule(evalAppliedChan) + + rule := CreateTestAlertRule(t, ruleStore, 10, rand.Int63(), randomNormalState()) + + go func() { + stop := make(chan struct{}) + t.Cleanup(func() { + close(stop) + }) + _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + }() + + expectedTime := time.UnixMicro(rand.Int63()) + evalChan <- &evalContext{ + now: expectedTime, + version: rule.Version, + } + + actualTime := waitForTimeChannel(t, evalAppliedChan) + require.Equal(t, expectedTime, actualTime) + + // Now update the rule + newRule := *rule + newRule.Version++ + ruleStore.putRule(&newRule) + + // and call with new version + expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) + evalChan <- &evalContext{ + now: expectedTime, + version: newRule.Version, + } + + actualTime = waitForTimeChannel(t, evalAppliedChan) + require.Equal(t, expectedTime, actualTime) + + queries := make([]models.GetAlertRuleByUIDQuery, 0) + for _, op := range ruleStore.recordedOps { + switch q := op.(type) { + case models.GetAlertRuleByUIDQuery: + queries = append(queries, q) + } + } + require.Len(t, queries, 2, "Expected exactly two request of %T", models.GetAlertRuleByUIDQuery{}) + require.Equal(t, rule.UID, queries[0].UID) + require.Equal(t, rule.OrgID, queries[0].OrgID) + require.Equal(t, rule.UID, queries[1].UID) + require.Equal(t, rule.OrgID, queries[1].OrgID) + }) + + t.Run("should not fetch rule if version is equal or less than current", func(t *testing.T) { + evalChan := make(chan *evalContext) + evalAppliedChan := make(chan time.Time) + + sch, ruleStore, _, _ := createSchedule(evalAppliedChan) + + rule := CreateTestAlertRule(t, ruleStore, 10, rand.Int63(), randomNormalState()) + + go func() { + stop := make(chan struct{}) + t.Cleanup(func() { + close(stop) + }) + _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + }() + + expectedTime := time.UnixMicro(rand.Int63()) + evalChan <- &evalContext{ + now: expectedTime, + version: rule.Version, + } + + actualTime := waitForTimeChannel(t, evalAppliedChan) + require.Equal(t, expectedTime, actualTime) + + // try again with the same version + expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) + evalChan <- &evalContext{ + now: expectedTime, + version: rule.Version, + } + actualTime = waitForTimeChannel(t, evalAppliedChan) + require.Equal(t, expectedTime, actualTime) + + expectedTime = expectedTime.Add(time.Duration(rand.Intn(10)) * time.Second) + evalChan <- &evalContext{ + now: expectedTime, + version: rule.Version - 1, + } + actualTime = waitForTimeChannel(t, evalAppliedChan) + require.Equal(t, expectedTime, actualTime) + + queries := make([]models.GetAlertRuleByUIDQuery, 0) + for _, op := range ruleStore.recordedOps { + switch q := op.(type) { + case models.GetAlertRuleByUIDQuery: + queries = append(queries, q) + } + } + require.Len(t, queries, 1, "Expected exactly one request of %T", models.GetAlertRuleByUIDQuery{}) + }) + + t.Run("when evaluation fails", func(t *testing.T) { + t.Run("it should increase failure counter", func(t *testing.T) { + t.Skip() + // TODO implement check for counter + }) + t.Run("it should retry up to configured times", func(t *testing.T) { + // TODO figure out how to simulate failure + t.Skip() + }) + }) + + t.Run("when there are alerts that should be firing", func(t *testing.T) { + t.Run("it should send to local alertmanager if configured for organization", func(t *testing.T) { + // TODO figure out how to simulate multiorg alertmanager + t.Skip() + }) + t.Run("it should send to external alertmanager if configured for organization", func(t *testing.T) { + fakeAM := NewFakeExternalAlertmanager(t) + defer fakeAM.Close() + + orgID := rand.Int63() + s, err := sender.New(nil) + require.NoError(t, err) + adminConfig := &models.AdminConfiguration{OrgID: orgID, Alertmanagers: []string{fakeAM.server.URL}} + err = s.ApplyConfig(adminConfig) + require.NoError(t, err) + s.Run() + defer s.Stop() + + require.Eventuallyf(t, func() bool { + return len(s.Alertmanagers()) == 1 + }, 20*time.Second, 200*time.Millisecond, "external Alertmanager was not discovered.") + + evalChan := make(chan *evalContext) + evalAppliedChan := make(chan time.Time) + + sch, ruleStore, _, _ := createSchedule(evalAppliedChan) + sch.senders[orgID] = s + // eval.Alerting makes state manager to create notifications for alertmanagers + rule := CreateTestAlertRule(t, ruleStore, 10, orgID, eval.Alerting) + + go func() { + stop := make(chan struct{}) + t.Cleanup(func() { + close(stop) + }) + _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + }() + + evalChan <- &evalContext{ + now: time.Now(), + version: rule.Version, + } + waitForTimeChannel(t, evalAppliedChan) + + var count int + require.Eventuallyf(t, func() bool { + count = fakeAM.AlertsCount() + return count == 1 && fakeAM.AlertNamesCompare([]string{rule.Title}) + }, 20*time.Second, 200*time.Millisecond, "Alertmanager never received an '%s', received alerts count: %d", rule.Title, count) + }) + }) + + t.Run("when there are no alerts to send it should not call notifiers", func(t *testing.T) { + // TODO needs some mocking/stubbing for Alertmanager and Sender to make sure it was not called + t.Skip() + }) +} + func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, acs store.AdminConfigurationStore) (*schedule, *clock.Mock) { t.Helper() @@ -266,11 +587,46 @@ func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, ac } // createTestAlertRule creates a dummy alert definition to be used by the tests. -func CreateTestAlertRule(t *testing.T, dbstore *fakeRuleStore, intervalSeconds int64, orgID int64) *models.AlertRule { +func CreateTestAlertRule(t *testing.T, dbstore *fakeRuleStore, intervalSeconds int64, orgID int64, evalResult eval.State) *models.AlertRule { t.Helper() - + records := make([]interface{}, 0, len(dbstore.recordedOps)) + copy(records, dbstore.recordedOps) + defer func() { + // erase queries that were made by the testing suite + dbstore.recordedOps = records + }() d := rand.Intn(1000) ruleGroup := fmt.Sprintf("ruleGroup-%d", d) + + var expression string + var forDuration time.Duration + switch evalResult { + case eval.Normal: + expression = `{ + "datasourceUid": "-100", + "type":"math", + "expression":"2 + 1 < 1" + }` + case eval.Pending, eval.Alerting: + expression = `{ + "datasourceUid": "-100", + "type":"math", + "expression":"2 + 2 > 1" + }` + if evalResult == eval.Pending { + forDuration = 100 * time.Second + } + case eval.Error: + expression = `{ + "datasourceUid": "-100", + "type":"math", + "expression":"$A" + }` + case eval.NoData: + // TODO Implement support for NoData + require.Fail(t, "Alert rule with desired evaluation result NoData is not supported yet") + } + err := dbstore.UpdateRuleGroup(store.UpdateRuleGroupCmd{ OrgID: orgID, NamespaceUID: "namespace", @@ -281,6 +637,7 @@ func CreateTestAlertRule(t *testing.T, dbstore *fakeRuleStore, intervalSeconds i { ApiRuleNode: &apimodels.ApiRuleNode{ Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, + For: model.Duration(forDuration), }, GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ Title: fmt.Sprintf("an alert definition %d", d), @@ -288,11 +645,7 @@ func CreateTestAlertRule(t *testing.T, dbstore *fakeRuleStore, intervalSeconds i Data: []models.AlertQuery{ { DatasourceUID: "-100", - Model: json.RawMessage(`{ - "datasourceUid": "-100", - "type":"math", - "expression":"2 + 2 > 1" - }`), + Model: json.RawMessage(expression), RelativeTimeRange: models.RelativeTimeRange{ From: models.Duration(5 * time.Hour), To: models.Duration(3 * time.Hour), diff --git a/pkg/services/ngalert/schedule/testing.go b/pkg/services/ngalert/schedule/testing.go index 31732d0ee97..e5c508ea73d 100644 --- a/pkg/services/ngalert/schedule/testing.go +++ b/pkg/services/ngalert/schedule/testing.go @@ -21,15 +21,52 @@ import ( "github.com/stretchr/testify/require" ) +// waitForTimeChannel blocks the execution until either the channel ch has some data or a timeout of 10 second expires. +// Timeout will cause the test to fail. +// Returns the data from the channel. +func waitForTimeChannel(t *testing.T, ch chan time.Time) time.Time { + select { + case result := <-ch: + return result + case <-time.After(time.Duration(10) * time.Second): + t.Fatalf("Timeout waiting for data in the time channel") + return time.Time{} + } +} + +// waitForErrChannel blocks the execution until either the channel ch has some data or a timeout of 10 second expires. +// Timeout will cause the test to fail. +// Returns the data from the channel. +func waitForErrChannel(t *testing.T, ch chan error) error { + timeout := time.Duration(10) * time.Second + select { + case result := <-ch: + return result + case <-time.After(timeout): + t.Fatal("Timeout waiting for data in the error channel") + return nil + } +} + func newFakeRuleStore(t *testing.T) *fakeRuleStore { return &fakeRuleStore{t: t, rules: map[int64]map[string]map[string][]*models.AlertRule{}} } // FakeRuleStore mocks the RuleStore of the scheduler. type fakeRuleStore struct { - t *testing.T - mtx sync.Mutex - rules map[int64]map[string]map[string][]*models.AlertRule + t *testing.T + mtx sync.Mutex + rules map[int64]map[string]map[string][]*models.AlertRule + recordedOps []interface{} +} + +// putRule puts the rule in the rules map. If there are existing rule in the same namespace, they will be overwritten +func (f *fakeRuleStore) putRule(r *models.AlertRule) { + f.mtx.Lock() + defer f.mtx.Unlock() + f.rules[r.OrgID][r.RuleGroup][r.NamespaceUID] = []*models.AlertRule{ + r, + } } func (f *fakeRuleStore) DeleteAlertRuleByUID(_ int64, _ string) error { return nil } @@ -43,7 +80,7 @@ func (f *fakeRuleStore) DeleteAlertInstancesByRuleUID(_ int64, _ string) error { func (f *fakeRuleStore) GetAlertRuleByUID(q *models.GetAlertRuleByUIDQuery) error { f.mtx.Lock() defer f.mtx.Unlock() - + f.recordedOps = append(f.recordedOps, *q) rgs, ok := f.rules[q.OrgID] if !ok { return nil @@ -67,7 +104,7 @@ func (f *fakeRuleStore) GetAlertRuleByUID(q *models.GetAlertRuleByUIDQuery) erro func (f *fakeRuleStore) GetAlertRulesForScheduling(q *models.ListAlertRulesQuery) error { f.mtx.Lock() defer f.mtx.Unlock() - + f.recordedOps = append(f.recordedOps, *q) for _, rg := range f.rules { for _, n := range rg { for _, r := range n { @@ -78,13 +115,22 @@ func (f *fakeRuleStore) GetAlertRulesForScheduling(q *models.ListAlertRulesQuery return nil } -func (f *fakeRuleStore) GetOrgAlertRules(_ *models.ListAlertRulesQuery) error { return nil } -func (f *fakeRuleStore) GetNamespaceAlertRules(_ *models.ListNamespaceAlertRulesQuery) error { +func (f *fakeRuleStore) GetOrgAlertRules(q *models.ListAlertRulesQuery) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, *q) + return nil +} +func (f *fakeRuleStore) GetNamespaceAlertRules(q *models.ListNamespaceAlertRulesQuery) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, *q) return nil } func (f *fakeRuleStore) GetRuleGroupAlertRules(q *models.ListRuleGroupAlertRulesQuery) error { f.mtx.Lock() defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, *q) rgs, ok := f.rules[q.OrgID] if !ok { return nil @@ -116,11 +162,23 @@ func (f *fakeRuleStore) GetNamespaces(_ context.Context, _ int64, _ *models2.Sig func (f *fakeRuleStore) GetNamespaceByTitle(_ context.Context, _ string, _ int64, _ *models2.SignedInUser, _ bool) (*models2.Folder, error) { return nil, nil } -func (f *fakeRuleStore) GetOrgRuleGroups(_ *models.ListOrgRuleGroupsQuery) error { return nil } -func (f *fakeRuleStore) UpsertAlertRules(_ []store.UpsertRule) error { return nil } +func (f *fakeRuleStore) GetOrgRuleGroups(q *models.ListOrgRuleGroupsQuery) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, *q) + return nil +} + +func (f *fakeRuleStore) UpsertAlertRules(q []store.UpsertRule) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, q) + return nil +} func (f *fakeRuleStore) UpdateRuleGroup(cmd store.UpdateRuleGroupCmd) error { f.mtx.Lock() defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, cmd) rgs, ok := f.rules[cmd.OrgID] if !ok { f.rules[cmd.OrgID] = map[string]map[string][]*models.AlertRule{} @@ -138,7 +196,7 @@ func (f *fakeRuleStore) UpdateRuleGroup(cmd store.UpdateRuleGroupCmd) error { rules := []*models.AlertRule{} for _, r := range cmd.RuleGroupConfig.Rules { - //TODO: Not sure why this is not being set properly, where is the code that sets this? + // TODO: Not sure why this is not being set properly, where is the code that sets this? for i := range r.GrafanaManagedAlert.Data { r.GrafanaManagedAlert.Data[i].DatasourceUID = "-100" } @@ -181,13 +239,32 @@ func (f *fakeRuleStore) UpdateRuleGroup(cmd store.UpdateRuleGroupCmd) error { return nil } -type fakeInstanceStore struct{} +type fakeInstanceStore struct { + mtx sync.Mutex + recordedOps []interface{} +} -func (f *fakeInstanceStore) GetAlertInstance(_ *models.GetAlertInstanceQuery) error { return nil } -func (f *fakeInstanceStore) ListAlertInstances(_ *models.ListAlertInstancesQuery) error { return nil } -func (f *fakeInstanceStore) SaveAlertInstance(_ *models.SaveAlertInstanceCommand) error { return nil } -func (f *fakeInstanceStore) FetchOrgIds() ([]int64, error) { return []int64{}, nil } -func (f *fakeInstanceStore) DeleteAlertInstance(_ int64, _, _ string) error { return nil } +func (f *fakeInstanceStore) GetAlertInstance(q *models.GetAlertInstanceQuery) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, *q) + return nil +} +func (f *fakeInstanceStore) ListAlertInstances(q *models.ListAlertInstancesQuery) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, *q) + return nil +} +func (f *fakeInstanceStore) SaveAlertInstance(q *models.SaveAlertInstanceCommand) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.recordedOps = append(f.recordedOps, *q) + return nil +} + +func (f *fakeInstanceStore) FetchOrgIds() ([]int64, error) { return []int64{}, nil } +func (f *fakeInstanceStore) DeleteAlertInstance(_ int64, _, _ string) error { return nil } func newFakeAdminConfigStore(t *testing.T) *fakeAdminConfigStore { t.Helper() diff --git a/pkg/services/notifications/codes_test.go b/pkg/services/notifications/codes_test.go index f470c2f8b5e..a314c8decab 100644 --- a/pkg/services/notifications/codes_test.go +++ b/pkg/services/notifications/codes_test.go @@ -5,34 +5,35 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) func TestEmailCodes(t *testing.T) { - Convey("When generating code", t, func() { + t.Run("When generating code", func(t *testing.T) { cfg := setting.NewCfg() cfg.EmailCodeValidMinutes = 120 user := &models.User{Id: 10, Email: "t@a.com", Login: "asd", Password: "1", Rands: "2"} code, err := createUserEmailCode(cfg, user, nil) - So(err, ShouldBeNil) + require.NoError(t, err) - Convey("getLoginForCode should return login", func() { + t.Run("getLoginForCode should return login", func(t *testing.T) { login := getLoginForEmailCode(code) - So(login, ShouldEqual, "asd") + require.Equal(t, login, "asd") }) - Convey("Can verify valid code", func() { + t.Run("Can verify valid code", func(t *testing.T) { isValid, err := validateUserEmailCode(cfg, user, code) - So(err, ShouldBeNil) - So(isValid, ShouldBeTrue) + require.NoError(t, err) + require.True(t, isValid) }) - Convey("Cannot verify in-valid code", func() { + t.Run("Cannot verify in-valid code", func(t *testing.T) { code = "ASD" isValid, err := validateUserEmailCode(cfg, user, code) - So(err, ShouldBeNil) - So(isValid, ShouldBeFalse) + require.NoError(t, err) + require.False(t, isValid) }) }) } diff --git a/pkg/services/notifications/send_email_integration_test.go b/pkg/services/notifications/send_email_integration_test.go index ef1b5496b90..6b257312357 100644 --- a/pkg/services/notifications/send_email_integration_test.go +++ b/pkg/services/notifications/send_email_integration_test.go @@ -7,11 +7,14 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) func TestEmailIntegrationTest(t *testing.T) { - SkipConvey("Given the notifications service", t, func() { + t.Run("Given the notifications service", func(t *testing.T) { + t.Skip() + setting.StaticRootPath = "../../../public/" setting.BuildVersion = "4.0.0" @@ -24,7 +27,7 @@ func TestEmailIntegrationTest(t *testing.T) { ns.Cfg.Smtp.FromName = "Grafana Admin" ns.Cfg.Smtp.ContentTypes = []string{"text/html", "text/plain"} - Convey("When sending reset email password", func() { + t.Run("When sending reset email password", func(t *testing.T) { cmd := &models.SendEmailCommand{ Data: map[string]interface{}{ @@ -54,15 +57,15 @@ func TestEmailIntegrationTest(t *testing.T) { } err := ns.sendEmailCommandHandler(cmd) - So(err, ShouldBeNil) + require.NoError(t, err) sentMsg := <-ns.mailQueue - So(sentMsg.From, ShouldEqual, "Grafana Admin ") - So(sentMsg.To[0], ShouldEqual, "asdf@asdf.com") + require.Equal(t, sentMsg.From, "Grafana Admin ") + require.Equal(t, sentMsg.To[0], "asdf@asdf.com") err = ioutil.WriteFile("../../../tmp/test_email.html", []byte(sentMsg.Body["text/html"]), 0777) - So(err, ShouldBeNil) + require.NoError(t, err) err = ioutil.WriteFile("../../../tmp/test_email.txt", []byte(sentMsg.Body["text/plain"]), 0777) - So(err, ShouldBeNil) + require.NoError(t, err) }) }) } diff --git a/pkg/services/searchusers/filters/filters.go b/pkg/services/searchusers/filters/filters.go index 9a573623521..be5f66ca0b6 100644 --- a/pkg/services/searchusers/filters/filters.go +++ b/pkg/services/searchusers/filters/filters.go @@ -26,7 +26,7 @@ func (o *OSSSearchUserFilter) GetFilter(filterName string, params []string) mode } filter, err := f(params) if err != nil { - log.Warnf("Cannot initialise the filter %s: %s", filterName, err) + log.Warn("Cannot initialise the filter.", "filter", filterName, "error", err) return nil } return filter diff --git a/pkg/services/sqlstore/migrations/ualert/securejsondata.go b/pkg/services/sqlstore/migrations/ualert/securejsondata.go index 119a44f3d50..557cd9046a7 100644 --- a/pkg/services/sqlstore/migrations/ualert/securejsondata.go +++ b/pkg/services/sqlstore/migrations/ualert/securejsondata.go @@ -1,6 +1,8 @@ package ualert import ( + "os" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -16,7 +18,8 @@ func (s SecureJsonData) DecryptedValue(key string) (string, bool) { if value, ok := s[key]; ok { decryptedData, err := util.Decrypt(value, setting.SecretKey) if err != nil { - log.Fatalf(4, err.Error()) + log.Error(err.Error()) + os.Exit(1) } return string(decryptedData), true } @@ -30,7 +33,8 @@ func (s SecureJsonData) Decrypt() map[string]string { for key, data := range s { decryptedData, err := util.Decrypt(data, setting.SecretKey) if err != nil { - log.Fatalf(4, err.Error()) + log.Error(err.Error()) + os.Exit(1) } decrypted[key] = string(decryptedData) @@ -44,7 +48,8 @@ func GetEncryptedJsonData(sjd map[string]string) SecureJsonData { for key, data := range sjd { encryptedData, err := util.Encrypt([]byte(data), setting.SecretKey) if err != nil { - log.Fatalf(4, err.Error()) + log.Error(err.Error()) + os.Exit(1) } encrypted[key] = encryptedData diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index d5fffe2446f..7941d3d3210 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -50,7 +50,7 @@ func (e *MigrationError) Unwrap() error { return e.Err } func AddDashAlertMigration(mg *migrator.Migrator) { logs, err := mg.GetMigrationLog() if err != nil { - mg.Logger.Crit("alert migration failure: could not get migration log", "error", err) + mg.Logger.Error("alert migration failure: could not get migration log", "error", err) os.Exit(1) } @@ -90,7 +90,7 @@ func AddDashAlertMigration(mg *migrator.Migrator) { func RerunDashAlertMigration(mg *migrator.Migrator) { logs, err := mg.GetMigrationLog() if err != nil { - mg.Logger.Crit("alert migration failure: could not get migration log", "error", err) + mg.Logger.Error("alert migration failure: could not get migration log", "error", err) os.Exit(1) } @@ -111,7 +111,7 @@ func RerunDashAlertMigration(mg *migrator.Migrator) { func AddDashboardUIDPanelIDMigration(mg *migrator.Migrator) { logs, err := mg.GetMigrationLog() if err != nil { - mg.Logger.Crit("alert migration failure: could not get migration log", "error", err) + mg.Logger.Error("alert migration failure: could not get migration log", "error", err) os.Exit(1) } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 1a851fd9369..821aa181178 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -161,6 +161,11 @@ func (ss *SQLStore) Reset() error { return ss.ensureMainOrgAndAdminUser() } +// Quote quotes the value in the used SQL dialect +func (ss *SQLStore) Quote(value string) string { + return ss.engine.Quote(value) +} + func (ss *SQLStore) ensureMainOrgAndAdminUser() error { ctx := context.Background() err := ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { diff --git a/pkg/services/sqlstore/transactions.go b/pkg/services/sqlstore/transactions.go index 6c2c01101a8..62043aae7fd 100644 --- a/pkg/services/sqlstore/transactions.go +++ b/pkg/services/sqlstore/transactions.go @@ -67,7 +67,7 @@ func inTransactionWithRetryCtx(ctx context.Context, engine *xorm.Engine, callbac if len(sess.events) > 0 { for _, e := range sess.events { if err = bus.Publish(e); err != nil { - log.Errorf(3, "Failed to publish event after commit. error: %v", err) + log.Error("Failed to publish event after commit.", "error", err) } } } diff --git a/pkg/services/teamguardian/teams_test.go b/pkg/services/teamguardian/teams_test.go index 358e1fc3c80..e79e3f1a689 100644 --- a/pkg/services/teamguardian/teams_test.go +++ b/pkg/services/teamguardian/teams_test.go @@ -5,11 +5,12 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) func TestUpdateTeam(t *testing.T) { - Convey("Updating a team", t, func() { + t.Run("Updating a team", func(t *testing.T) { bus.ClearBusHandlers() admin := models.SignedInUser{ @@ -27,20 +28,20 @@ func TestUpdateTeam(t *testing.T) { OrgId: 1, } - Convey("Given an editor and a team he isn't a member of", func() { - Convey("Should not be able to update the team", func() { + t.Run("Given an editor and a team he isn't a member of", func(t *testing.T) { + t.Run("Should not be able to update the team", func(t *testing.T) { bus.AddHandler("test", func(cmd *models.GetTeamMembersQuery) error { cmd.Result = []*models.TeamMemberDTO{} return nil }) err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor) - So(err, ShouldEqual, models.ErrNotAllowedToUpdateTeam) + require.Equal(t, models.ErrNotAllowedToUpdateTeam, err) }) }) - Convey("Given an editor and a team he is an admin in", func() { - Convey("Should be able to update the team", func() { + t.Run("Given an editor and a team he is an admin in", func(t *testing.T) { + t.Run("Should be able to update the team", func(t *testing.T) { bus.AddHandler("test", func(cmd *models.GetTeamMembersQuery) error { cmd.Result = []*models.TeamMemberDTO{{ OrgId: testTeam.OrgId, @@ -52,17 +53,17 @@ func TestUpdateTeam(t *testing.T) { }) err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor) - So(err, ShouldBeNil) + require.NoError(t, err) }) }) - Convey("Given an editor and a team in another org", func() { + t.Run("Given an editor and a team in another org", func(t *testing.T) { testTeamOtherOrg := models.Team{ Id: 1, OrgId: 2, } - Convey("Shouldn't be able to update the team", func() { + t.Run("Shouldn't be able to update the team", func(t *testing.T) { bus.AddHandler("test", func(cmd *models.GetTeamMembersQuery) error { cmd.Result = []*models.TeamMemberDTO{{ OrgId: testTeamOtherOrg.OrgId, @@ -74,14 +75,14 @@ func TestUpdateTeam(t *testing.T) { }) err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor) - So(err, ShouldEqual, models.ErrNotAllowedToUpdateTeamInDifferentOrg) + require.Equal(t, models.ErrNotAllowedToUpdateTeamInDifferentOrg, err) }) }) - Convey("Given an org admin and a team", func() { - Convey("Should be able to update the team", func() { + t.Run("Given an org admin and a team", func(t *testing.T) { + t.Run("Should be able to update the team", func(t *testing.T) { err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin) - So(err, ShouldBeNil) + require.NoError(t, err) }) }) }) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 3b3d6afe5db..c4c132821d5 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -464,7 +464,8 @@ func parseAppUrlAndSubUrl(section *ini.Section) (string, string, error) { // Check if has app suburl. url, err := url.Parse(appUrl) if err != nil { - log.Fatalf(4, "Invalid root_url(%s): %s", appUrl, err) + log.Error("Invalid root_url.", "url", appUrl, "error", err) + os.Exit(1) } appSubUrl := strings.TrimSuffix(url.Path, "/") @@ -631,8 +632,8 @@ func getCommandLineProperties(args []string) map[string]string { trimmed := strings.TrimPrefix(arg, "cfg:") parts := strings.Split(trimmed, "=") if len(parts) != 2 { - log.Fatalf(3, "Invalid command line argument. argument: %v", arg) - return nil + log.Error("Invalid command line argument.", "argument", arg) + os.Exit(1) } props[parts[0]] = parts[1] @@ -718,7 +719,8 @@ func (cfg *Cfg) loadConfiguration(args CommandLineArgs) (*ini.File, error) { if err2 != nil { return nil, err2 } - log.Fatalf(3, err.Error()) + log.Error(err.Error()) + os.Exit(1) } // apply environment overrides @@ -961,7 +963,7 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.readDataSourcesSettings() if VerifyEmailEnabled && !cfg.Smtp.Enabled { - log.Warnf("require_email_validation is enabled but smtp is disabled") + log.Warn("require_email_validation is enabled but smtp is disabled") } // check old key name @@ -1356,7 +1358,8 @@ func (cfg *Cfg) readRenderingSettings(iniFile *ini.File) error { _, err := url.Parse(cfg.RendererCallbackUrl) if err != nil { // XXX: Should return an error? - log.Fatalf(4, "Invalid callback_url(%s): %s", cfg.RendererCallbackUrl, err) + log.Error("Invalid callback_url.", "url", cfg.RendererCallbackUrl, "error", err) + os.Exit(1) } } diff --git a/pkg/setting/setting_session_test.go b/pkg/setting/setting_session_test.go index a0f97540ccd..7ad622b02e7 100644 --- a/pkg/setting/setting_session_test.go +++ b/pkg/setting/setting_session_test.go @@ -5,7 +5,8 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/log" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) type testLogger struct { @@ -24,24 +25,22 @@ func (stub *testLogger) Info(testMessage string, ctx ...interface{}) { } func TestSessionSettings(t *testing.T) { - Convey("session config", t, func() { - skipStaticRootValidation = true + skipStaticRootValidation = true - Convey("Reading session should log error ", func() { - cfg := NewCfg() - homePath := "../../" + t.Run("Reading session should log error ", func(t *testing.T) { + cfg := NewCfg() + homePath := "../../" - stub := &testLogger{} - cfg.Logger = stub + stub := &testLogger{} + cfg.Logger = stub - err := cfg.Load(CommandLineArgs{ - HomePath: homePath, - Config: filepath.Join(homePath, "pkg/setting/testdata/session.ini"), - }) - So(err, ShouldBeNil) - - So(stub.warnCalled, ShouldEqual, true) - So(len(stub.warnMessage), ShouldBeGreaterThan, 0) + err := cfg.Load(CommandLineArgs{ + HomePath: homePath, + Config: filepath.Join(homePath, "pkg/setting/testdata/session.ini"), }) + require.Nil(t, err) + + require.Equal(t, true, stub.warnCalled) + require.Greater(t, len(stub.warnMessage), 0) }) } diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index d74ecad5312..c33039aeb9a 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -15,8 +15,6 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/ini.v1" - - . "github.com/smartystreets/goconvey/convey" ) const ( @@ -24,281 +22,279 @@ const ( ) func TestLoadingSettings(t *testing.T) { - Convey("Testing loading settings from ini file", t, func() { - skipStaticRootValidation = true + skipStaticRootValidation = true - Convey("Given the default ini files", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{HomePath: "../../", Config: "../../conf/defaults.ini"}) - So(err, ShouldBeNil) + t.Run("Given the default ini files", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{HomePath: "../../", Config: "../../conf/defaults.ini"}) + require.Nil(t, err) - So(cfg.AdminUser, ShouldEqual, "admin") - So(cfg.RendererCallbackUrl, ShouldEqual, "http://localhost:3000/") - }) - - Convey("default.ini should have no semi-colon commented entries", func() { - file, err := os.Open("../../conf/defaults.ini") - if err != nil { - t.Errorf("failed to load defaults.ini file: %v", err) - } - defer func() { - err := file.Close() - So(err, ShouldBeNil) - }() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - // This only catches values commented out with ";" and will not catch those that are commented out with "#". - if strings.HasPrefix(scanner.Text(), ";") { - t.Errorf("entries in defaults.ini must not be commented or environment variables will not work: %v", scanner.Text()) - } - } - }) - - Convey("sample.ini should load successfully", func() { - customInitPath := CustomInitPath - CustomInitPath = "conf/sample.ini" - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{HomePath: "../../"}) - So(err, ShouldBeNil) - // Restore CustomInitPath to avoid side effects. - CustomInitPath = customInitPath - }) - - Convey("Should be able to override via environment variables", func() { - err := os.Setenv("GF_SECURITY_ADMIN_USER", "superduper") - require.NoError(t, err) - - cfg := NewCfg() - err = cfg.Load(CommandLineArgs{HomePath: "../../"}) - So(err, ShouldBeNil) - - So(cfg.AdminUser, ShouldEqual, "superduper") - So(cfg.DataPath, ShouldEqual, filepath.Join(HomePath, "data")) - So(cfg.LogsPath, ShouldEqual, filepath.Join(cfg.DataPath, "log")) - }) - - Convey("Should replace password when defined in environment", func() { - err := os.Setenv("GF_SECURITY_ADMIN_PASSWORD", "supersecret") - require.NoError(t, err) - - cfg := NewCfg() - err = cfg.Load(CommandLineArgs{HomePath: "../../"}) - So(err, ShouldBeNil) - - So(appliedEnvOverrides, ShouldContain, "GF_SECURITY_ADMIN_PASSWORD=*********") - }) - - Convey("Should replace password in URL when url environment is defined", func() { - err := os.Setenv("GF_DATABASE_URL", "mysql://user:secret@localhost:3306/database") - require.NoError(t, err) - - cfg := NewCfg() - err = cfg.Load(CommandLineArgs{HomePath: "../../"}) - So(err, ShouldBeNil) - - So(appliedEnvOverrides, ShouldContain, "GF_DATABASE_URL=mysql://user:xxxxx@localhost:3306/database") - }) - - Convey("Should get property map from command line args array", func() { - props := getCommandLineProperties([]string{"cfg:test=value", "cfg:map.test=1"}) - - So(len(props), ShouldEqual, 2) - So(props["test"], ShouldEqual, "value") - So(props["map.test"], ShouldEqual, "1") - }) - - Convey("Should be able to override via command line", func() { - if runtime.GOOS == windows { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`}, - }) - So(err, ShouldBeNil) - So(cfg.DataPath, ShouldEqual, `c:\tmp\data`) - So(cfg.LogsPath, ShouldEqual, `c:\tmp\logs`) - } else { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"}, - }) - So(err, ShouldBeNil) - - So(cfg.DataPath, ShouldEqual, "/tmp/data") - So(cfg.LogsPath, ShouldEqual, "/tmp/logs") - } - }) - - Convey("Should be able to override defaults via command line", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{ - "cfg:default.server.domain=test2", - }, - Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), - }) - So(err, ShouldBeNil) - - So(cfg.Domain, ShouldEqual, "test2") - }) - - Convey("Defaults can be overridden in specified config file", func() { - if runtime.GOOS == windows { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), - Args: []string{`cfg:default.paths.data=c:\tmp\data`}, - }) - So(err, ShouldBeNil) - - So(cfg.DataPath, ShouldEqual, `c:\tmp\override`) - } else { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), - Args: []string{"cfg:default.paths.data=/tmp/data"}, - }) - So(err, ShouldBeNil) - - So(cfg.DataPath, ShouldEqual, "/tmp/override") - } - }) - - Convey("Command line overrides specified config file", func() { - if runtime.GOOS == windows { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), - Args: []string{`cfg:paths.data=c:\tmp\data`}, - }) - So(err, ShouldBeNil) - - So(cfg.DataPath, ShouldEqual, `c:\tmp\data`) - } else { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), - Args: []string{"cfg:paths.data=/tmp/data"}, - }) - So(err, ShouldBeNil) - - So(cfg.DataPath, ShouldEqual, "/tmp/data") - } - }) - - Convey("Can use environment variables in config values", func() { - if runtime.GOOS == windows { - err := os.Setenv("GF_DATA_PATH", `c:\tmp\env_override`) - require.NoError(t, err) - cfg := NewCfg() - err = cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:paths.data=${GF_DATA_PATH}"}, - }) - So(err, ShouldBeNil) - - So(cfg.DataPath, ShouldEqual, `c:\tmp\env_override`) - } else { - err := os.Setenv("GF_DATA_PATH", "/tmp/env_override") - require.NoError(t, err) - cfg := NewCfg() - err = cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:paths.data=${GF_DATA_PATH}"}, - }) - So(err, ShouldBeNil) - - So(cfg.DataPath, ShouldEqual, "/tmp/env_override") - } - }) - - Convey("instance_name default to hostname even if hostname env is empty", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - }) - So(err, ShouldBeNil) - - hostname, err := os.Hostname() - So(err, ShouldBeNil) - So(InstanceName, ShouldEqual, hostname) - }) - - Convey("Reading callback_url should add trailing slash", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:rendering.callback_url=http://myserver/renderer"}, - }) - So(err, ShouldBeNil) - - So(cfg.RendererCallbackUrl, ShouldEqual, "http://myserver/renderer/") - }) - - Convey("Only sync_ttl should return the value sync_ttl", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:auth.proxy.sync_ttl=2"}, - }) - So(err, ShouldBeNil) - - So(cfg.AuthProxySyncTTL, ShouldEqual, 2) - }) - - Convey("Only ldap_sync_ttl should return the value ldap_sync_ttl", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:auth.proxy.ldap_sync_ttl=5"}, - }) - So(err, ShouldBeNil) - - So(cfg.AuthProxySyncTTL, ShouldEqual, 5) - }) - - Convey("ldap_sync should override ldap_sync_ttl that is default value", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:auth.proxy.sync_ttl=5"}, - }) - So(err, ShouldBeNil) - - So(cfg.AuthProxySyncTTL, ShouldEqual, 5) - }) - - Convey("ldap_sync should not override ldap_sync_ttl that is different from default value", func() { - cfg := NewCfg() - err := cfg.Load(CommandLineArgs{ - HomePath: "../../", - Args: []string{"cfg:auth.proxy.ldap_sync_ttl=12", "cfg:auth.proxy.sync_ttl=5"}, - }) - So(err, ShouldBeNil) - - So(cfg.AuthProxySyncTTL, ShouldEqual, 12) - }) + require.Equal(t, "admin", cfg.AdminUser) + require.Equal(t, "http://localhost:3000/", cfg.RendererCallbackUrl) }) - Convey("Test reading string values from .ini file", t, func() { - iniFile, err := ini.Load(path.Join(HomePath, "pkg/setting/testdata/invalid.ini")) - So(err, ShouldBeNil) + t.Run("default.ini should have no semi-colon commented entries", func(t *testing.T) { + file, err := os.Open("../../conf/defaults.ini") + if err != nil { + t.Errorf("failed to load defaults.ini file: %v", err) + } + defer func() { + err := file.Close() + require.Nil(t, err) + }() - Convey("If key is found - should return value from ini file", func() { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + // This only catches values commented out with ";" and will not catch those that are commented out with "#". + if strings.HasPrefix(scanner.Text(), ";") { + t.Errorf("entries in defaults.ini must not be commented or environment variables will not work: %v", scanner.Text()) + } + } + }) + + t.Run("sample.ini should load successfully", func(t *testing.T) { + customInitPath := CustomInitPath + CustomInitPath = "conf/sample.ini" + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{HomePath: "../../"}) + require.Nil(t, err) + // Restore CustomInitPath to avoid side effects. + CustomInitPath = customInitPath + }) + + t.Run("Should be able to override via environment variables", func(t *testing.T) { + err := os.Setenv("GF_SECURITY_ADMIN_USER", "superduper") + require.NoError(t, err) + + cfg := NewCfg() + err = cfg.Load(CommandLineArgs{HomePath: "../../"}) + require.Nil(t, err) + + require.Equal(t, "superduper", cfg.AdminUser) + require.Equal(t, filepath.Join(HomePath, "data"), cfg.DataPath) + require.Equal(t, filepath.Join(cfg.DataPath, "log"), cfg.LogsPath) + }) + + t.Run("Should replace password when defined in environment", func(t *testing.T) { + err := os.Setenv("GF_SECURITY_ADMIN_PASSWORD", "supersecret") + require.NoError(t, err) + + cfg := NewCfg() + err = cfg.Load(CommandLineArgs{HomePath: "../../"}) + require.Nil(t, err) + + require.Contains(t, appliedEnvOverrides, "GF_SECURITY_ADMIN_PASSWORD=*********") + }) + + t.Run("Should replace password in URL when url environment is defined", func(t *testing.T) { + err := os.Setenv("GF_DATABASE_URL", "mysql://user:secret@localhost:3306/database") + require.NoError(t, err) + + cfg := NewCfg() + err = cfg.Load(CommandLineArgs{HomePath: "../../"}) + require.Nil(t, err) + + require.Contains(t, appliedEnvOverrides, "GF_DATABASE_URL=mysql://user:xxxxx@localhost:3306/database") + }) + + t.Run("Should get property map from command line args array", func(t *testing.T) { + props := getCommandLineProperties([]string{"cfg:test=value", "cfg:map.test=1"}) + + require.Equal(t, 2, len(props)) + require.Equal(t, "value", props["test"]) + require.Equal(t, "1", props["map.test"]) + }) + + t.Run("Should be able to override via command line", func(t *testing.T) { + if runtime.GOOS == windows { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`}, + }) + require.Nil(t, err) + require.Equal(t, `c:\tmp\data`, cfg.DataPath) + require.Equal(t, `c:\tmp\logs`, cfg.LogsPath) + } else { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"}, + }) + require.Nil(t, err) + + require.Equal(t, "/tmp/data", cfg.DataPath) + require.Equal(t, "/tmp/logs", cfg.LogsPath) + } + }) + + t.Run("Should be able to override defaults via command line", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{ + "cfg:default.server.domain=test2", + }, + Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), + }) + require.Nil(t, err) + + require.Equal(t, "test2", cfg.Domain) + }) + + t.Run("Defaults can be overridden in specified config file", func(t *testing.T) { + if runtime.GOOS == windows { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), + Args: []string{`cfg:default.paths.data=c:\tmp\data`}, + }) + require.Nil(t, err) + + require.Equal(t, `c:\tmp\override`, cfg.DataPath) + } else { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), + Args: []string{"cfg:default.paths.data=/tmp/data"}, + }) + require.Nil(t, err) + + require.Equal(t, "/tmp/override", cfg.DataPath) + } + }) + + t.Run("Command line overrides specified config file", func(t *testing.T) { + if runtime.GOOS == windows { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), + Args: []string{`cfg:paths.data=c:\tmp\data`}, + }) + require.Nil(t, err) + + require.Equal(t, `c:\tmp\data`, cfg.DataPath) + } else { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), + Args: []string{"cfg:paths.data=/tmp/data"}, + }) + require.Nil(t, err) + + require.Equal(t, "/tmp/data", cfg.DataPath) + } + }) + + t.Run("Can use environment variables in config values", func(t *testing.T) { + if runtime.GOOS == windows { + err := os.Setenv("GF_DATA_PATH", `c:\tmp\env_override`) + require.NoError(t, err) + cfg := NewCfg() + err = cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:paths.data=${GF_DATA_PATH}"}, + }) + require.Nil(t, err) + + require.Equal(t, `c:\tmp\env_override`, cfg.DataPath) + } else { + err := os.Setenv("GF_DATA_PATH", "/tmp/env_override") + require.NoError(t, err) + cfg := NewCfg() + err = cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:paths.data=${GF_DATA_PATH}"}, + }) + require.Nil(t, err) + + require.Equal(t, "/tmp/env_override", cfg.DataPath) + } + }) + + t.Run("instance_name default to hostname even if hostname env is empty", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + }) + require.Nil(t, err) + + hostname, err := os.Hostname() + require.Nil(t, err) + require.Equal(t, hostname, InstanceName) + }) + + t.Run("Reading callback_url should add trailing slash", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:rendering.callback_url=http://myserver/renderer"}, + }) + require.Nil(t, err) + + require.Equal(t, "http://myserver/renderer/", cfg.RendererCallbackUrl) + }) + + t.Run("Only sync_ttl should return the value sync_ttl", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:auth.proxy.sync_ttl=2"}, + }) + require.Nil(t, err) + + require.Equal(t, 2, cfg.AuthProxySyncTTL) + }) + + t.Run("Only ldap_sync_ttl should return the value ldap_sync_ttl", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:auth.proxy.ldap_sync_ttl=5"}, + }) + require.Nil(t, err) + + require.Equal(t, 5, cfg.AuthProxySyncTTL) + }) + + t.Run("ldap_sync should override ldap_sync_ttl that is default value", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:auth.proxy.sync_ttl=5"}, + }) + require.Nil(t, err) + + require.Equal(t, 5, cfg.AuthProxySyncTTL) + }) + + t.Run("ldap_sync should not override ldap_sync_ttl that is different from default value", func(t *testing.T) { + cfg := NewCfg() + err := cfg.Load(CommandLineArgs{ + HomePath: "../../", + Args: []string{"cfg:auth.proxy.ldap_sync_ttl=12", "cfg:auth.proxy.sync_ttl=5"}, + }) + require.Nil(t, err) + + require.Equal(t, 12, cfg.AuthProxySyncTTL) + }) + + t.Run("Test reading string values from .ini file", func(t *testing.T) { + iniFile, err := ini.Load(path.Join(HomePath, "pkg/setting/testdata/invalid.ini")) + require.Nil(t, err) + + t.Run("If key is found - should return value from ini file", func(t *testing.T) { value := valueAsString(iniFile.Section("server"), "alt_url", "") - So(value, ShouldEqual, "https://grafana.com/") + require.Equal(t, "https://grafana.com/", value) }) - Convey("If key is not found - should return default value", func() { + t.Run("If key is not found - should return default value", func(t *testing.T) { value := valueAsString(iniFile.Section("server"), "extra_url", "default_url_val") - So(value, ShouldEqual, "default_url_val") + require.Equal(t, "default_url_val", value) }) }) } diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go index d5ee2a5ee3b..767d1a1b315 100644 --- a/pkg/tests/api/alerting/api_available_channel_test.go +++ b/pkg/tests/api/alerting/api_available_channel_test.go @@ -1405,7 +1405,23 @@ var expAvailableChannelJsonOutput = ` "required": false, "validationRule": "", "secure": false - } + }, + { + "element": "checkbox", + "inputType": "", + "label": "Use Discord's Webhook Username", + "description": "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'", + "placeholder": "", + "propertyName": "use_discord_username", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false + } ] }, { diff --git a/pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go b/pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go index 69d8a80ab65..6084036404a 100644 --- a/pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go +++ b/pkg/tsdb/azuremonitor/applicationinsights-datasource_test.go @@ -8,15 +8,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/stretchr/testify/require" - - . "github.com/smartystreets/goconvey/convey" ) func TestApplicationInsightsDatasource(t *testing.T) { - Convey("ApplicationInsightsDatasource", t, func() { + t.Run("ApplicationInsightsDatasource", func(t *testing.T) { datasource := &ApplicationInsightsDatasource{} - Convey("Parse queries from frontend and build AzureMonitor API queries", func() { + t.Run("Parse queries from frontend and build AzureMonitor API queries", func(t *testing.T) { fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) tsdbQuery := []backend.DataQuery{ { @@ -38,22 +36,22 @@ func TestApplicationInsightsDatasource(t *testing.T) { Interval: 1234, }, } - Convey("and is a normal query", func() { + t.Run("and is a normal query", func(t *testing.T) { queries, err := datasource.buildQueries(tsdbQuery) - So(err, ShouldBeNil) + require.NoError(t, err) - So(len(queries), ShouldEqual, 1) - So(queries[0].RefID, ShouldEqual, "A") - So(queries[0].ApiURL, ShouldEqual, "metrics/server/exceptions") - So(queries[0].Target, ShouldEqual, "aggregation=Average&interval=PT1M×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z") - So(len(queries[0].Params), ShouldEqual, 3) - So(queries[0].Params["timespan"][0], ShouldEqual, "2018-03-15T13:00:00Z/2018-03-15T13:34:00Z") - So(queries[0].Params["aggregation"][0], ShouldEqual, "Average") - So(queries[0].Params["interval"][0], ShouldEqual, "PT1M") - So(queries[0].Alias, ShouldEqual, "testalias") + require.Equal(t, len(queries), 1) + require.Equal(t, queries[0].RefID, "A") + require.Equal(t, queries[0].ApiURL, "metrics/server/exceptions") + require.Equal(t, queries[0].Target, "aggregation=Average&interval=PT1M×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z") + require.Equal(t, len(queries[0].Params), 3) + require.Equal(t, queries[0].Params["timespan"][0], "2018-03-15T13:00:00Z/2018-03-15T13:34:00Z") + require.Equal(t, queries[0].Params["aggregation"][0], "Average") + require.Equal(t, queries[0].Params["interval"][0], "PT1M") + require.Equal(t, queries[0].Alias, "testalias") }) - Convey("and has a time grain set to auto", func() { + t.Run("and has a time grain set to auto", func(t *testing.T) { tsdbQuery[0].JSON = []byte(`{ "appInsights": { "rawQuery": false, @@ -69,12 +67,12 @@ func TestApplicationInsightsDatasource(t *testing.T) { require.NoError(t, err) queries, err := datasource.buildQueries(tsdbQuery) - So(err, ShouldBeNil) + require.NoError(t, err) - So(queries[0].Params["interval"][0], ShouldEqual, "PT15M") + require.Equal(t, queries[0].Params["interval"][0], "PT15M") }) - Convey("and has an empty time grain", func() { + t.Run("and has an empty time grain", func(t *testing.T) { tsdbQuery[0].JSON = []byte(`{ "appInsights": { "rawQuery": false, @@ -88,12 +86,12 @@ func TestApplicationInsightsDatasource(t *testing.T) { tsdbQuery[0].Interval, _ = time.ParseDuration("400s") queries, err := datasource.buildQueries(tsdbQuery) - So(err, ShouldBeNil) + require.NoError(t, err) - So(queries[0].Params["interval"][0], ShouldEqual, "PT15M") + require.Equal(t, queries[0].Params["interval"][0], "PT15M") }) - Convey("and has a time grain set to auto and the metric has a limited list of allowed time grains", func() { + t.Run("and has a time grain set to auto and the metric has a limited list of allowed time grains", func(t *testing.T) { tsdbQuery[0].JSON = []byte(`{ "appInsights": { "rawQuery": false, @@ -108,12 +106,12 @@ func TestApplicationInsightsDatasource(t *testing.T) { tsdbQuery[0].Interval, _ = time.ParseDuration("400s") queries, err := datasource.buildQueries(tsdbQuery) - So(err, ShouldBeNil) + require.NoError(t, err) - So(queries[0].Params["interval"][0], ShouldEqual, "PT5M") + require.Equal(t, queries[0].Params["interval"][0], "PT5M") }) - Convey("and has a dimension filter", func() { + t.Run("and has a dimension filter", func(t *testing.T) { tsdbQuery[0].JSON = []byte(`{ "appInsights": { "rawQuery": false, @@ -128,13 +126,13 @@ func TestApplicationInsightsDatasource(t *testing.T) { }`) queries, err := datasource.buildQueries(tsdbQuery) - So(err, ShouldBeNil) + require.NoError(t, err) - So(queries[0].Target, ShouldEqual, "aggregation=Average&filter=blob+eq+%27%2A%27&interval=PT1M&segment=blob×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z") - So(queries[0].Params["filter"][0], ShouldEqual, "blob eq '*'") + require.Equal(t, queries[0].Target, "aggregation=Average&filter=blob+eq+%27%2A%27&interval=PT1M&segment=blob×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z") + require.Equal(t, queries[0].Params["filter"][0], "blob eq '*'") }) - Convey("and has a dimension filter set to None", func() { + t.Run("and has a dimension filter set to None", func(t *testing.T) { tsdbQuery[0].JSON = []byte(`{ "appInsights": { "rawQuery": false, @@ -148,9 +146,9 @@ func TestApplicationInsightsDatasource(t *testing.T) { }`) queries, err := datasource.buildQueries(tsdbQuery) - So(err, ShouldBeNil) + require.NoError(t, err) - So(queries[0].Target, ShouldEqual, "aggregation=Average&interval=PT1M×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z") + require.Equal(t, queries[0].Target, "aggregation=Average&interval=PT1M×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z") }) }) }) diff --git a/pkg/tsdb/azuremonitor/url-builder_test.go b/pkg/tsdb/azuremonitor/url-builder_test.go index c4e40cd0ef5..a2e6e58ed55 100644 --- a/pkg/tsdb/azuremonitor/url-builder_test.go +++ b/pkg/tsdb/azuremonitor/url-builder_test.go @@ -3,12 +3,12 @@ package azuremonitor import ( "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) func TestURLBuilder(t *testing.T) { - Convey("AzureMonitor URL Builder", t, func() { - Convey("when metric definition is in the short form", func() { + t.Run("AzureMonitor URL Builder", func(t *testing.T) { + t.Run("when metric definition is in the short form", func(t *testing.T) { ub := &urlBuilder{ DefaultSubscription: "default-sub", ResourceGroup: "rg", @@ -17,10 +17,10 @@ func TestURLBuilder(t *testing.T) { } url := ub.Build() - So(url, ShouldEqual, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") }) - Convey("when metric definition is in the short form and a subscription is defined", func() { + t.Run("when metric definition is in the short form and a subscription is defined", func(t *testing.T) { ub := &urlBuilder{ DefaultSubscription: "default-sub", Subscription: "specified-sub", @@ -30,10 +30,10 @@ func TestURLBuilder(t *testing.T) { } url := ub.Build() - So(url, ShouldEqual, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") + require.Equal(t, url, "specified-sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics") }) - Convey("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func() { + t.Run("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func(t *testing.T) { ub := &urlBuilder{ DefaultSubscription: "default-sub", ResourceGroup: "rg", @@ -42,10 +42,10 @@ func TestURLBuilder(t *testing.T) { } url := ub.Build() - So(url, ShouldEqual, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics") + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics") }) - Convey("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func() { + t.Run("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func(t *testing.T) { ub := &urlBuilder{ DefaultSubscription: "default-sub", ResourceGroup: "rg", @@ -54,10 +54,10 @@ func TestURLBuilder(t *testing.T) { } url := ub.Build() - So(url, ShouldEqual, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics") + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics") }) - Convey("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func() { + t.Run("when metric definition is Microsoft.NetApp/netAppAccounts/capacityPools/volumes", func(t *testing.T) { ub := &urlBuilder{ DefaultSubscription: "default-sub", ResourceGroup: "rg", @@ -66,7 +66,7 @@ func TestURLBuilder(t *testing.T) { } url := ub.Build() - So(url, ShouldEqual, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics") + require.Equal(t, url, "default-sub/resourceGroups/rg/providers/Microsoft.NetApp/netAppAccounts/rn1/capacityPools/rn2/volumes/rn3/providers/microsoft.insights/metrics") }) }) } diff --git a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go index 4ed67d7d1a2..a319682e766 100644 --- a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go @@ -23,7 +23,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/api/pluginproxy" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" @@ -63,6 +62,8 @@ var ( ) const ( + dsName = "stackdriver" + gceAuthentication string = "gce" jwtAuthentication string = "jwt" metricQueryType string = "metrics" @@ -87,7 +88,7 @@ func ProvideService(cfg *setting.Cfg, httpClientProvider httpclient.Provider, pl QueryDataHandler: s, }) - if err := s.backendPluginManager.Register("stackdriver", factory); err != nil { + if err := s.backendPluginManager.Register(dsName, factory); err != nil { slog.Error("Failed to register plugin", "error", err) } return s @@ -112,9 +113,10 @@ type datasourceInfo struct { url string authenticationType string defaultProject string + clientEmail string + tokenUri string client *http.Client - jsonData map[string]interface{} decryptedSecureJSONData map[string]string } @@ -126,16 +128,6 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst return nil, fmt.Errorf("error reading settings: %w", err) } - opts, err := settings.HTTPClientOptions() - if err != nil { - return nil, err - } - - client, err := httpClientProvider.New(opts) - if err != nil { - return nil, err - } - authType := jwtAuthentication if authTypeOverride, ok := jsonData["authenticationType"].(string); ok && authTypeOverride != "" { authType = authTypeOverride @@ -146,16 +138,38 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst defaultProject = jsonData["defaultProject"].(string) } - return &datasourceInfo{ + var clientEmail string + if jsonData["clientEmail"] != nil { + clientEmail = jsonData["clientEmail"].(string) + } + + var tokenUri string + if jsonData["tokenUri"] != nil { + tokenUri = jsonData["tokenUri"].(string) + } + + dsInfo := &datasourceInfo{ id: settings.ID, updated: settings.Updated, url: settings.URL, authenticationType: authType, defaultProject: defaultProject, - client: client, - jsonData: jsonData, + clientEmail: clientEmail, + tokenUri: tokenUri, decryptedSecureJSONData: settings.DecryptedSecureJSONData, - }, nil + } + + opts, err := settings.HTTPClientOptions() + if err != nil { + return nil, err + } + + dsInfo.client, err = newHTTPClient(dsInfo, opts, httpClientProvider) + if err != nil { + return nil, err + } + + return dsInfo, nil } } @@ -340,14 +354,6 @@ func (s *Service) buildQueryExecutors(req *backend.QueryDataRequest) ([]cloudMon return cloudMonitoringQueryExecutors, nil } -func reverse(s string) string { - chars := []rune(s) - for i, j := 0, len(chars)-1; i < j; i, j = i+1, j-1 { - chars[i], chars[j] = chars[j], chars[i] - } - return string(chars) -} - func interpolateFilterWildcards(value string) string { matches := strings.Count(value, "*") switch { @@ -478,19 +484,6 @@ func calculateAlignmentPeriod(alignmentPeriod string, intervalMs int64, duration return alignmentPeriod } -func toSnakeCase(str string) string { - return strings.ToLower(matchAllCap.ReplaceAllString(str, "${1}_${2}")) -} - -func containsLabel(labels []string, newLabel string) bool { - for _, val := range labels { - if val == newLabel { - return true - } - } - return false -} - func formatLegendKeys(metricType string, defaultMetricName string, labels map[string]string, additionalLabels map[string]string, query *cloudMonitoringTimeSeriesFilter) string { if query.AliasBy == "" { @@ -589,34 +582,14 @@ func (s *Service) createRequest(ctx context.Context, pluginCtx backend.PluginCon if body != nil { method = http.MethodPost } - req, err := http.NewRequest(method, "https://monitoring.googleapis.com/", body) + req, err := http.NewRequest(method, cloudMonitoringRoute.url, body) if err != nil { slog.Error("Failed to create request", "error", err) return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - - // find plugin - plugin := s.pluginManager.GetDataSource(pluginCtx.PluginID) - if plugin == nil { - return nil, errors.New("unable to find datasource plugin CloudMonitoring") - } - - var cloudMonitoringRoute *plugins.AppPluginRoute - for _, route := range plugin.Routes { - if route.Path == "cloudmonitoring" { - cloudMonitoringRoute = route - break - } - } - - pluginproxy.ApplyRoute(ctx, req, proxyPass, cloudMonitoringRoute, pluginproxy.DSInfo{ - ID: dsInfo.id, - Updated: dsInfo.updated, - JSONData: dsInfo.jsonData, - DecryptedSecureJSONData: dsInfo.decryptedSecureJSONData, - }, s.cfg) + req.URL.Path = proxyPass return req, nil } diff --git a/pkg/tsdb/cloudmonitoring/httpclient.go b/pkg/tsdb/cloudmonitoring/httpclient.go new file mode 100644 index 00000000000..711795be6dd --- /dev/null +++ b/pkg/tsdb/cloudmonitoring/httpclient.go @@ -0,0 +1,56 @@ +package cloudmonitoring + +import ( + "net/http" + + "github.com/grafana/grafana-google-sdk-go/pkg/tokenprovider" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + infrahttp "github.com/grafana/grafana/pkg/infra/httpclient" +) + +var cloudMonitoringRoute = struct { + path string + method string + url string + scopes []string +}{ + path: "cloudmonitoring", + method: "GET", + url: "https://monitoring.googleapis.com", + scopes: []string{"https://www.googleapis.com/auth/monitoring.read"}, +} + +func getMiddleware(model *datasourceInfo) (httpclient.Middleware, error) { + providerConfig := tokenprovider.Config{ + RoutePath: cloudMonitoringRoute.path, + RouteMethod: cloudMonitoringRoute.method, + DataSourceID: model.id, + DataSourceUpdated: model.updated, + Scopes: cloudMonitoringRoute.scopes, + } + + var provider tokenprovider.TokenProvider + switch model.authenticationType { + case gceAuthentication: + provider = tokenprovider.NewGceAccessTokenProvider(providerConfig) + case jwtAuthentication: + providerConfig.JwtTokenConfig = &tokenprovider.JwtTokenConfig{ + Email: model.clientEmail, + URI: model.tokenUri, + PrivateKey: []byte(model.decryptedSecureJSONData["privateKey"]), + } + provider = tokenprovider.NewJwtAccessTokenProvider(providerConfig) + } + + return tokenprovider.AuthMiddleware(provider), nil +} + +func newHTTPClient(model *datasourceInfo, opts httpclient.Options, clientProvider infrahttp.Provider) (*http.Client, error) { + m, err := getMiddleware(model) + if err != nil { + return nil, err + } + + opts.Middlewares = append(opts.Middlewares, m) + return clientProvider.New(opts) +} diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index f091b56781e..8f80c9c4adf 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -29,7 +29,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) run(ctx context.Context slog.Info("No project name set on query, using project name from datasource", "projectName", projectName) } - r, err := s.createRequest(ctx, req.PluginContext, &dsInfo, path.Join("cloudmonitoringv3/projects", projectName, "timeSeries"), nil) + r, err := s.createRequest(ctx, req.PluginContext, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries"), nil) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil diff --git a/pkg/tsdb/cloudmonitoring/time_series_query.go b/pkg/tsdb/cloudmonitoring/time_series_query.go index 645c413889c..b8320e8ffcf 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query.go @@ -49,7 +49,7 @@ func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) run(ctx context.Context, r dr.Error = err return dr, cloudMonitoringResponse{}, "", nil } - r, err := s.createRequest(ctx, req.PluginContext, &dsInfo, path.Join("cloudmonitoringv3/projects", projectName, "timeSeries:query"), bytes.NewBuffer(buf)) + r, err := s.createRequest(ctx, req.PluginContext, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries:query"), bytes.NewBuffer(buf)) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil diff --git a/pkg/tsdb/cloudmonitoring/utils.go b/pkg/tsdb/cloudmonitoring/utils.go new file mode 100644 index 00000000000..91b2b6bacd1 --- /dev/null +++ b/pkg/tsdb/cloudmonitoring/utils.go @@ -0,0 +1,26 @@ +package cloudmonitoring + +import ( + "strings" +) + +func reverse(s string) string { + chars := []rune(s) + for i, j := 0, len(chars)-1; i < j; i, j = i+1, j-1 { + chars[i], chars[j] = chars[j], chars[i] + } + return string(chars) +} + +func toSnakeCase(str string) string { + return strings.ToLower(matchAllCap.ReplaceAllString(str, "${1}_${2}")) +} + +func containsLabel(labels []string, newLabel string) bool { + for _, val := range labels { + if val == newLabel { + return true + } + } + return false +} diff --git a/pkg/tsdb/elasticsearch/client/search_request_test.go b/pkg/tsdb/elasticsearch/client/search_request_test.go index 5472b9fe6db..f9c29ac25f3 100644 --- a/pkg/tsdb/elasticsearch/client/search_request_test.go +++ b/pkg/tsdb/elasticsearch/client/search_request_test.go @@ -8,437 +8,445 @@ import ( "github.com/Masterminds/semver" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/tsdb/intervalv2" - . "github.com/smartystreets/goconvey/convey" + + "github.com/stretchr/testify/require" ) func TestSearchRequest(t *testing.T) { - Convey("Test elasticsearch search request", t, func() { - timeField := "@timestamp" - Convey("Given new search request builder for es version 5", func() { - version5, _ := semver.NewVersion("5.0.0") - b := NewSearchRequestBuilder(version5, intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) + timeField := "@timestamp" - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) + setup := func() *SearchRequestBuilder { + version5, _ := semver.NewVersion("5.0.0") + return NewSearchRequestBuilder(version5, intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) + } - Convey("Should have size of zero", func() { - So(sr.Size, ShouldEqual, 0) - }) + t.Run("When building search request", func(t *testing.T) { + b := setup() + sr, err := b.Build() + require.Nil(t, err) - Convey("Should have no sorting", func() { - So(sr.Sort, ShouldHaveLength, 0) - }) + t.Run("Should have size of zero", func(t *testing.T) { + require.Equal(t, 0, sr.Size) + }) - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) - So(json.Get("size").MustInt(500), ShouldEqual, 0) - So(json.Get("sort").Interface(), ShouldBeNil) - So(json.Get("aggs").Interface(), ShouldBeNil) - So(json.Get("query").Interface(), ShouldBeNil) - }) + t.Run("Should have no sorting", func(t *testing.T) { + require.Equal(t, 0, len(sr.Sort)) + }) + + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) + require.Equal(t, 0, json.Get("size").MustInt(500)) + require.Nil(t, json.Get("sort").Interface()) + require.Nil(t, json.Get("aggs").Interface()) + require.Nil(t, json.Get("query").Interface()) + }) + }) + + t.Run("When adding size, sort, filters", func(t *testing.T) { + b := setup() + b.Size(200) + b.SortDesc(timeField, "boolean") + filters := b.Query().Bool().Filter() + filters.AddDateRangeFilter(timeField, "$timeTo", "$timeFrom", DateFormatEpochMS) + filters.AddQueryStringFilter("test", true) + + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) + + t.Run("Should have correct size", func(t *testing.T) { + require.Equal(t, 200, sr.Size) }) - Convey("When adding size, sort, filters", func() { - b.Size(200) - b.SortDesc(timeField, "boolean") - filters := b.Query().Bool().Filter() - filters.AddDateRangeFilter(timeField, "$timeTo", "$timeFrom", DateFormatEpochMS) - filters.AddQueryStringFilter("test", true) - - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) - - Convey("Should have correct size", func() { - So(sr.Size, ShouldEqual, 200) - }) - - Convey("Should have correct sorting", func() { - sort, ok := sr.Sort[timeField].(map[string]string) - So(ok, ShouldBeTrue) - So(sort["order"], ShouldEqual, "desc") - So(sort["unmapped_type"], ShouldEqual, "boolean") - }) - - Convey("Should have range filter", func() { - f, ok := sr.Query.Bool.Filters[0].(*RangeFilter) - So(ok, ShouldBeTrue) - So(f.Gte, ShouldEqual, "$timeFrom") - So(f.Lte, ShouldEqual, "$timeTo") - So(f.Format, ShouldEqual, "epoch_millis") - }) - - Convey("Should have query string filter", func() { - f, ok := sr.Query.Bool.Filters[1].(*QueryStringFilter) - So(ok, ShouldBeTrue) - So(f.Query, ShouldEqual, "test") - So(f.AnalyzeWildcard, ShouldBeTrue) - }) - - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) - So(json.Get("size").MustInt(0), ShouldEqual, 200) - - sort := json.GetPath("sort", timeField) - So(sort.Get("order").MustString(), ShouldEqual, "desc") - So(sort.Get("unmapped_type").MustString(), ShouldEqual, "boolean") - - timeRangeFilter := json.GetPath("query", "bool", "filter").GetIndex(0).Get("range").Get(timeField) - So(timeRangeFilter.Get("gte").MustString(""), ShouldEqual, "$timeFrom") - So(timeRangeFilter.Get("lte").MustString(""), ShouldEqual, "$timeTo") - So(timeRangeFilter.Get("format").MustString(""), ShouldEqual, DateFormatEpochMS) - - queryStringFilter := json.GetPath("query", "bool", "filter").GetIndex(1).Get("query_string") - So(queryStringFilter.Get("analyze_wildcard").MustBool(false), ShouldEqual, true) - So(queryStringFilter.Get("query").MustString(""), ShouldEqual, "test") - }) - }) + t.Run("Should have correct sorting", func(t *testing.T) { + sort, ok := sr.Sort[timeField].(map[string]string) + require.True(t, ok) + require.Equal(t, "desc", sort["order"]) + require.Equal(t, "boolean", sort["unmapped_type"]) }) - Convey("When adding doc value field", func() { - b.AddDocValueField(timeField) - - Convey("should set correct props", func() { - So(b.customProps["fields"], ShouldBeNil) - - scriptFields, ok := b.customProps["script_fields"].(map[string]interface{}) - So(ok, ShouldBeTrue) - So(scriptFields, ShouldHaveLength, 0) - - docValueFields, ok := b.customProps["docvalue_fields"].([]string) - So(ok, ShouldBeTrue) - So(docValueFields, ShouldHaveLength, 1) - So(docValueFields[0], ShouldEqual, timeField) - }) - - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) - - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) - - scriptFields, err := json.Get("script_fields").Map() - So(err, ShouldBeNil) - So(scriptFields, ShouldHaveLength, 0) - - _, err = json.Get("fields").StringArray() - So(err, ShouldNotBeNil) - - docValueFields, err := json.Get("docvalue_fields").StringArray() - So(err, ShouldBeNil) - So(docValueFields, ShouldHaveLength, 1) - So(docValueFields[0], ShouldEqual, timeField) - }) - }) + t.Run("Should have range filter", func(t *testing.T) { + f, ok := sr.Query.Bool.Filters[0].(*RangeFilter) + require.True(t, ok) + require.Equal(t, "$timeFrom", f.Gte) + require.Equal(t, "$timeTo", f.Lte) + require.Equal(t, "epoch_millis", f.Format) }) - Convey("and adding multiple top level aggs", func() { - aggBuilder := b.Agg() - aggBuilder.Terms("1", "@hostname", nil) - aggBuilder.DateHistogram("2", "@timestamp", nil) - - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) - - Convey("Should have 2 top level aggs", func() { - aggs := sr.Aggs - So(aggs, ShouldHaveLength, 2) - So(aggs[0].Key, ShouldEqual, "1") - So(aggs[0].Aggregation.Type, ShouldEqual, "terms") - So(aggs[1].Key, ShouldEqual, "2") - So(aggs[1].Aggregation.Type, ShouldEqual, "date_histogram") - }) - - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) - - So(json.Get("aggs").MustMap(), ShouldHaveLength, 2) - So(json.GetPath("aggs", "1", "terms", "field").MustString(), ShouldEqual, "@hostname") - So(json.GetPath("aggs", "2", "date_histogram", "field").MustString(), ShouldEqual, "@timestamp") - }) - }) + t.Run("Should have query string filter", func(t *testing.T) { + f, ok := sr.Query.Bool.Filters[1].(*QueryStringFilter) + require.True(t, ok) + require.Equal(t, "test", f.Query) + require.True(t, f.AnalyzeWildcard) }) - Convey("and adding top level agg with child agg", func() { - aggBuilder := b.Agg() - aggBuilder.Terms("1", "@hostname", func(a *TermsAggregation, ib AggBuilder) { - ib.DateHistogram("2", "@timestamp", nil) - }) + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) + require.Equal(t, 200, json.Get("size").MustInt(0)) - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) + sort := json.GetPath("sort", timeField) + require.Equal(t, "desc", sort.Get("order").MustString()) + require.Equal(t, "boolean", sort.Get("unmapped_type").MustString()) - Convey("Should have 1 top level agg and one child agg", func() { - aggs := sr.Aggs - So(aggs, ShouldHaveLength, 1) + timeRangeFilter := json.GetPath("query", "bool", "filter").GetIndex(0).Get("range").Get(timeField) + require.Equal(t, "$timeFrom", timeRangeFilter.Get("gte").MustString("")) + require.Equal(t, "$timeTo", timeRangeFilter.Get("lte").MustString("")) + require.Equal(t, DateFormatEpochMS, timeRangeFilter.Get("format").MustString("")) - topAgg := aggs[0] - So(topAgg.Key, ShouldEqual, "1") - So(topAgg.Aggregation.Type, ShouldEqual, "terms") - So(topAgg.Aggregation.Aggs, ShouldHaveLength, 1) + queryStringFilter := json.GetPath("query", "bool", "filter").GetIndex(1).Get("query_string") + require.Equal(t, true, queryStringFilter.Get("analyze_wildcard").MustBool(false)) + require.Equal(t, "test", queryStringFilter.Get("query").MustString("")) + }) + }) + }) - childAgg := aggs[0].Aggregation.Aggs[0] - So(childAgg.Key, ShouldEqual, "2") - So(childAgg.Aggregation.Type, ShouldEqual, "date_histogram") - }) + t.Run("When adding doc value field", func(t *testing.T) { + b := setup() + b.AddDocValueField(timeField) - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) + t.Run("should set correct props", func(t *testing.T) { + require.Nil(t, b.customProps["fields"]) - So(json.Get("aggs").MustMap(), ShouldHaveLength, 1) - firstLevelAgg := json.GetPath("aggs", "1") - secondLevelAgg := firstLevelAgg.GetPath("aggs", "2") - So(firstLevelAgg.GetPath("terms", "field").MustString(), ShouldEqual, "@hostname") - So(secondLevelAgg.GetPath("date_histogram", "field").MustString(), ShouldEqual, "@timestamp") - }) - }) + scriptFields, ok := b.customProps["script_fields"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, 0, len(scriptFields)) + + docValueFields, ok := b.customProps["docvalue_fields"].([]string) + require.True(t, ok) + require.Equal(t, 1, len(docValueFields)) + require.Equal(t, timeField, docValueFields[0]) + }) + + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) + + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) + + scriptFields, err := json.Get("script_fields").Map() + require.Nil(t, err) + require.Equal(t, 0, len(scriptFields)) + + _, err = json.Get("fields").StringArray() + require.Error(t, err) + + docValueFields, err := json.Get("docvalue_fields").StringArray() + require.Nil(t, err) + require.Equal(t, 1, len(docValueFields)) + require.Equal(t, timeField, docValueFields[0]) + }) + }) + }) + + t.Run("and adding multiple top level aggs", func(t *testing.T) { + b := setup() + aggBuilder := b.Agg() + aggBuilder.Terms("1", "@hostname", nil) + aggBuilder.DateHistogram("2", "@timestamp", nil) + + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) + + t.Run("Should have 2 top level aggs", func(t *testing.T) { + aggs := sr.Aggs + require.Equal(t, 2, len(aggs)) + require.Equal(t, "1", aggs[0].Key) + require.Equal(t, "terms", aggs[0].Aggregation.Type) + require.Equal(t, "2", aggs[1].Key) + require.Equal(t, "date_histogram", aggs[1].Aggregation.Type) }) - Convey("and adding two top level aggs with child agg", func() { - aggBuilder := b.Agg() - aggBuilder.Histogram("1", "@hostname", func(a *HistogramAgg, ib AggBuilder) { - ib.DateHistogram("2", "@timestamp", nil) - }) - aggBuilder.Filters("3", func(a *FiltersAggregation, ib AggBuilder) { - ib.Terms("4", "@test", nil) - }) + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) + require.Equal(t, 2, len(json.Get("aggs").MustMap())) + require.Equal(t, "@hostname", json.GetPath("aggs", "1", "terms", "field").MustString()) + require.Equal(t, "@timestamp", json.GetPath("aggs", "2", "date_histogram", "field").MustString()) + }) + }) + }) - Convey("Should have 2 top level aggs with one child agg each", func() { - aggs := sr.Aggs - So(aggs, ShouldHaveLength, 2) + t.Run("and adding top level agg with child agg", func(t *testing.T) { + b := setup() + aggBuilder := b.Agg() + aggBuilder.Terms("1", "@hostname", func(a *TermsAggregation, ib AggBuilder) { + ib.DateHistogram("2", "@timestamp", nil) + }) - topAggOne := aggs[0] - So(topAggOne.Key, ShouldEqual, "1") - So(topAggOne.Aggregation.Type, ShouldEqual, "histogram") - So(topAggOne.Aggregation.Aggs, ShouldHaveLength, 1) + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) - topAggOnechildAgg := topAggOne.Aggregation.Aggs[0] - So(topAggOnechildAgg.Key, ShouldEqual, "2") - So(topAggOnechildAgg.Aggregation.Type, ShouldEqual, "date_histogram") + t.Run("Should have 1 top level agg and one child agg", func(t *testing.T) { + aggs := sr.Aggs + require.Equal(t, 1, len(aggs)) - topAggTwo := aggs[1] - So(topAggTwo.Key, ShouldEqual, "3") - So(topAggTwo.Aggregation.Type, ShouldEqual, "filters") - So(topAggTwo.Aggregation.Aggs, ShouldHaveLength, 1) + topAgg := aggs[0] + require.Equal(t, "1", topAgg.Key) + require.Equal(t, "terms", topAgg.Aggregation.Type) + require.Equal(t, 1, len(topAgg.Aggregation.Aggs)) - topAggTwochildAgg := topAggTwo.Aggregation.Aggs[0] - So(topAggTwochildAgg.Key, ShouldEqual, "4") - So(topAggTwochildAgg.Aggregation.Type, ShouldEqual, "terms") - }) - - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) - - topAggOne := json.GetPath("aggs", "1") - So(topAggOne.GetPath("histogram", "field").MustString(), ShouldEqual, "@hostname") - topAggOnechildAgg := topAggOne.GetPath("aggs", "2") - So(topAggOnechildAgg.GetPath("date_histogram", "field").MustString(), ShouldEqual, "@timestamp") - - topAggTwo := json.GetPath("aggs", "3") - topAggTwochildAgg := topAggTwo.GetPath("aggs", "4") - So(topAggTwo.GetPath("filters").MustArray(), ShouldHaveLength, 0) - So(topAggTwochildAgg.GetPath("terms", "field").MustString(), ShouldEqual, "@test") - }) - }) + childAgg := aggs[0].Aggregation.Aggs[0] + require.Equal(t, "2", childAgg.Key) + require.Equal(t, "date_histogram", childAgg.Aggregation.Type) }) - Convey("and adding top level agg with child agg with child agg", func() { - aggBuilder := b.Agg() - aggBuilder.Terms("1", "@hostname", func(a *TermsAggregation, ib AggBuilder) { - ib.Terms("2", "@app", func(a *TermsAggregation, ib AggBuilder) { - ib.DateHistogram("3", "@timestamp", nil) - }) - }) + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) + require.Equal(t, 1, len(json.Get("aggs").MustMap())) + firstLevelAgg := json.GetPath("aggs", "1") + secondLevelAgg := firstLevelAgg.GetPath("aggs", "2") + require.Equal(t, "@hostname", firstLevelAgg.GetPath("terms", "field").MustString()) + require.Equal(t, "@timestamp", secondLevelAgg.GetPath("date_histogram", "field").MustString()) + }) + }) + }) - Convey("Should have 1 top level agg with one child having a child", func() { - aggs := sr.Aggs - So(aggs, ShouldHaveLength, 1) + t.Run("and adding two top level aggs with child agg", func(t *testing.T) { + b := setup() + aggBuilder := b.Agg() + aggBuilder.Histogram("1", "@hostname", func(a *HistogramAgg, ib AggBuilder) { + ib.DateHistogram("2", "@timestamp", nil) + }) + aggBuilder.Filters("3", func(a *FiltersAggregation, ib AggBuilder) { + ib.Terms("4", "@test", nil) + }) - topAgg := aggs[0] - So(topAgg.Key, ShouldEqual, "1") - So(topAgg.Aggregation.Type, ShouldEqual, "terms") - So(topAgg.Aggregation.Aggs, ShouldHaveLength, 1) + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) - childAgg := topAgg.Aggregation.Aggs[0] - So(childAgg.Key, ShouldEqual, "2") - So(childAgg.Aggregation.Type, ShouldEqual, "terms") + t.Run("Should have 2 top level aggs with one child agg each", func(t *testing.T) { + aggs := sr.Aggs + require.Equal(t, 2, len(aggs)) - childChildAgg := childAgg.Aggregation.Aggs[0] - So(childChildAgg.Key, ShouldEqual, "3") - So(childChildAgg.Aggregation.Type, ShouldEqual, "date_histogram") - }) + topAggOne := aggs[0] + require.Equal(t, "1", topAggOne.Key) + require.Equal(t, "histogram", topAggOne.Aggregation.Type) + require.Equal(t, 1, len(topAggOne.Aggregation.Aggs)) - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) + topAggOnechildAgg := topAggOne.Aggregation.Aggs[0] + require.Equal(t, "2", topAggOnechildAgg.Key) + require.Equal(t, "date_histogram", topAggOnechildAgg.Aggregation.Type) - topAgg := json.GetPath("aggs", "1") - So(topAgg.GetPath("terms", "field").MustString(), ShouldEqual, "@hostname") + topAggTwo := aggs[1] + require.Equal(t, "3", topAggTwo.Key) + require.Equal(t, "filters", topAggTwo.Aggregation.Type) + require.Equal(t, 1, len(topAggTwo.Aggregation.Aggs)) - childAgg := topAgg.GetPath("aggs", "2") - So(childAgg.GetPath("terms", "field").MustString(), ShouldEqual, "@app") - - childChildAgg := childAgg.GetPath("aggs", "3") - So(childChildAgg.GetPath("date_histogram", "field").MustString(), ShouldEqual, "@timestamp") - }) - }) + topAggTwochildAgg := topAggTwo.Aggregation.Aggs[0] + require.Equal(t, "4", topAggTwochildAgg.Key) + require.Equal(t, "terms", topAggTwochildAgg.Aggregation.Type) }) - Convey("and adding bucket and metric aggs", func() { - aggBuilder := b.Agg() - aggBuilder.Terms("1", "@hostname", func(a *TermsAggregation, ib AggBuilder) { - ib.Terms("2", "@app", func(a *TermsAggregation, ib AggBuilder) { - ib.Metric("4", "avg", "@value", nil) - ib.DateHistogram("3", "@timestamp", func(a *DateHistogramAgg, ib AggBuilder) { - ib.Metric("4", "avg", "@value", nil) - ib.Metric("5", "max", "@value", nil) - }) - }) - }) + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) + topAggOne := json.GetPath("aggs", "1") + require.Equal(t, "@hostname", topAggOne.GetPath("histogram", "field").MustString()) + topAggOnechildAgg := topAggOne.GetPath("aggs", "2") + require.Equal(t, "@timestamp", topAggOnechildAgg.GetPath("date_histogram", "field").MustString()) - Convey("Should have 1 top level agg with one child having a child", func() { - aggs := sr.Aggs - So(aggs, ShouldHaveLength, 1) + topAggTwo := json.GetPath("aggs", "3") + topAggTwochildAgg := topAggTwo.GetPath("aggs", "4") + require.Equal(t, 0, len(topAggTwo.GetPath("filters").MustArray())) + require.Equal(t, "@test", topAggTwochildAgg.GetPath("terms", "field").MustString()) + }) + }) + }) - topAgg := aggs[0] - So(topAgg.Key, ShouldEqual, "1") - So(topAgg.Aggregation.Type, ShouldEqual, "terms") - So(topAgg.Aggregation.Aggs, ShouldHaveLength, 1) + t.Run("and adding top level agg with child agg with child agg", func(t *testing.T) { + b := setup() + aggBuilder := b.Agg() + aggBuilder.Terms("1", "@hostname", func(a *TermsAggregation, ib AggBuilder) { + ib.Terms("2", "@app", func(a *TermsAggregation, ib AggBuilder) { + ib.DateHistogram("3", "@timestamp", nil) + }) + }) - childAgg := topAgg.Aggregation.Aggs[0] - So(childAgg.Key, ShouldEqual, "2") - So(childAgg.Aggregation.Type, ShouldEqual, "terms") + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) - childChildOneAgg := childAgg.Aggregation.Aggs[0] - So(childChildOneAgg.Key, ShouldEqual, "4") - So(childChildOneAgg.Aggregation.Type, ShouldEqual, "avg") + t.Run("Should have 1 top level agg with one child having a child", func(t *testing.T) { + aggs := sr.Aggs + require.Equal(t, 1, len(aggs)) - childChildTwoAgg := childAgg.Aggregation.Aggs[1] - So(childChildTwoAgg.Key, ShouldEqual, "3") - So(childChildTwoAgg.Aggregation.Type, ShouldEqual, "date_histogram") + topAgg := aggs[0] + require.Equal(t, "1", topAgg.Key) + require.Equal(t, "terms", topAgg.Aggregation.Type) + require.Equal(t, 1, len(topAgg.Aggregation.Aggs)) - childChildTwoChildOneAgg := childChildTwoAgg.Aggregation.Aggs[0] - So(childChildTwoChildOneAgg.Key, ShouldEqual, "4") - So(childChildTwoChildOneAgg.Aggregation.Type, ShouldEqual, "avg") + childAgg := topAgg.Aggregation.Aggs[0] + require.Equal(t, "2", childAgg.Key) + require.Equal(t, "terms", childAgg.Aggregation.Type) - childChildTwoChildTwoAgg := childChildTwoAgg.Aggregation.Aggs[1] - So(childChildTwoChildTwoAgg.Key, ShouldEqual, "5") - So(childChildTwoChildTwoAgg.Aggregation.Type, ShouldEqual, "max") - }) + childChildAgg := childAgg.Aggregation.Aggs[0] + require.Equal(t, "3", childChildAgg.Key) + require.Equal(t, "date_histogram", childChildAgg.Aggregation.Type) + }) - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) - termsAgg := json.GetPath("aggs", "1") - So(termsAgg.GetPath("terms", "field").MustString(), ShouldEqual, "@hostname") + topAgg := json.GetPath("aggs", "1") + require.Equal(t, "@hostname", topAgg.GetPath("terms", "field").MustString()) - termsAggTwo := termsAgg.GetPath("aggs", "2") - So(termsAggTwo.GetPath("terms", "field").MustString(), ShouldEqual, "@app") + childAgg := topAgg.GetPath("aggs", "2") + require.Equal(t, "@app", childAgg.GetPath("terms", "field").MustString()) - termsAggTwoAvg := termsAggTwo.GetPath("aggs", "4") - So(termsAggTwoAvg.GetPath("avg", "field").MustString(), ShouldEqual, "@value") + childChildAgg := childAgg.GetPath("aggs", "3") + require.Equal(t, "@timestamp", childChildAgg.GetPath("date_histogram", "field").MustString()) + }) + }) + }) - dateHistAgg := termsAggTwo.GetPath("aggs", "3") - So(dateHistAgg.GetPath("date_histogram", "field").MustString(), ShouldEqual, "@timestamp") - - avgAgg := dateHistAgg.GetPath("aggs", "4") - So(avgAgg.GetPath("avg", "field").MustString(), ShouldEqual, "@value") - - maxAgg := dateHistAgg.GetPath("aggs", "5") - So(maxAgg.GetPath("max", "field").MustString(), ShouldEqual, "@value") - }) + t.Run("and adding bucket and metric aggs", func(t *testing.T) { + b := setup() + aggBuilder := b.Agg() + aggBuilder.Terms("1", "@hostname", func(a *TermsAggregation, ib AggBuilder) { + ib.Terms("2", "@app", func(a *TermsAggregation, ib AggBuilder) { + ib.Metric("4", "avg", "@value", nil) + ib.DateHistogram("3", "@timestamp", func(a *DateHistogramAgg, ib AggBuilder) { + ib.Metric("4", "avg", "@value", nil) + ib.Metric("5", "max", "@value", nil) }) }) }) - Convey("Given new search request builder for es version 2", func() { - version2, _ := semver.NewVersion("2.0.0") - b := NewSearchRequestBuilder(version2, intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) - Convey("When adding doc value field", func() { - b.AddDocValueField(timeField) + t.Run("Should have 1 top level agg with one child having a child", func(t *testing.T) { + aggs := sr.Aggs + require.Equal(t, 1, len(aggs)) - Convey("should set correct props", func() { - fields, ok := b.customProps["fields"].([]string) - So(ok, ShouldBeTrue) - So(fields, ShouldHaveLength, 2) - So(fields[0], ShouldEqual, "*") - So(fields[1], ShouldEqual, "_source") + topAgg := aggs[0] + require.Equal(t, "1", topAgg.Key) + require.Equal(t, "terms", topAgg.Aggregation.Type) + require.Equal(t, 1, len(topAgg.Aggregation.Aggs)) - scriptFields, ok := b.customProps["script_fields"].(map[string]interface{}) - So(ok, ShouldBeTrue) - So(scriptFields, ShouldHaveLength, 0) + childAgg := topAgg.Aggregation.Aggs[0] + require.Equal(t, "2", childAgg.Key) + require.Equal(t, "terms", childAgg.Aggregation.Type) - fieldDataFields, ok := b.customProps["fielddata_fields"].([]string) - So(ok, ShouldBeTrue) - So(fieldDataFields, ShouldHaveLength, 1) - So(fieldDataFields[0], ShouldEqual, timeField) - }) + childChildOneAgg := childAgg.Aggregation.Aggs[0] + require.Equal(t, "4", childChildOneAgg.Key) + require.Equal(t, "avg", childChildOneAgg.Aggregation.Type) - Convey("When building search request", func() { - sr, err := b.Build() - So(err, ShouldBeNil) + childChildTwoAgg := childAgg.Aggregation.Aggs[1] + require.Equal(t, "3", childChildTwoAgg.Key) + require.Equal(t, "date_histogram", childChildTwoAgg.Aggregation.Type) - Convey("When marshal to JSON should generate correct json", func() { - body, err := json.Marshal(sr) - So(err, ShouldBeNil) - json, err := simplejson.NewJson(body) - So(err, ShouldBeNil) + childChildTwoChildOneAgg := childChildTwoAgg.Aggregation.Aggs[0] + require.Equal(t, "4", childChildTwoChildOneAgg.Key) + require.Equal(t, "avg", childChildTwoChildOneAgg.Aggregation.Type) - scriptFields, err := json.Get("script_fields").Map() - So(err, ShouldBeNil) - So(scriptFields, ShouldHaveLength, 0) + childChildTwoChildTwoAgg := childChildTwoAgg.Aggregation.Aggs[1] + require.Equal(t, "5", childChildTwoChildTwoAgg.Key) + require.Equal(t, "max", childChildTwoChildTwoAgg.Aggregation.Type) + }) - fields, err := json.Get("fields").StringArray() - So(err, ShouldBeNil) - So(fields, ShouldHaveLength, 2) - So(fields[0], ShouldEqual, "*") - So(fields[1], ShouldEqual, "_source") + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) - fieldDataFields, err := json.Get("fielddata_fields").StringArray() - So(err, ShouldBeNil) - So(fieldDataFields, ShouldHaveLength, 1) - So(fieldDataFields[0], ShouldEqual, timeField) - }) + termsAgg := json.GetPath("aggs", "1") + require.Equal(t, "@hostname", termsAgg.GetPath("terms", "field").MustString()) + + termsAggTwo := termsAgg.GetPath("aggs", "2") + require.Equal(t, "@app", termsAggTwo.GetPath("terms", "field").MustString()) + + termsAggTwoAvg := termsAggTwo.GetPath("aggs", "4") + require.Equal(t, "@value", termsAggTwoAvg.GetPath("avg", "field").MustString()) + + dateHistAgg := termsAggTwo.GetPath("aggs", "3") + require.Equal(t, "@timestamp", dateHistAgg.GetPath("date_histogram", "field").MustString()) + + avgAgg := dateHistAgg.GetPath("aggs", "4") + require.Equal(t, "@value", avgAgg.GetPath("avg", "field").MustString()) + + maxAgg := dateHistAgg.GetPath("aggs", "5") + require.Equal(t, "@value", maxAgg.GetPath("max", "field").MustString()) + }) + }) + }) + + t.Run("Given new search request builder for es version 2", func(t *testing.T) { + version2, _ := semver.NewVersion("2.0.0") + b := NewSearchRequestBuilder(version2, intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) + + t.Run("When adding doc value field", func(t *testing.T) { + b.AddDocValueField(timeField) + + t.Run("should set correct props", func(t *testing.T) { + fields, ok := b.customProps["fields"].([]string) + require.True(t, ok) + require.Equal(t, 2, len(fields)) + require.Equal(t, "*", fields[0]) + require.Equal(t, "_source", fields[1]) + + scriptFields, ok := b.customProps["script_fields"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, 0, len(scriptFields)) + + fieldDataFields, ok := b.customProps["fielddata_fields"].([]string) + require.True(t, ok) + require.Equal(t, 1, len(fieldDataFields)) + require.Equal(t, timeField, fieldDataFields[0]) + }) + + t.Run("When building search request", func(t *testing.T) { + sr, err := b.Build() + require.Nil(t, err) + + t.Run("When marshal to JSON should generate correct json", func(t *testing.T) { + body, err := json.Marshal(sr) + require.Nil(t, err) + json, err := simplejson.NewJson(body) + require.Nil(t, err) + + scriptFields, err := json.Get("script_fields").Map() + require.Nil(t, err) + require.Equal(t, 0, len(scriptFields)) + + fields, err := json.Get("fields").StringArray() + require.Nil(t, err) + require.Equal(t, 2, len(fields)) + require.Equal(t, "*", fields[0]) + require.Equal(t, "_source", fields[1]) + + fieldDataFields, err := json.Get("fielddata_fields").StringArray() + require.Nil(t, err) + require.Equal(t, 1, len(fieldDataFields)) + require.Equal(t, timeField, fieldDataFields[0]) }) }) }) @@ -446,31 +454,28 @@ func TestSearchRequest(t *testing.T) { } func TestMultiSearchRequest(t *testing.T) { - Convey("Test elasticsearch multi search request", t, func() { - Convey("Given new multi search request builder", func() { - version2, _ := semver.NewVersion("2.0.0") - b := NewMultiSearchRequestBuilder(version2) + t.Run("When adding one search request", func(t *testing.T) { + version2, _ := semver.NewVersion("2.0.0") + b := NewMultiSearchRequestBuilder(version2) + b.Search(intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) - Convey("When adding one search request", func() { - b.Search(intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) + t.Run("When building search request should contain one search request", func(t *testing.T) { + mr, err := b.Build() + require.Nil(t, err) + require.Equal(t, 1, len(mr.Requests)) + }) + }) - Convey("When building search request should contain one search request", func() { - mr, err := b.Build() - So(err, ShouldBeNil) - So(mr.Requests, ShouldHaveLength, 1) - }) - }) + t.Run("When adding two search requests", func(t *testing.T) { + version2, _ := semver.NewVersion("2.0.0") + b := NewMultiSearchRequestBuilder(version2) + b.Search(intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) + b.Search(intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) - Convey("When adding two search requests", func() { - b.Search(intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) - b.Search(intervalv2.Interval{Value: 15 * time.Second, Text: "15s"}) - - Convey("When building search request should contain two search requests", func() { - mr, err := b.Build() - So(err, ShouldBeNil) - So(mr.Requests, ShouldHaveLength, 2) - }) - }) + t.Run("When building search request should contain two search requests", func(t *testing.T) { + mr, err := b.Build() + require.Nil(t, err) + require.Equal(t, 2, len(mr.Requests)) }) }) } diff --git a/pkg/tsdb/mssql/macros_test.go b/pkg/tsdb/mssql/macros_test.go index 2b6e568cb76..76b2a8c0b23 100644 --- a/pkg/tsdb/mssql/macros_test.go +++ b/pkg/tsdb/mssql/macros_test.go @@ -8,206 +8,204 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) func TestMacroEngine(t *testing.T) { - Convey("MacroEngine", t, func() { - engine := &msSQLMacroEngine{} - query := &backend.DataQuery{ - JSON: []byte("{}"), + engine := &msSQLMacroEngine{} + query := &backend.DataQuery{ + JSON: []byte("{}"), + } + + dfltTimeRange := backend.TimeRange{} + + t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := backend.TimeRange{From: from, To: to} + + t.Run("interpolate __time function", func(t *testing.T) { + sql, err := engine.Interpolate(query, dfltTimeRange, "select $__time(time_column)") + require.Nil(t, err) + + require.Equal(t, "select time_column AS time", sql) + }) + + t.Run("interpolate __timeEpoch function", func(t *testing.T) { + sql, err := engine.Interpolate(query, dfltTimeRange, "select $__timeEpoch(time_column)") + require.Nil(t, err) + + require.Equal(t, "select DATEDIFF(second, '1970-01-01', time_column) AS time", sql) + }) + + t.Run("interpolate __timeEpoch function wrapped in aggregation", func(t *testing.T) { + sql, err := engine.Interpolate(query, dfltTimeRange, "select min($__timeEpoch(time_column))") + require.Nil(t, err) + + require.Equal(t, "select min(DATEDIFF(second, '1970-01-01', time_column) AS time)", sql) + }) + + t.Run("interpolate __timeFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql) + }) + + t.Run("interpolate __timeFrom function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()") + require.Nil(t, err) + + require.Equal(t, "select '2018-04-12T18:00:00Z'", sql) + }) + + t.Run("interpolate __timeTo function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()") + require.Nil(t, err) + + require.Equal(t, "select '2018-04-12T18:05:00Z'", sql) + }) + + t.Run("interpolate __timeGroup function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + require.Nil(t, err) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')") + require.Nil(t, err) + + require.Equal(t, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300", sql) + require.Equal(t, sql+" AS [time]", sql2) + }) + + t.Run("interpolate __timeGroup function with spaces around arguments", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") + require.Nil(t, err) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')") + require.Nil(t, err) + + require.Equal(t, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300", sql) + require.Equal(t, sql+" AS [time]", sql2) + }) + + t.Run("interpolate __timeGroup function with fill (value = NULL)", func(t *testing.T) { + _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', NULL)") + require.Nil(t, err) + queryJson, err := query.JSON.MarshalJSON() + require.Nil(t, err) + require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"null"}`, string(queryJson)) + }) + + t.Run("interpolate __timeGroup function with fill (value = previous)", func(t *testing.T) { + _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', previous)") + require.Nil(t, err) + queryJson, err := query.JSON.MarshalJSON() + require.Nil(t, err) + require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"previous"}`, string(queryJson)) + }) + + t.Run("interpolate __timeGroup function with fill (value = float)", func(t *testing.T) { + _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', 1.5)") + require.Nil(t, err) + queryJson, err := query.JSON.MarshalJSON() + require.Nil(t, err) + require.Equal(t, `{"fill":true,"fillInterval":300,"fillMode":"value","fillValue":1.5}`, string(queryJson)) + }) + + t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql) + }) + + t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql) + }) + t.Run("interpolate __unixEpochNanoFrom function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select %d", from.UnixNano()), sql) + }) + + t.Run("interpolate __unixEpochNanoTo function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select %d", to.UnixNano()), sql) + }) + + t.Run("interpolate __unixEpochGroup function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") + require.Nil(t, err) + sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')") + require.Nil(t, err) + + require.Equal(t, "SELECT FLOOR(time_column/300)*300", sql) + require.Equal(t, sql+" AS [time]", sql2) + }) + }) + + t.Run("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func(t *testing.T) { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := backend.TimeRange{ + From: from, + To: to, } - dfltTimeRange := backend.TimeRange{} + t.Run("interpolate __timeFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + require.Nil(t, err) - Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { - from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) - to := from.Add(5 * time.Minute) - timeRange := backend.TimeRange{From: from, To: to} - - Convey("interpolate __time function", func() { - sql, err := engine.Interpolate(query, dfltTimeRange, "select $__time(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select time_column AS time") - }) - - Convey("interpolate __timeEpoch function", func() { - sql, err := engine.Interpolate(query, dfltTimeRange, "select $__timeEpoch(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select DATEDIFF(second, '1970-01-01', time_column) AS time") - }) - - Convey("interpolate __timeEpoch function wrapped in aggregation", func() { - sql, err := engine.Interpolate(query, dfltTimeRange, "select min($__timeEpoch(time_column))") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select min(DATEDIFF(second, '1970-01-01', time_column) AS time)") - }) - - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) - }) - - Convey("interpolate __timeFrom function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select '2018-04-12T18:00:00Z'") - }) - - Convey("interpolate __timeTo function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select '2018-04-12T18:05:00Z'") - }) - - Convey("interpolate __timeGroup function", func() { - sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") - So(err, ShouldBeNil) - sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300") - So(sql2, ShouldEqual, sql+" AS [time]") - }) - - Convey("interpolate __timeGroup function with spaces around arguments", func() { - sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") - So(err, ShouldBeNil) - sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300") - So(sql2, ShouldEqual, sql+" AS [time]") - }) - - Convey("interpolate __timeGroup function with fill (value = NULL)", func() { - _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', NULL)") - So(err, ShouldBeNil) - queryJson, err := query.JSON.MarshalJSON() - So(err, ShouldBeNil) - So(string(queryJson), ShouldEqual, `{"fill":true,"fillInterval":300,"fillMode":"null"}`) - }) - - Convey("interpolate __timeGroup function with fill (value = previous)", func() { - _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', previous)") - So(err, ShouldBeNil) - queryJson, err := query.JSON.MarshalJSON() - So(err, ShouldBeNil) - So(string(queryJson), ShouldEqual, `{"fill":true,"fillInterval":300,"fillMode":"previous"}`) - }) - - Convey("interpolate __timeGroup function with fill (value = float)", func() { - _, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m', 1.5)") - So(err, ShouldBeNil) - queryJson, err := query.JSON.MarshalJSON() - So(err, ShouldBeNil) - So(string(queryJson), ShouldEqual, `{"fill":true,"fillInterval":300,"fillMode":"value","fillValue":1.5}`) - }) - - Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) - }) - - Convey("interpolate __unixEpochNanoFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano())) - }) - Convey("interpolate __unixEpochNanoFrom function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano())) - }) - - Convey("interpolate __unixEpochNanoTo function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano())) - }) - - Convey("interpolate __unixEpochGroup function", func() { - sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") - So(err, ShouldBeNil) - sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "SELECT FLOOR(time_column/300)*300") - So(sql2, ShouldEqual, sql+" AS [time]") - }) + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql) }) - Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { - from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) - to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC) - timeRange := backend.TimeRange{ - From: from, - To: to, - } + t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") + require.Nil(t, err) - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) - }) - - Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) - }) - - Convey("interpolate __unixEpochNanoFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano())) - }) + require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql) }) - Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { - from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) - to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC) - timeRange := backend.TimeRange{ - From: from, - To: to, - } + t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)") + require.Nil(t, err) - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) + require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql) + }) + }) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) - }) + t.Run("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func(t *testing.T) { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := backend.TimeRange{ + From: from, + To: to, + } - Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") - So(err, ShouldBeNil) + t.Run("interpolate __timeFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + require.Nil(t, err) - So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix())) - }) + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)), sql) + }) - Convey("interpolate __unixEpochNanoFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)") - So(err, ShouldBeNil) + t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)") + require.Nil(t, err) - So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano())) - }) + require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()), sql) + }) + + t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()), sql) }) }) } diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index c4546104cc6..543993731de 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -8,189 +8,187 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/infra/log" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/require" ) func TestMacroEngine(t *testing.T) { - Convey("MacroEngine", t, func() { - engine := &mySQLMacroEngine{ - logger: log.New("test"), + engine := &mySQLMacroEngine{ + logger: log.New("test"), + } + query := &backend.DataQuery{} + + t.Run("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func(t *testing.T) { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := backend.TimeRange{From: from, To: to} + + t.Run("interpolate __time function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__time(time_column)") + require.Nil(t, err) + + require.Equal(t, "select UNIX_TIMESTAMP(time_column) as time_sec", sql) + }) + + t.Run("interpolate __time function wrapped in aggregation", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select min($__time(time_column))") + require.Nil(t, err) + + require.Equal(t, "select min(UNIX_TIMESTAMP(time_column) as time_sec)", sql) + }) + + t.Run("interpolate __timeGroup function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + require.Nil(t, err) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')") + require.Nil(t, err) + + require.Equal(t, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300", sql) + require.Equal(t, sql+" AS \"time\"", sql2) + }) + + t.Run("interpolate __timeGroup function with spaces around arguments", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") + require.Nil(t, err) + sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')") + require.Nil(t, err) + + require.Equal(t, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300", sql) + require.Equal(t, sql+" AS \"time\"", sql2) + }) + + t.Run("interpolate __timeFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()), sql) + }) + + t.Run("interpolate __timeFrom function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix()), sql) + }) + + t.Run("interpolate __timeTo function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix()), sql) + }) + + t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()), sql) + }) + + t.Run("interpolate __unixEpochNanoFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()), sql) + }) + + t.Run("interpolate __unixEpochNanoFrom function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select %d", from.UnixNano()), sql) + }) + + t.Run("interpolate __unixEpochNanoTo function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()") + require.Nil(t, err) + + require.Equal(t, fmt.Sprintf("select %d", to.UnixNano()), sql) + }) + + t.Run("interpolate __unixEpochGroup function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") + require.Nil(t, err) + sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')") + require.Nil(t, err) + + require.Equal(t, "SELECT time_column DIV 300 * 300", sql) + require.Equal(t, sql+" AS \"time\"", sql2) + }) + }) + + t.Run("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func(t *testing.T) { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := backend.TimeRange{ + From: from, + To: to, } - query := &backend.DataQuery{} - Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { - from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) - to := from.Add(5 * time.Minute) - timeRange := backend.TimeRange{From: from, To: to} + t.Run("interpolate __timeFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + require.Nil(t, err) - Convey("interpolate __time function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__time(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select UNIX_TIMESTAMP(time_column) as time_sec") - }) - - Convey("interpolate __time function wrapped in aggregation", func() { - sql, err := engine.Interpolate(query, timeRange, "select min($__time(time_column))") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "select min(UNIX_TIMESTAMP(time_column) as time_sec)") - }) - - Convey("interpolate __timeGroup function", func() { - sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')") - So(err, ShouldBeNil) - sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column,'5m')") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300") - So(sql2, ShouldEqual, sql+" AS \"time\"") - }) - - Convey("interpolate __timeGroup function with spaces around arguments", func() { - sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')") - So(err, ShouldBeNil) - sql2, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroupAlias(time_column , '5m')") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300") - So(sql2, ShouldEqual, sql+" AS \"time\"") - }) - - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) - }) - - Convey("interpolate __timeFrom function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix())) - }) - - Convey("interpolate __timeTo function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__timeTo()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix())) - }) - - Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) - }) - - Convey("interpolate __unixEpochNanoFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano())) - }) - - Convey("interpolate __unixEpochNanoFrom function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano())) - }) - - Convey("interpolate __unixEpochNanoTo function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano())) - }) - - Convey("interpolate __unixEpochGroup function", func() { - sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')") - So(err, ShouldBeNil) - sql2, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroupAlias(time_column,'5m')") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, "SELECT time_column DIV 300 * 300") - So(sql2, ShouldEqual, sql+" AS \"time\"") - }) + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()), sql) }) - Convey("Given a time range between 1960-02-01 07:00 and 1965-02-03 08:00", func() { - from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) - to := time.Date(1965, 2, 3, 8, 0, 0, 0, time.UTC) - timeRange := backend.TimeRange{ - From: from, - To: to, - } + t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + require.Nil(t, err) - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) + require.Equal(t, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()), sql) + }) + }) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) - }) + t.Run("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func(t *testing.T) { + from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) + to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC) + timeRange := backend.TimeRange{ + From: from, + To: to, + } - Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") - So(err, ShouldBeNil) + t.Run("interpolate __timeFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") + require.Nil(t, err) - So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) - }) + require.Equal(t, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()), sql) }) - Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() { - from := time.Date(1960, 2, 1, 7, 0, 0, 0, time.UTC) - to := time.Date(1980, 2, 3, 8, 0, 0, 0, time.UTC) - timeRange := backend.TimeRange{ - From: from, - To: to, - } + t.Run("interpolate __unixEpochFilter function", func(t *testing.T) { + sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") + require.Nil(t, err) - Convey("interpolate __timeFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) - }) - - Convey("interpolate __unixEpochFilter function", func() { - sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)") - So(err, ShouldBeNil) - - So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix())) - }) + require.Equal(t, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()), sql) }) + }) - Convey("Given queries that contains unallowed user functions", func() { - tcs := []string{ - "select \nSESSION_USER(), abc", - "SELECT session_User( ) ", - "SELECT session_User( )\n", - "SELECT current_user", - "SELECT current_USER", - "SELECT current_user()", - "SELECT Current_User()", - "SELECT current_user( )", - "SELECT current_user(\t )", - "SELECT user()", - "SELECT USER()", - "SELECT SYSTEM_USER()", - "SELECT System_User()", - "SELECT System_User( )", - "SELECT System_User(\t \t)", - "SHOW \t grants", - " show Grants\n", - "show grants;", - } + t.Run("Given queries that contains unallowed user functions", func(t *testing.T) { + tcs := []string{ + "select \nSESSION_USER(), abc", + "SELECT session_User( ) ", + "SELECT session_User( )\n", + "SELECT current_user", + "SELECT current_USER", + "SELECT current_user()", + "SELECT Current_User()", + "SELECT current_user( )", + "SELECT current_user(\t )", + "SELECT user()", + "SELECT USER()", + "SELECT SYSTEM_USER()", + "SELECT System_User()", + "SELECT System_User( )", + "SELECT System_User(\t \t)", + "SHOW \t grants", + " show Grants\n", + "show grants;", + } - for _, tc := range tcs { - _, err := engine.Interpolate(&backend.DataQuery{}, backend.TimeRange{}, tc) - So(err.Error(), ShouldEqual, "invalid query - inspect Grafana server log for details") - } - }) + for _, tc := range tcs { + _, err := engine.Interpolate(&backend.DataQuery{}, backend.TimeRange{}, tc) + require.Equal(t, "invalid query - inspect Grafana server log for details", err.Error()) + } }) } diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 5366b1a2339..ef9ad6fcc77 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -5,37 +5,19 @@ import ( "encoding/json" "errors" "fmt" - "math" "regexp" - "sort" - "strconv" - "strings" - "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/tsdb/intervalv2" - "github.com/opentracing/opentracing-go" "github.com/prometheus/client_golang/api" apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" - "github.com/prometheus/common/model" -) - -// Internal interval and range variables -const ( - varInterval = "$__interval" - varIntervalMs = "$__interval_ms" - varRange = "$__range" - varRangeS = "$__range_s" - varRangeMs = "$__range_ms" - varRateInterval = "$__rate_interval" ) var ( @@ -116,87 +98,26 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst } } -//nolint: staticcheck // plugins.DataResponse deprecated func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { if len(req.Queries) == 0 { return &backend.QueryDataResponse{}, fmt.Errorf("query contains no queries") } + q := req.Queries[0] dsInfo, err := s.getDSInfo(req.PluginContext) if err != nil { return nil, err } - client := dsInfo.promClient - - result := backend.QueryDataResponse{ - Responses: backend.Responses{}, + var result *backend.QueryDataResponse + switch q.QueryType { + case "timeSeriesQuery": + fallthrough + default: + result, err = s.executeTimeSeriesQuery(ctx, req, dsInfo) } - queries, err := s.parseQuery(req, dsInfo) - if err != nil { - return &result, err - } - - for _, query := range queries { - plog.Debug("Sending query", "start", query.Start, "end", query.End, "step", query.Step, "query", query.Expr) - - span, ctx := opentracing.StartSpanFromContext(ctx, "datasource.prometheus") - span.SetTag("expr", query.Expr) - span.SetTag("start_unixnano", query.Start.UnixNano()) - span.SetTag("stop_unixnano", query.End.UnixNano()) - defer span.Finish() - - response := make(map[PrometheusQueryType]interface{}) - - timeRange := apiv1.Range{ - Step: query.Step, - // Align query range to step. It rounds start and end down to a multiple of step. - Start: time.Unix(int64(math.Floor((float64(query.Start.Unix()+query.UtcOffsetSec)/query.Step.Seconds()))*query.Step.Seconds()-float64(query.UtcOffsetSec)), 0), - End: time.Unix(int64(math.Floor((float64(query.End.Unix()+query.UtcOffsetSec)/query.Step.Seconds()))*query.Step.Seconds()-float64(query.UtcOffsetSec)), 0), - } - - if query.RangeQuery { - rangeResponse, _, err := client.QueryRange(ctx, query.Expr, timeRange) - if err != nil { - plog.Error("Range query", query.Expr, "failed with", err) - result.Responses[query.RefId] = backend.DataResponse{Error: err} - } else { - response[RangeQueryType] = rangeResponse - } - } - - if query.InstantQuery { - instantResponse, _, err := client.Query(ctx, query.Expr, query.End) - if err != nil { - plog.Error("Instant query", query.Expr, "failed with", err) - result.Responses[query.RefId] = backend.DataResponse{Error: err} - } else { - response[InstantQueryType] = instantResponse - } - } - - if query.ExemplarQuery { - exemplarResponse, err := client.QueryExemplars(ctx, query.Expr, timeRange.Start, timeRange.End) - if err != nil { - plog.Error("Exemplar query", query.Expr, "failed with", err) - result.Responses[query.RefId] = backend.DataResponse{Error: err} - } else { - response[ExemplarQueryType] = exemplarResponse - } - } - - frames, err := parseResponse(response, query) - if err != nil { - return &result, err - } - - result.Responses[query.RefId] = backend.DataResponse{ - Frames: frames, - } - } - - return &result, nil + return result, err } func createClient(url string, httpOpts sdkhttpclient.Options, clientProvider httpclient.Provider) (apiv1.API, error) { @@ -232,137 +153,6 @@ func (s *Service) getDSInfo(pluginCtx backend.PluginContext) (*DatasourceInfo, e return &instance, nil } -func formatLegend(metric model.Metric, query *PrometheusQuery) string { - var legend string - - if query.LegendFormat == "" { - legend = metric.String() - } else { - result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { - labelName := strings.Replace(string(in), "{{", "", 1) - labelName = strings.Replace(labelName, "}}", "", 1) - labelName = strings.TrimSpace(labelName) - if val, exists := metric[model.LabelName(labelName)]; exists { - return []byte(val) - } - return []byte{} - }) - legend = string(result) - } - - // If legend is empty brackets, use query expression - if legend == "{}" { - legend = query.Expr - } - - return legend -} - -func (s *Service) parseQuery(queryContext *backend.QueryDataRequest, dsInfo *DatasourceInfo) ([]*PrometheusQuery, error) { - qs := []*PrometheusQuery{} - for _, query := range queryContext.Queries { - model := &QueryModel{} - err := json.Unmarshal(query.JSON, model) - if err != nil { - return nil, err - } - //Final interval value - var interval time.Duration - - //Calculate interval - queryInterval := model.Interval - //If we are using variable or interval/step, we will replace it with calculated interval - if queryInterval == varInterval || queryInterval == varIntervalMs || queryInterval == varRateInterval { - queryInterval = "" - } - minInterval, err := intervalv2.GetIntervalFrom(dsInfo.TimeInterval, queryInterval, model.IntervalMS, 15*time.Second) - if err != nil { - return nil, err - } - - calculatedInterval := s.intervalCalculator.Calculate(query.TimeRange, minInterval, query.MaxDataPoints) - safeInterval := s.intervalCalculator.CalculateSafeInterval(query.TimeRange, int64(safeRes)) - adjustedInterval := safeInterval.Value - - if calculatedInterval.Value > safeInterval.Value { - adjustedInterval = calculatedInterval.Value - } - - if queryInterval == varRateInterval { - // Rate interval is final and is not affected by resolution - interval = calculateRateInterval(adjustedInterval, dsInfo.TimeInterval, s.intervalCalculator) - } else { - intervalFactor := model.IntervalFactor - if intervalFactor == 0 { - intervalFactor = 1 - } - interval = time.Duration(int64(adjustedInterval) * intervalFactor) - } - - intervalMs := int64(interval / time.Millisecond) - rangeS := query.TimeRange.To.Unix() - query.TimeRange.From.Unix() - - // Interpolate variables in expr - expr := model.Expr - expr = strings.ReplaceAll(expr, varIntervalMs, strconv.FormatInt(intervalMs, 10)) - expr = strings.ReplaceAll(expr, varInterval, intervalv2.FormatDuration(interval)) - expr = strings.ReplaceAll(expr, varRangeMs, strconv.FormatInt(rangeS*1000, 10)) - expr = strings.ReplaceAll(expr, varRangeS, strconv.FormatInt(rangeS, 10)) - expr = strings.ReplaceAll(expr, varRange, strconv.FormatInt(rangeS, 10)+"s") - expr = strings.ReplaceAll(expr, varRateInterval, intervalv2.FormatDuration(calculateRateInterval(interval, dsInfo.TimeInterval, s.intervalCalculator))) - - rangeQuery := model.RangeQuery - if !model.InstantQuery && !model.RangeQuery { - // In older dashboards, we were not setting range query param and !range && !instant was run as range query - rangeQuery = true - } - - qs = append(qs, &PrometheusQuery{ - Expr: expr, - Step: interval, - LegendFormat: model.LegendFormat, - Start: query.TimeRange.From, - End: query.TimeRange.To, - RefId: query.RefID, - InstantQuery: model.InstantQuery, - RangeQuery: rangeQuery, - ExemplarQuery: model.ExemplarQuery, - UtcOffsetSec: model.UtcOffsetSec, - }) - } - return qs, nil -} - -func parseResponse(value map[PrometheusQueryType]interface{}, query *PrometheusQuery) (data.Frames, error) { - var ( - frames = data.Frames{} - nextFrames = data.Frames{} - ) - - for _, value := range value { - // Zero out the slice to prevent data corruption. - nextFrames = nextFrames[:0] - - switch v := value.(type) { - case model.Matrix: - nextFrames = matrixToDataFrames(v, query, nextFrames) - case model.Vector: - nextFrames = vectorToDataFrames(v, query, nextFrames) - case *model.Scalar: - nextFrames = scalarToDataFrames(v, query, nextFrames) - case []apiv1.ExemplarQueryResult: - nextFrames = exemplarToDataFrames(v, query, nextFrames) - default: - plog.Error("Query", query.Expr, "returned unexpected result type", v) - continue - } - - frames = append(frames, nextFrames...) - } - - return frames, nil -} - // IsAPIError returns whether err is or wraps a Prometheus error. func IsAPIError(err error) bool { // Check if the right error type is in err's chain. @@ -377,230 +167,3 @@ func ConvertAPIError(err error) error { } return err } - -func calculateRateInterval(interval time.Duration, scrapeInterval string, intervalCalculator intervalv2.Calculator) time.Duration { - scrape := scrapeInterval - if scrape == "" { - scrape = "15s" - } - - scrapeIntervalDuration, err := intervalv2.ParseIntervalStringToTimeDuration(scrape) - if err != nil { - return time.Duration(0) - } - - rateInterval := time.Duration(int(math.Max(float64(interval+scrapeIntervalDuration), float64(4)*float64(scrapeIntervalDuration)))) - return rateInterval -} - -func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data.Frames) data.Frames { - for _, v := range matrix { - tags := make(map[string]string, len(v.Metric)) - for k, v := range v.Metric { - tags[string(k)] = string(v) - } - - timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(v.Values)) - valueField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(v.Values)) - - for i, k := range v.Values { - timeField.Set(i, time.Unix(k.Timestamp.Unix(), 0).UTC()) - value := float64(k.Value) - if !math.IsNaN(value) { - valueField.Set(i, &value) - } - } - - name := formatLegend(v.Metric, query) - timeField.Name = data.TimeSeriesTimeFieldName - valueField.Name = data.TimeSeriesValueFieldName - valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} - valueField.Labels = tags - - frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) - } - - return frames -} - -func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery, frames data.Frames) data.Frames { - timeVector := []time.Time{time.Unix(scalar.Timestamp.Unix(), 0).UTC()} - values := []float64{float64(scalar.Value)} - name := fmt.Sprintf("%g", values[0]) - - return append( - frames, - newDataFrame( - name, - "scalar", - data.NewField("Time", nil, timeVector), - data.NewField("Value", nil, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}), - ), - ) -} - -func vectorToDataFrames(vector model.Vector, query *PrometheusQuery, frames data.Frames) data.Frames { - for _, v := range vector { - name := formatLegend(v.Metric, query) - tags := make(map[string]string, len(v.Metric)) - timeVector := []time.Time{time.Unix(v.Timestamp.Unix(), 0).UTC()} - values := []float64{float64(v.Value)} - - for k, v := range v.Metric { - tags[string(k)] = string(v) - } - - frames = append( - frames, - newDataFrame( - name, - "vector", - data.NewField("Time", nil, timeVector), - data.NewField("Value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}), - ), - ) - } - - return frames -} - -func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *PrometheusQuery, frames data.Frames) data.Frames { - // TODO: this preallocation is very naive. - // We should figure out a better approximation here. - events := make([]ExemplarEvent, 0, len(response)*2) - - for _, exemplarData := range response { - for _, exemplar := range exemplarData.Exemplars { - event := ExemplarEvent{} - exemplarTime := time.Unix(exemplar.Timestamp.Unix(), 0).UTC() - event.Time = exemplarTime - event.Value = float64(exemplar.Value) - event.Labels = make(map[string]string) - - for label, value := range exemplar.Labels { - event.Labels[string(label)] = string(value) - } - - for seriesLabel, seriesValue := range exemplarData.SeriesLabels { - event.Labels[string(seriesLabel)] = string(seriesValue) - } - - events = append(events, event) - } - } - - // Sampling of exemplars - bucketedExemplars := make(map[string][]ExemplarEvent) - values := make([]float64, 0, len(events)) - - // Create bucketed exemplars based on aligned timestamp - for _, event := range events { - alignedTs := fmt.Sprintf("%.0f", math.Floor(float64(event.Time.Unix())/query.Step.Seconds())*query.Step.Seconds()) - _, ok := bucketedExemplars[alignedTs] - if !ok { - bucketedExemplars[alignedTs] = make([]ExemplarEvent, 0) - } - - bucketedExemplars[alignedTs] = append(bucketedExemplars[alignedTs], event) - values = append(values, event.Value) - } - - // Calculate standard deviation - standardDeviation := deviation(values) - - // Create slice with all of the bucketed exemplars - sampledBuckets := make([]string, len(bucketedExemplars)) - for bucketTimes := range bucketedExemplars { - sampledBuckets = append(sampledBuckets, bucketTimes) - } - sort.Strings(sampledBuckets) - - // Sample exemplars based ona value, so we are not showing too many of them - sampleExemplars := make([]ExemplarEvent, 0, len(sampledBuckets)) - for _, bucket := range sampledBuckets { - exemplarsInBucket := bucketedExemplars[bucket] - if len(exemplarsInBucket) == 1 { - sampleExemplars = append(sampleExemplars, exemplarsInBucket[0]) - } else { - bucketValues := make([]float64, len(exemplarsInBucket)) - for _, exemplar := range exemplarsInBucket { - bucketValues = append(bucketValues, exemplar.Value) - } - sort.Slice(bucketValues, func(i, j int) bool { - return bucketValues[i] > bucketValues[j] - }) - - sampledBucketValues := make([]float64, 0) - for _, value := range bucketValues { - if len(sampledBucketValues) == 0 { - sampledBucketValues = append(sampledBucketValues, value) - } else { - // Then take values only when at least 2 standard deviation distance to previously taken value - prev := sampledBucketValues[len(sampledBucketValues)-1] - if standardDeviation != 0 && prev-value >= float64(2)*standardDeviation { - sampledBucketValues = append(sampledBucketValues, value) - } - } - } - for _, valueBucket := range sampledBucketValues { - for _, exemplar := range exemplarsInBucket { - if exemplar.Value == valueBucket { - sampleExemplars = append(sampleExemplars, exemplar) - } - } - } - } - } - - // Create DF from sampled exemplars - timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(sampleExemplars)) - timeField.Name = "Time" - valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, len(sampleExemplars)) - valueField.Name = "Value" - labelsVector := make(map[string][]string, len(sampleExemplars)) - - for i, exemplar := range sampleExemplars { - timeField.Set(i, exemplar.Time) - valueField.Set(i, exemplar.Value) - - for label, value := range exemplar.Labels { - if labelsVector[label] == nil { - labelsVector[label] = make([]string, 0) - } - - labelsVector[label] = append(labelsVector[label], value) - } - } - - dataFields := make([]*data.Field, 0, len(labelsVector)+2) - dataFields = append(dataFields, timeField, valueField) - for label, vector := range labelsVector { - dataFields = append(dataFields, data.NewField(label, nil, vector)) - } - - return append(frames, newDataFrame("exemplar", "exemplar", dataFields...)) -} - -func deviation(values []float64) float64 { - var sum, mean, sd float64 - valuesLen := float64(len(values)) - for _, value := range values { - sum += value - } - mean = sum / valuesLen - for j := 0; j < len(values); j++ { - sd += math.Pow(values[j]-mean, 2) - } - return math.Sqrt(sd / (valuesLen - 1)) -} - -func newDataFrame(name string, typ string, fields ...*data.Field) *data.Frame { - frame := data.NewFrame(name, fields...) - frame.Meta = &data.FrameMeta{ - Custom: map[string]string{ - "resultType": typ, - }, - } - - return frame -} diff --git a/pkg/tsdb/prometheus/time_series_query.go b/pkg/tsdb/prometheus/time_series_query.go new file mode 100644 index 00000000000..bcee14f4f88 --- /dev/null +++ b/pkg/tsdb/prometheus/time_series_query.go @@ -0,0 +1,468 @@ +package prometheus + +import ( + "context" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/tsdb/intervalv2" + "github.com/opentracing/opentracing-go" + apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" +) + +// Internal interval and range variables +const ( + varInterval = "$__interval" + varIntervalMs = "$__interval_ms" + varRange = "$__range" + varRangeS = "$__range_s" + varRangeMs = "$__range_ms" + varRateInterval = "$__rate_interval" +) + +type TimeSeriesQueryType string + +const ( + RangeQueryType TimeSeriesQueryType = "range" + InstantQueryType TimeSeriesQueryType = "instant" + ExemplarQueryType TimeSeriesQueryType = "exemplar" +) + +func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo *DatasourceInfo) (*backend.QueryDataResponse, error) { + client := dsInfo.promClient + + result := backend.QueryDataResponse{ + Responses: backend.Responses{}, + } + + queries, err := s.parseTimeSeriesQuery(req, dsInfo) + if err != nil { + return &result, err + } + + for _, query := range queries { + plog.Debug("Sending query", "start", query.Start, "end", query.End, "step", query.Step, "query", query.Expr) + + span, ctx := opentracing.StartSpanFromContext(ctx, "datasource.prometheus") + span.SetTag("expr", query.Expr) + span.SetTag("start_unixnano", query.Start.UnixNano()) + span.SetTag("stop_unixnano", query.End.UnixNano()) + defer span.Finish() + + response := make(map[TimeSeriesQueryType]interface{}) + + timeRange := apiv1.Range{ + Step: query.Step, + // Align query range to step. It rounds start and end down to a multiple of step. + Start: time.Unix(int64(math.Floor((float64(query.Start.Unix()+query.UtcOffsetSec)/query.Step.Seconds()))*query.Step.Seconds()-float64(query.UtcOffsetSec)), 0), + End: time.Unix(int64(math.Floor((float64(query.End.Unix()+query.UtcOffsetSec)/query.Step.Seconds()))*query.Step.Seconds()-float64(query.UtcOffsetSec)), 0), + } + + if query.RangeQuery { + rangeResponse, _, err := client.QueryRange(ctx, query.Expr, timeRange) + if err != nil { + plog.Error("Range query", query.Expr, "failed with", err) + result.Responses[query.RefId] = backend.DataResponse{Error: err} + } else { + response[RangeQueryType] = rangeResponse + } + } + + if query.InstantQuery { + instantResponse, _, err := client.Query(ctx, query.Expr, query.End) + if err != nil { + plog.Error("Instant query", query.Expr, "failed with", err) + result.Responses[query.RefId] = backend.DataResponse{Error: err} + } else { + response[InstantQueryType] = instantResponse + } + } + + if query.ExemplarQuery { + exemplarResponse, err := client.QueryExemplars(ctx, query.Expr, timeRange.Start, timeRange.End) + if err != nil { + plog.Error("Exemplar query", query.Expr, "failed with", err) + result.Responses[query.RefId] = backend.DataResponse{Error: err} + } else { + response[ExemplarQueryType] = exemplarResponse + } + } + + frames, err := parseTimeSeriesResponse(response, query) + if err != nil { + return &result, err + } + + result.Responses[query.RefId] = backend.DataResponse{ + Frames: frames, + } + } + + return &result, nil +} + +func formatLegend(metric model.Metric, query *PrometheusQuery) string { + var legend string + + if query.LegendFormat == "" { + legend = metric.String() + } else { + result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { + labelName := strings.Replace(string(in), "{{", "", 1) + labelName = strings.Replace(labelName, "}}", "", 1) + labelName = strings.TrimSpace(labelName) + if val, exists := metric[model.LabelName(labelName)]; exists { + return []byte(val) + } + return []byte{} + }) + legend = string(result) + } + + // If legend is empty brackets, use query expression + if legend == "{}" { + legend = query.Expr + } + + return legend +} + +func (s *Service) parseTimeSeriesQuery(queryContext *backend.QueryDataRequest, dsInfo *DatasourceInfo) ([]*PrometheusQuery, error) { + qs := []*PrometheusQuery{} + for _, query := range queryContext.Queries { + model := &QueryModel{} + err := json.Unmarshal(query.JSON, model) + if err != nil { + return nil, err + } + //Final interval value + var interval time.Duration + + //Calculate interval + queryInterval := model.Interval + //If we are using variable or interval/step, we will replace it with calculated interval + if queryInterval == varInterval || queryInterval == varIntervalMs || queryInterval == varRateInterval { + queryInterval = "" + } + minInterval, err := intervalv2.GetIntervalFrom(dsInfo.TimeInterval, queryInterval, model.IntervalMS, 15*time.Second) + if err != nil { + return nil, err + } + + calculatedInterval := s.intervalCalculator.Calculate(query.TimeRange, minInterval, query.MaxDataPoints) + safeInterval := s.intervalCalculator.CalculateSafeInterval(query.TimeRange, int64(safeRes)) + adjustedInterval := safeInterval.Value + + if calculatedInterval.Value > safeInterval.Value { + adjustedInterval = calculatedInterval.Value + } + + if queryInterval == varRateInterval { + // Rate interval is final and is not affected by resolution + interval = calculateRateInterval(adjustedInterval, dsInfo.TimeInterval, s.intervalCalculator) + } else { + intervalFactor := model.IntervalFactor + if intervalFactor == 0 { + intervalFactor = 1 + } + interval = time.Duration(int64(adjustedInterval) * intervalFactor) + } + + intervalMs := int64(interval / time.Millisecond) + rangeS := query.TimeRange.To.Unix() - query.TimeRange.From.Unix() + + // Interpolate variables in expr + expr := model.Expr + expr = strings.ReplaceAll(expr, varIntervalMs, strconv.FormatInt(intervalMs, 10)) + expr = strings.ReplaceAll(expr, varInterval, intervalv2.FormatDuration(interval)) + expr = strings.ReplaceAll(expr, varRangeMs, strconv.FormatInt(rangeS*1000, 10)) + expr = strings.ReplaceAll(expr, varRangeS, strconv.FormatInt(rangeS, 10)) + expr = strings.ReplaceAll(expr, varRange, strconv.FormatInt(rangeS, 10)+"s") + expr = strings.ReplaceAll(expr, varRateInterval, intervalv2.FormatDuration(calculateRateInterval(interval, dsInfo.TimeInterval, s.intervalCalculator))) + + rangeQuery := model.RangeQuery + if !model.InstantQuery && !model.RangeQuery { + // In older dashboards, we were not setting range query param and !range && !instant was run as range query + rangeQuery = true + } + + qs = append(qs, &PrometheusQuery{ + Expr: expr, + Step: interval, + LegendFormat: model.LegendFormat, + Start: query.TimeRange.From, + End: query.TimeRange.To, + RefId: query.RefID, + InstantQuery: model.InstantQuery, + RangeQuery: rangeQuery, + ExemplarQuery: model.ExemplarQuery, + UtcOffsetSec: model.UtcOffsetSec, + }) + } + return qs, nil +} + +func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *PrometheusQuery) (data.Frames, error) { + var ( + frames = data.Frames{} + nextFrames = data.Frames{} + ) + + for _, value := range value { + // Zero out the slice to prevent data corruption. + nextFrames = nextFrames[:0] + + switch v := value.(type) { + case model.Matrix: + nextFrames = matrixToDataFrames(v, query, nextFrames) + case model.Vector: + nextFrames = vectorToDataFrames(v, query, nextFrames) + case *model.Scalar: + nextFrames = scalarToDataFrames(v, query, nextFrames) + case []apiv1.ExemplarQueryResult: + nextFrames = exemplarToDataFrames(v, query, nextFrames) + default: + plog.Error("Query", query.Expr, "returned unexpected result type", v) + continue + } + + frames = append(frames, nextFrames...) + } + + return frames, nil +} + +func calculateRateInterval(interval time.Duration, scrapeInterval string, intervalCalculator intervalv2.Calculator) time.Duration { + scrape := scrapeInterval + if scrape == "" { + scrape = "15s" + } + + scrapeIntervalDuration, err := intervalv2.ParseIntervalStringToTimeDuration(scrape) + if err != nil { + return time.Duration(0) + } + + rateInterval := time.Duration(int(math.Max(float64(interval+scrapeIntervalDuration), float64(4)*float64(scrapeIntervalDuration)))) + return rateInterval +} + +func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data.Frames) data.Frames { + for _, v := range matrix { + tags := make(map[string]string, len(v.Metric)) + for k, v := range v.Metric { + tags[string(k)] = string(v) + } + + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(v.Values)) + valueField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(v.Values)) + + for i, k := range v.Values { + timeField.Set(i, time.Unix(k.Timestamp.Unix(), 0).UTC()) + value := float64(k.Value) + if !math.IsNaN(value) { + valueField.Set(i, &value) + } + } + + name := formatLegend(v.Metric, query) + timeField.Name = data.TimeSeriesTimeFieldName + valueField.Name = data.TimeSeriesValueFieldName + valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} + valueField.Labels = tags + + frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) + } + + return frames +} + +func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery, frames data.Frames) data.Frames { + timeVector := []time.Time{time.Unix(scalar.Timestamp.Unix(), 0).UTC()} + values := []float64{float64(scalar.Value)} + name := fmt.Sprintf("%g", values[0]) + + return append( + frames, + newDataFrame( + name, + "scalar", + data.NewField("Time", nil, timeVector), + data.NewField("Value", nil, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}), + ), + ) +} + +func vectorToDataFrames(vector model.Vector, query *PrometheusQuery, frames data.Frames) data.Frames { + for _, v := range vector { + name := formatLegend(v.Metric, query) + tags := make(map[string]string, len(v.Metric)) + timeVector := []time.Time{time.Unix(v.Timestamp.Unix(), 0).UTC()} + values := []float64{float64(v.Value)} + + for k, v := range v.Metric { + tags[string(k)] = string(v) + } + + frames = append( + frames, + newDataFrame( + name, + "vector", + data.NewField("Time", nil, timeVector), + data.NewField("Value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}), + ), + ) + } + + return frames +} + +func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *PrometheusQuery, frames data.Frames) data.Frames { + // TODO: this preallocation is very naive. + // We should figure out a better approximation here. + events := make([]ExemplarEvent, 0, len(response)*2) + + for _, exemplarData := range response { + for _, exemplar := range exemplarData.Exemplars { + event := ExemplarEvent{} + exemplarTime := time.Unix(exemplar.Timestamp.Unix(), 0).UTC() + event.Time = exemplarTime + event.Value = float64(exemplar.Value) + event.Labels = make(map[string]string) + + for label, value := range exemplar.Labels { + event.Labels[string(label)] = string(value) + } + + for seriesLabel, seriesValue := range exemplarData.SeriesLabels { + event.Labels[string(seriesLabel)] = string(seriesValue) + } + + events = append(events, event) + } + } + + // Sampling of exemplars + bucketedExemplars := make(map[string][]ExemplarEvent) + values := make([]float64, 0, len(events)) + + // Create bucketed exemplars based on aligned timestamp + for _, event := range events { + alignedTs := fmt.Sprintf("%.0f", math.Floor(float64(event.Time.Unix())/query.Step.Seconds())*query.Step.Seconds()) + _, ok := bucketedExemplars[alignedTs] + if !ok { + bucketedExemplars[alignedTs] = make([]ExemplarEvent, 0) + } + + bucketedExemplars[alignedTs] = append(bucketedExemplars[alignedTs], event) + values = append(values, event.Value) + } + + // Calculate standard deviation + standardDeviation := deviation(values) + + // Create slice with all of the bucketed exemplars + sampledBuckets := make([]string, len(bucketedExemplars)) + for bucketTimes := range bucketedExemplars { + sampledBuckets = append(sampledBuckets, bucketTimes) + } + sort.Strings(sampledBuckets) + + // Sample exemplars based ona value, so we are not showing too many of them + sampleExemplars := make([]ExemplarEvent, 0, len(sampledBuckets)) + for _, bucket := range sampledBuckets { + exemplarsInBucket := bucketedExemplars[bucket] + if len(exemplarsInBucket) == 1 { + sampleExemplars = append(sampleExemplars, exemplarsInBucket[0]) + } else { + bucketValues := make([]float64, len(exemplarsInBucket)) + for _, exemplar := range exemplarsInBucket { + bucketValues = append(bucketValues, exemplar.Value) + } + sort.Slice(bucketValues, func(i, j int) bool { + return bucketValues[i] > bucketValues[j] + }) + + sampledBucketValues := make([]float64, 0) + for _, value := range bucketValues { + if len(sampledBucketValues) == 0 { + sampledBucketValues = append(sampledBucketValues, value) + } else { + // Then take values only when at least 2 standard deviation distance to previously taken value + prev := sampledBucketValues[len(sampledBucketValues)-1] + if standardDeviation != 0 && prev-value >= float64(2)*standardDeviation { + sampledBucketValues = append(sampledBucketValues, value) + } + } + } + for _, valueBucket := range sampledBucketValues { + for _, exemplar := range exemplarsInBucket { + if exemplar.Value == valueBucket { + sampleExemplars = append(sampleExemplars, exemplar) + } + } + } + } + } + + // Create DF from sampled exemplars + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(sampleExemplars)) + timeField.Name = "Time" + valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, len(sampleExemplars)) + valueField.Name = "Value" + labelsVector := make(map[string][]string, len(sampleExemplars)) + + for i, exemplar := range sampleExemplars { + timeField.Set(i, exemplar.Time) + valueField.Set(i, exemplar.Value) + + for label, value := range exemplar.Labels { + if labelsVector[label] == nil { + labelsVector[label] = make([]string, 0) + } + + labelsVector[label] = append(labelsVector[label], value) + } + } + + dataFields := make([]*data.Field, 0, len(labelsVector)+2) + dataFields = append(dataFields, timeField, valueField) + for label, vector := range labelsVector { + dataFields = append(dataFields, data.NewField(label, nil, vector)) + } + + return append(frames, newDataFrame("exemplar", "exemplar", dataFields...)) +} + +func deviation(values []float64) float64 { + var sum, mean, sd float64 + valuesLen := float64(len(values)) + for _, value := range values { + sum += value + } + mean = sum / valuesLen + for j := 0; j < len(values); j++ { + sd += math.Pow(values[j]-mean, 2) + } + return math.Sqrt(sd / (valuesLen - 1)) +} + +func newDataFrame(name string, typ string, fields ...*data.Field) *data.Frame { + frame := data.NewFrame(name, fields...) + frame.Meta = &data.FrameMeta{ + Custom: map[string]string{ + "resultType": typ, + }, + } + + return frame +} diff --git a/pkg/tsdb/prometheus/prometheus_test.go b/pkg/tsdb/prometheus/time_series_query_test.go similarity index 89% rename from pkg/tsdb/prometheus/prometheus_test.go rename to pkg/tsdb/prometheus/time_series_query_test.go index e3625618c2a..86dd5cc7ee5 100644 --- a/pkg/tsdb/prometheus/prometheus_test.go +++ b/pkg/tsdb/prometheus/time_series_query_test.go @@ -14,7 +14,7 @@ import ( var now = time.Now() -func TestPrometheus_formatLeged(t *testing.T) { +func TestPrometheus_timeSeriesQuery_formatLeged(t *testing.T) { t.Run("converting metric name", func(t *testing.T) { metric := map[p.LabelName]p.LabelValue{ p.LabelName("app"): p.LabelValue("backend"), @@ -54,7 +54,7 @@ func TestPrometheus_formatLeged(t *testing.T) { }) } -func TestPrometheus_parseQuery(t *testing.T) { +func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { service := Service{ intervalCalculator: intervalv2.NewCalculator(), } @@ -72,7 +72,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, time.Second*30, models[0].Step) }) @@ -91,7 +91,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, time.Second*15, models[0].Step) }) @@ -110,7 +110,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, time.Minute*20, models[0].Step) }) @@ -129,7 +129,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, time.Minute*2, models[0].Step) }) @@ -150,7 +150,7 @@ func TestPrometheus_parseQuery(t *testing.T) { dsInfo := &DatasourceInfo{ TimeInterval: "240s", } - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, time.Minute*4, models[0].Step) }) @@ -169,7 +169,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [2m]})", models[0].Expr) }) @@ -188,7 +188,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [120000]})", models[0].Expr) }) @@ -207,7 +207,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [120000]}) + rate(ALERTS{job=\"test\" [2m]})", models[0].Expr) }) @@ -226,7 +226,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800s]})", models[0].Expr) }) @@ -245,7 +245,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800]})", models[0].Expr) }) @@ -264,7 +264,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [172800000]})", models[0].Expr) }) @@ -283,7 +283,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, "rate(ALERTS{job=\"test\" [1m]})", models[0].Expr) }) @@ -303,7 +303,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, true, models[0].RangeQuery) }) @@ -324,7 +324,7 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, true, models[0].RangeQuery) require.Equal(t, true, models[0].InstantQuery) @@ -344,15 +344,15 @@ func TestPrometheus_parseQuery(t *testing.T) { }`, timeRange) dsInfo := &DatasourceInfo{} - models, err := service.parseQuery(query, dsInfo) + models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) require.Equal(t, true, models[0].RangeQuery) }) } -func TestPrometheus_parseResponse(t *testing.T) { +func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { t.Run("exemplars response should be sampled and parsed normally", func(t *testing.T) { - value := make(map[PrometheusQueryType]interface{}) + value := make(map[TimeSeriesQueryType]interface{}) exemplars := []apiv1.ExemplarQueryResult{ { SeriesLabels: p.LabelSet{ @@ -389,7 +389,7 @@ func TestPrometheus_parseResponse(t *testing.T) { query := &PrometheusQuery{ LegendFormat: "legend {{app}}", } - res, err := parseResponse(value, query) + res, err := parseTimeSeriesResponse(value, query) require.NoError(t, err) // Test fields @@ -413,7 +413,7 @@ func TestPrometheus_parseResponse(t *testing.T) { {Value: 4, Timestamp: 4000}, {Value: 5, Timestamp: 5000}, } - value := make(map[PrometheusQueryType]interface{}) + value := make(map[TimeSeriesQueryType]interface{}) value[RangeQueryType] = p.Matrix{ &p.SampleStream{ Metric: p.Metric{"app": "Application", "tag2": "tag2"}, @@ -423,7 +423,7 @@ func TestPrometheus_parseResponse(t *testing.T) { query := &PrometheusQuery{ LegendFormat: "legend {{app}}", } - res, err := parseResponse(value, query) + res, err := parseTimeSeriesResponse(value, query) require.NoError(t, err) require.Len(t, res, 1) @@ -442,7 +442,7 @@ func TestPrometheus_parseResponse(t *testing.T) { }) t.Run("matrix response with NaN value should be changed to null", func(t *testing.T) { - value := make(map[PrometheusQueryType]interface{}) + value := make(map[TimeSeriesQueryType]interface{}) value[RangeQueryType] = p.Matrix{ &p.SampleStream{ Metric: p.Metric{"app": "Application"}, @@ -454,7 +454,7 @@ func TestPrometheus_parseResponse(t *testing.T) { query := &PrometheusQuery{ LegendFormat: "", } - res, err := parseResponse(value, query) + res, err := parseTimeSeriesResponse(value, query) require.NoError(t, err) var nilPointer *float64 @@ -463,7 +463,7 @@ func TestPrometheus_parseResponse(t *testing.T) { }) t.Run("vector response should be parsed normally", func(t *testing.T) { - value := make(map[PrometheusQueryType]interface{}) + value := make(map[TimeSeriesQueryType]interface{}) value[RangeQueryType] = p.Vector{ &p.Sample{ Metric: p.Metric{"app": "Application", "tag2": "tag2"}, @@ -474,7 +474,7 @@ func TestPrometheus_parseResponse(t *testing.T) { query := &PrometheusQuery{ LegendFormat: "legend {{app}}", } - res, err := parseResponse(value, query) + res, err := parseTimeSeriesResponse(value, query) require.NoError(t, err) require.Len(t, res, 1) @@ -494,14 +494,14 @@ func TestPrometheus_parseResponse(t *testing.T) { }) t.Run("scalar response should be parsed normally", func(t *testing.T) { - value := make(map[PrometheusQueryType]interface{}) + value := make(map[TimeSeriesQueryType]interface{}) value[RangeQueryType] = &p.Scalar{ Value: 1, Timestamp: 1000, } query := &PrometheusQuery{} - res, err := parseResponse(value, query) + res, err := parseTimeSeriesResponse(value, query) require.NoError(t, err) require.Len(t, res, 1) diff --git a/pkg/tsdb/prometheus/types.go b/pkg/tsdb/prometheus/types.go index 430dbe0bdaa..6841f25fafc 100644 --- a/pkg/tsdb/prometheus/types.go +++ b/pkg/tsdb/prometheus/types.go @@ -45,11 +45,3 @@ type QueryModel struct { IntervalFactor int64 `json:"intervalFactor"` UtcOffsetSec int64 `json:"utcOffsetSec"` } - -type PrometheusQueryType string - -const ( - RangeQueryType PrometheusQueryType = "range" - InstantQueryType PrometheusQueryType = "instant" - ExemplarQueryType PrometheusQueryType = "exemplar" -) diff --git a/public/app/angular/AngularApp.ts b/public/app/angular/AngularApp.ts index 2124d8093fa..dabcafbeea2 100644 --- a/public/app/angular/AngularApp.ts +++ b/public/app/angular/AngularApp.ts @@ -14,7 +14,7 @@ import { extend } from 'lodash'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTemplateSrv } from '@grafana/runtime'; import './panel/all'; - +import './partials'; export class AngularApp { ngModuleDependencies: any[]; preBootModules: any[]; diff --git a/public/app/core/partials.ts b/public/app/angular/partials.ts similarity index 100% rename from public/app/core/partials.ts rename to public/app/angular/partials.ts diff --git a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx index a2c5845f3d8..aa3fddb0778 100644 --- a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx +++ b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data'; -import { getAllPanelPluginMeta } from '../../../features/panel/components/VizTypePicker/VizTypePicker'; +import { getAllPanelPluginMeta } from 'app/features/panel/state/util'; import { Icon, resetSelectStyles, MultiSelect, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index a55c22a8b26..3ff14355734 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,5 +1,5 @@ import { config, GrafanaBootConfig } from '@grafana/runtime'; -import { PluginState } from '../../../packages/grafana-data/src'; +import { PluginState } from '@grafana/data'; // Legacy binding paths export { config, GrafanaBootConfig as Settings }; diff --git a/public/app/core/core.ts b/public/app/core/core.ts index c90abd0781d..aaaa92bf10b 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -8,7 +8,6 @@ import '../angular/rebuild_on_change'; import '../angular/give_focus'; import '../angular/diff-view'; import './jquery_extended'; -import './partials'; import './components/jsontree/jsontree'; import './components/code_editor/code_editor'; import './components/colorpicker/spectrum_picker'; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 77ed750e0e3..c0799e93ff8 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -15,6 +15,7 @@ import organizationReducers from 'app/features/org/state/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; import templatingReducers from 'app/features/variables/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; +import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers'; import panelsReducers from 'app/features/panel/state/reducers'; const rootReducers = { @@ -33,6 +34,7 @@ const rootReducers = { ...ldapReducers, ...templatingReducers, ...importDashboardReducers, + ...panelEditorReducers, ...panelsReducers, }; diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index b5a3d73c58f..280ae6c0b09 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -239,7 +239,7 @@ describe('hasNonEmptyQuery', () => { }); test('should return false if query is empty', () => { - expect(hasNonEmptyQuery([{ refId: '1', key: '2', context: 'panel' }])).toBeFalsy(); + expect(hasNonEmptyQuery([{ refId: '1', key: '2', context: 'panel', datasource: 'some-ds' }])).toBeFalsy(); }); test('should return false if no queries exist', () => { diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 647611a23c0..11f8c522f18 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -201,6 +201,25 @@ export const safeStringifyValue = (value: any, space?: number) => { return ''; }; +export const EXPLORE_GRAPH_STYLES = ['lines', 'bars', 'points', 'stacked_lines', 'stacked_bars'] as const; + +export type ExploreGraphStyle = typeof EXPLORE_GRAPH_STYLES[number]; + +const DEFAULT_GRAPH_STYLE: ExploreGraphStyle = 'lines'; +// we use this function to take any kind of data we loaded +// from an external source (URL, localStorage, whatever), +// and extract the graph-style from it, or return the default +// graph-style if we are not able to do that. +// it is important that this function is able to take any form of data, +// (be it objects, or arrays, or booleans or whatever), +// and produce a best-effort graphStyle. +// note that typescript makes sure we make no mistake in this function. +// we do not rely on ` as ` or ` any `. +export const toGraphStyle = (data: unknown): ExploreGraphStyle => { + const found = EXPLORE_GRAPH_STYLES.find((v) => v === data); + return found ?? DEFAULT_GRAPH_STYLE; +}; + export function parseUrlState(initial: string | undefined): ExploreUrlState { const parsed = safeParseJson(initial); const errorResult: any = { @@ -277,9 +296,12 @@ export function ensureQueries(queries?: DataQuery[]): DataQuery[] { } /** - * A target is non-empty when it has keys (with non-empty values) other than refId, key and context. + * A target is non-empty when it has keys (with non-empty values) other than refId, key, context and datasource. + * FIXME: While this is reasonable for practical use cases, a query without any propery might still be "non-empty" + * in its own scope, for instance when there's no user input needed. This might be the case for an hypothetic datasource in + * which query options are only set in its config and the query object itself, as generated from its query editor it's always "empty" */ -const validKeys = ['refId', 'key', 'context']; +const validKeys = ['refId', 'key', 'context', 'datasource']; export function hasNonEmptyQuery(queries: TQuery[]): boolean { return ( queries && diff --git a/public/app/core/utils/richHistory.test.ts b/public/app/core/utils/richHistory.test.ts index 08be5c3ec1b..c1267e526ec 100644 --- a/public/app/core/utils/richHistory.test.ts +++ b/public/app/core/utils/richHistory.test.ts @@ -281,29 +281,57 @@ describe('richHistory', () => { deleteAllFromRichHistory(); expect(store.exists(key)).toBeFalsy(); }); - it('should load from localStorage data in old format', () => { - const oldHistoryItem = { ...mock.storedHistory[0], queries: ['test query 1', 'test query 2', 'test query 3'] }; - store.setObject(key, [oldHistoryItem]); - const expectedHistoryItem = { - ...mock.storedHistory[0], - queries: [ - { - expr: 'test query 1', - refId: 'A', - }, - { - expr: 'test query 2', - refId: 'B', - }, - { - expr: 'test query 3', - refId: 'C', - }, - ], - }; + describe('should load from localStorage data in old formats', () => { + it('should load when queries are strings', () => { + const oldHistoryItem = { ...mock.storedHistory[0], queries: ['test query 1', 'test query 2', 'test query 3'] }; + store.setObject(key, [oldHistoryItem]); + const expectedHistoryItem = { + ...mock.storedHistory[0], + queries: [ + { + expr: 'test query 1', + refId: 'A', + }, + { + expr: 'test query 2', + refId: 'B', + }, + { + expr: 'test query 3', + refId: 'C', + }, + ], + }; - const result = getRichHistory(); - expect(result).toStrictEqual([expectedHistoryItem]); + const result = getRichHistory(); + expect(result).toStrictEqual([expectedHistoryItem]); + }); + + it('should load when queries are json-encoded strings', () => { + const oldHistoryItem = { + ...mock.storedHistory[0], + queries: ['{"refId":"A","key":"key1","metrics":[]}', '{"refId":"B","key":"key2","metrics":[]}'], + }; + store.setObject(key, [oldHistoryItem]); + const expectedHistoryItem = { + ...mock.storedHistory[0], + queries: [ + { + refId: 'A', + key: 'key1', + metrics: [], + }, + { + refId: 'B', + key: 'key2', + metrics: [], + }, + ], + }; + + const result = getRichHistory(); + expect(result).toStrictEqual([expectedHistoryItem]); + }); }); }); }); diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index b0d995f94bb..ff7b032d25a 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -406,10 +406,13 @@ function migrateRichHistory(richHistory: RichHistoryQuery[]) { function createDataQuery(query: RichHistoryQuery, individualQuery: DataQuery | string, index: number) { const letters = 'ABCDEFGHIJKLMNOPQRSTUVXYZ'; if (typeof individualQuery === 'object') { + // the current format return individualQuery; } else if (isParsable(individualQuery)) { + // ElasticSearch (maybe other datasoures too) before grafana7 return JSON.parse(individualQuery); } + // prometehus (maybe other datasources too) before grafana7 return { expr: individualQuery, refId: letters[index] }; } diff --git a/public/app/features/alerting/FeatureTogglePage.tsx b/public/app/features/alerting/FeatureTogglePage.tsx new file mode 100644 index 00000000000..57fb28b554a --- /dev/null +++ b/public/app/features/alerting/FeatureTogglePage.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Page from 'app/core/components/Page/Page'; +import { useNavModel } from 'app/core/hooks/useNavModel'; + +export default function FeatureTogglePage() { + const navModel = useNavModel('alert-list'); + + return ( + + +

Alerting is not enabled

+ To enable alerting, enable it in the Grafana config: +
+
+            {`[unified_alerting]
+enable = true
+`}
+          
+
+
+ For legacy alerting +
+            {`[alerting]
+enable = true
+`}
+          
+
+ + + ); +} diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx new file mode 100644 index 00000000000..b767df008c5 --- /dev/null +++ b/public/app/features/alerting/routes.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; +import { config } from 'app/core/config'; +import { RouteDescriptor } from 'app/core/navigation/types'; + +const alertingRoutes = [ + { + path: '/alerting', + // eslint-disable-next-line react/display-name + component: () => , + }, + { + path: '/alerting/list', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertRuleListIndex" */ 'app/features/alerting/AlertRuleListIndex') + ), + }, + { + path: '/alerting/ng/list', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList') + ), + }, + { + path: '/alerting/routes', + roles: () => ['Admin', 'Editor'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes') + ), + }, + { + path: '/alerting/silences', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') + ), + }, + { + path: '/alerting/silence/new', + roles: () => ['Editor', 'Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') + ), + }, + { + path: '/alerting/silence/:id/edit', + roles: () => ['Editor', 'Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') + ), + }, + { + path: '/alerting/notifications', + roles: config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : undefined, + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') + ), + }, + { + path: '/alerting/notifications/templates/new', + roles: () => ['Editor', 'Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') + ), + }, + { + path: '/alerting/notifications/templates/:id/edit', + roles: () => ['Editor', 'Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') + ), + }, + { + path: '/alerting/notifications/receivers/new', + roles: () => ['Editor', 'Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') + ), + }, + { + path: '/alerting/notifications/receivers/:id/edit', + roles: () => ['Editor', 'Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') + ), + }, + { + path: '/alerting/notifications/global-config', + roles: () => ['Admin', 'Editor'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/NotificationsIndex') + ), + }, + { + path: '/alerting/notification/new', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "NewNotificationChannel" */ 'app/features/alerting/NewNotificationChannelPage') + ), + }, + { + path: '/alerting/notification/:id/edit', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "EditNotificationChannel"*/ 'app/features/alerting/EditNotificationChannelPage') + ), + }, + { + path: '/alerting/groups/', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups') + ), + }, + { + path: '/alerting/new', + pageClass: 'page-alerting', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') + ), + }, + { + path: '/alerting/:id/edit', + pageClass: 'page-alerting', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') + ), + }, + { + path: '/alerting/:sourceName/:id/view', + pageClass: 'page-alerting', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertingRule"*/ 'app/features/alerting/unified/RuleViewer') + ), + }, + { + path: '/alerting/:sourceName/:name/find', + pageClass: 'page-alerting', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertingRedirectToRule"*/ 'app/features/alerting/unified/RedirectToRuleViewer') + ), + }, + { + path: '/alerting/admin', + roles: () => ['Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AlertingAdmin" */ 'app/features/alerting/unified/Admin') + ), + }, +]; + +export function getAlertingRoutes(cfg = config): RouteDescriptor[] { + if (cfg.alertingEnabled || cfg.unifiedAlertingEnabled) { + return alertingRoutes; + } + + return alertingRoutes.map((route) => ({ + ...route, + component: SafeDynamicImport( + () => import(/* webpackChunkName: "Alerting feature toggle page"*/ 'app/features/alerting/FeatureTogglePage') + ), + })); +} diff --git a/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx b/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx index 99778dd3463..605b9573856 100644 --- a/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx @@ -83,7 +83,7 @@ export class AngularPanelOptionsUnconnected extends PureComponent { const panelCtrl: PanelCtrl = scope.$$childHead.ctrl; panelCtrl.initEditMode(); panelCtrl.onPluginTypeChange = (plugin: PanelPluginMeta) => { - changePanelPlugin(panel, plugin.id); + changePanelPlugin({ panel, pluginId: plugin.id }); }; let template = ''; diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPane.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPane.tsx index 16c57345bbb..73e73e8ee24 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPane.tsx @@ -45,7 +45,7 @@ export const OptionsPane: React.FC = ({ )} - {isVizPickerOpen && } + {isVizPickerOpen && } ); }; diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx index 5d56dbbaa7e..6a105fb7a7c 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx @@ -35,6 +35,7 @@ export const OptionsPaneOptions: React.FC = (props) => { const mainBoxElements: React.ReactNode[] = []; const isSearching = searchQuery.length > 0; const optionRadioFilters = useMemo(getOptionRadioFilters, []); + const allOptions = isPanelModelLibraryPanel(panel) ? [libraryPanelOptions, panelFrameOptions, ...vizOptions] : [panelFrameOptions, ...vizOptions]; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 275a060a1e4..2e25852c090 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -218,7 +218,7 @@ export class PanelEditorUnconnected extends PureComponent { updatePanelEditorUIState({ isPanelOptionsVisible: !uiState.isPanelOptionsVisible }); }; - renderPanel(styles: EditorStyles, noTabsBelow: boolean) { + renderPanel(styles: EditorStyles, isOnlyPanel: boolean) { const { dashboard, panel, uiState, tableViewEnabled } = this.props; return ( @@ -232,7 +232,7 @@ export class PanelEditorUnconnected extends PureComponent { } // If no tabs limit height so panel does not extend to edge - if (noTabsBelow) { + if (isOnlyPanel) { height -= config.theme2.spacing.gridSize * 2; } @@ -270,21 +270,23 @@ export class PanelEditorUnconnected extends PureComponent { renderPanelAndEditor(styles: EditorStyles) { const { panel, dashboard, plugin, tab } = this.props; const tabs = getPanelEditorTabs(tab, plugin); + const isOnlyPanel = tabs.length === 0; + const panelPane = this.renderPanel(styles, isOnlyPanel); - if (tabs.length > 0) { - return [ - this.renderPanel(styles, false), -
- -
, - ]; + if (tabs.length === 0) { + return panelPane; } - return this.renderPanel(styles, true); + return [ + panelPane, +
+ +
, + ]; } renderTemplateVariables(styles: EditorStyles) { @@ -529,6 +531,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme, props: Props) => { justify-content: center; align-items: center; position: relative; + flex-direction: column; `, }; }); diff --git a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx index 87af8f1b822..a0b953cb568 100644 --- a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx @@ -1,45 +1,43 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import { css } from '@emotion/css'; -import { GrafanaTheme, PanelPluginMeta, SelectableValue } from '@grafana/data'; -import { Button, CustomScrollbar, Icon, Input, RadioButtonGroup, useStyles } from '@grafana/ui'; +import { GrafanaTheme, PanelData, SelectableValue } from '@grafana/data'; +import { Button, CustomScrollbar, FilterInput, RadioButtonGroup, useStyles } from '@grafana/ui'; import { changePanelPlugin } from '../../../panel/state/actions'; import { PanelModel } from '../../state/PanelModel'; import { useDispatch, useSelector } from 'react-redux'; -import { - filterPluginList, - getAllPanelPluginMeta, - VizTypePicker, -} from '../../../panel/components/VizTypePicker/VizTypePicker'; +import { VizTypePicker } from '../../../panel/components/VizTypePicker/VizTypePicker'; import { Field } from '@grafana/ui/src/components/Forms/Field'; import { PanelLibraryOptionsGroup } from 'app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup'; import { toggleVizPicker } from './state/reducers'; import { selectors } from '@grafana/e2e-selectors'; import { getPanelPluginWithFallback } from '../../state/selectors'; +import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; +import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions'; +import { useLocalStorage } from 'react-use'; interface Props { panel: PanelModel; + data?: PanelData; } -export const VisualizationSelectPane: FC = ({ panel }) => { +export const VisualizationSelectPane: FC = ({ panel, data }) => { const plugin = useSelector(getPanelPluginWithFallback(panel.type)); const [searchQuery, setSearchQuery] = useState(''); - const [listMode, setListMode] = useState(ListMode.Visualizations); + const [listMode, setListMode] = useLocalStorage(`VisualizationSelectPane.ListMode`, ListMode.Visualizations); const dispatch = useDispatch(); const styles = useStyles(getStyles); const searchRef = useRef(null); - const onPluginTypeChange = useCallback( - (meta: PanelPluginMeta, withModKey: boolean) => { - if (meta.id !== plugin.meta.id) { - dispatch(changePanelPlugin(panel, meta.id)); - } + const onVizChange = useCallback( + (pluginChange: VizTypeChangeDetails) => { + dispatch(changePanelPlugin({ panel: panel, ...pluginChange })); // close viz picker unless a mod key is pressed while clicking - if (!withModKey) { + if (!pluginChange.withModKey) { dispatch(toggleVizPicker(false)); } }, - [dispatch, panel, plugin.meta.id] + [dispatch, panel] ); // Give Search input focus when using radio button switch list mode @@ -53,27 +51,20 @@ export const VisualizationSelectPane: FC = ({ panel }) => { dispatch(toggleVizPicker(false)); }; - const onKeyPress = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - const query = e.currentTarget.value; - const plugins = getAllPanelPluginMeta(); - const match = filterPluginList(plugins, query, plugin.meta); + // const onKeyPress = useCallback( + // (e: React.KeyboardEvent) => { + // if (e.key === 'Enter') { + // const query = e.currentTarget.value; + // const plugins = getAllPanelPluginMeta(); + // const match = filterPluginList(plugins, query, plugin.meta); - if (match && match.length) { - onPluginTypeChange(match[0], false); - } - } - }, - [onPluginTypeChange, plugin.meta] - ); - - const suffix = - searchQuery !== '' ? ( - - ) : null; + // if (match && match.length) { + // onPluginTypeChange(match[0], false); + // } + // } + // }, + // [onPluginTypeChange, plugin.meta] + // ); if (!plugin) { return null; @@ -81,6 +72,7 @@ export const VisualizationSelectPane: FC = ({ panel }) => { const radioOptions: Array> = [ { label: 'Visualizations', value: ListMode.Visualizations }, + { label: 'Suggestions', value: ListMode.Suggestions }, { label: 'Library panels', value: ListMode.LibraryPanels, @@ -92,13 +84,11 @@ export const VisualizationSelectPane: FC = ({ panel }) => {
- setSearchQuery(e.currentTarget.value)} - onKeyPress={onKeyPress} - prefix={} - suffix={suffix} + onChange={setSearchQuery} ref={searchRef} + autoFocus={true} placeholder="Search for..." />
0) { LogsVolumePanelContent = ( ; + +export function applyGraphStyle(config: FieldConfig, style: ExploreGraphStyle): FieldConfig { + return produce(config, (draft) => { + if (draft.defaults.custom === undefined) { + draft.defaults.custom = {}; + } + + const { custom } = draft.defaults; + + if (custom.stacking === undefined) { + custom.stacking = { group: 'A' }; + } + + switch (style) { + case 'lines': + custom.drawStyle = GraphDrawStyle.Line; + custom.stacking.mode = StackingMode.None; + custom.fillOpacity = 0; + break; + case 'bars': + custom.drawStyle = GraphDrawStyle.Bars; + custom.stacking.mode = StackingMode.None; + custom.fillOpacity = 100; + break; + case 'points': + custom.drawStyle = GraphDrawStyle.Points; + custom.stacking.mode = StackingMode.None; + custom.fillOpacity = 0; + break; + case 'stacked_lines': + custom.drawStyle = GraphDrawStyle.Line; + custom.stacking.mode = StackingMode.Normal; + custom.fillOpacity = 100; + break; + case 'stacked_bars': + custom.drawStyle = GraphDrawStyle.Bars; + custom.stacking.mode = StackingMode.Normal; + custom.fillOpacity = 100; + break; + default: { + // should never happen + // NOTE: casting to `never` will cause typescript + // to verify that the switch statement checks every possible + // enum-value + const invalidValue: never = style; + throw new Error(`Invalid graph-style: ${invalidValue}`); + } + } + }); +} diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index d1da8319c0d..160f5e5e949 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -8,6 +8,7 @@ import { ensureQueries, generateNewKeyAndAddRefIdIfMissing, getTimeRangeFromUrl, + ExploreGraphStyle, } from 'app/core/utils/explore'; import { ExploreId, ExploreItemState } from 'app/types/explore'; import { queryReducer, runQueries, setQueriesAction } from './query'; @@ -19,6 +20,7 @@ import { loadAndInitDatasource, createEmptyQueryResponse, getUrlStateFromPaneState, + storeGraphStyle, } from './utils'; import { createAction, PayloadAction } from '@reduxjs/toolkit'; import { EventBusExtended, DataQuery, ExploreUrlState, TimeRange, HistoryItem, DataSourceApi } from '@grafana/data'; @@ -76,6 +78,20 @@ export function changeSize( return changeSizeAction({ exploreId, height, width }); } +interface ChangeGraphStylePayload { + exploreId: ExploreId; + graphStyle: ExploreGraphStyle; +} + +const changeGraphStyleAction = createAction('explore/changeGraphStyle'); + +export function changeGraphStyle(exploreId: ExploreId, graphStyle: ExploreGraphStyle): ThunkResult { + return async (dispatch, getState) => { + storeGraphStyle(graphStyle); + dispatch(changeGraphStyleAction({ exploreId, graphStyle })); + }; +} + /** * Initialize Explore state with state from the URL and the React component. * Call this only on components for with the Explore state has not been initialized. @@ -200,6 +216,11 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac return { ...state, containerWidth }; } + if (changeGraphStyleAction.match(action)) { + const { graphStyle } = action.payload; + return { ...state, graphStyle }; + } + if (initializeExploreAction.match(action)) { const { containerWidth, eventBridge, queries, range, originPanelId, datasourceInstance, history } = action.payload; diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index d6028ece374..33ec8339099 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -326,7 +326,6 @@ export const runQueries = ( const exploreItemState = getState().explore[exploreId]!; const { datasourceInstance, - queries, containerWidth, isLive: live, range, @@ -341,6 +340,11 @@ export const runQueries = ( } = exploreItemState; let newQuerySub; + const queries = exploreItemState.queries.map((query) => ({ + ...query, + datasource: query.datasource || datasourceInstance?.name, + })); + const cachedValue = getResultsFromCache(cache, absoluteRange); // If we have results saved in cache, we are going to use those results instead of running queries diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index f9b0aab0ae2..5fa76e41950 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -12,7 +12,12 @@ import { import { ExploreItemState } from 'app/types/explore'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; import store from '../../../core/store'; -import { clearQueryKeys, lastUsedDatasourceKeyForOrgId } from '../../../core/utils/explore'; +import { + clearQueryKeys, + ExploreGraphStyle, + lastUsedDatasourceKeyForOrgId, + toGraphStyle, +} from '../../../core/utils/explore'; import { toRawTimeRange } from '../utils/time'; export const DEFAULT_RANGE = { @@ -20,6 +25,16 @@ export const DEFAULT_RANGE = { to: 'now', }; +const GRAPH_STYLE_KEY = 'grafana.explore.style.graph'; +export const storeGraphStyle = (graphStyle: string): void => { + store.set(GRAPH_STYLE_KEY, graphStyle); +}; + +const loadGraphStyle = (): ExploreGraphStyle => { + const data = store.get(GRAPH_STYLE_KEY); + return toGraphStyle(data); +}; + /** * Returns a fresh Explore area state */ @@ -52,6 +67,7 @@ export const makeExplorePaneState = (): ExploreItemState => ({ cache: [], logsVolumeDataProvider: undefined, logsVolumeData: undefined, + graphStyle: loadGraphStyle(), }); export const createEmptyQueryResponse = (): PanelData => ({ diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index 84371e17886..5a278cf051f 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -8,7 +8,7 @@ import { LibraryPanelsSearch, LibraryPanelsSearchProps } from './LibraryPanelsSe import * as api from '../../state/api'; import { LibraryElementKind, LibraryElementsSearchResult } from '../../types'; import { backendSrv } from '../../../../core/services/backend_srv'; -import * as viztypepicker from '../../../panel/components/VizTypePicker/VizTypePicker'; +import * as panelUtils from '../../../panel/state/util'; jest.mock('@grafana/runtime', () => ({ ...((jest.requireActual('@grafana/runtime') as unknown) as object), @@ -64,9 +64,7 @@ async function getTestContext( .spyOn(backendSrv, 'get') .mockResolvedValue({ sortOptions: [{ displaName: 'Desc', name: 'alpha-desc' }] }); const getLibraryPanelsSpy = jest.spyOn(api, 'getLibraryPanels').mockResolvedValue(searchResult); - const getAllPanelPluginMetaSpy = jest - .spyOn(viztypepicker, 'getAllPanelPluginMeta') - .mockReturnValue([graph, timeseries]); + const getAllPanelPluginMetaSpy = jest.spyOn(panelUtils, 'getAllPanelPluginMeta').mockReturnValue([graph, timeseries]); const props: LibraryPanelsSearchProps = { onClick: jest.fn(), diff --git a/public/app/features/live/pages/routes.ts b/public/app/features/live/pages/routes.ts index d95721107e4..7620f07c10e 100644 --- a/public/app/features/live/pages/routes.ts +++ b/public/app/features/live/pages/routes.ts @@ -1,7 +1,7 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { config } from 'app/core/config'; import { RouteDescriptor } from 'app/core/navigation/types'; -import { isGrafanaAdmin } from 'app/features/plugins/admin/helpers'; +import { isGrafanaAdmin } from 'app/features/plugins/admin/permissions'; const liveRoutes = [ { diff --git a/public/app/features/panel/components/CannotVisualizeData.tsx b/public/app/features/panel/components/CannotVisualizeData.tsx new file mode 100644 index 00000000000..51489b95d07 --- /dev/null +++ b/public/app/features/panel/components/CannotVisualizeData.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { GrafanaTheme2, VisualizationSuggestion } from '@grafana/data'; +import { useStyles2 } from '../../../../../packages/grafana-ui/src'; +import { css } from '@emotion/css'; + +interface Props { + message: string; + suggestions?: VisualizationSuggestion[]; +} + +export function CannotVisualizeData({ message, suggestions }: Props) { + const styles = useStyles2(getStyles); + + return ( +
+
{message}
+ { + // suggestions && ( + //
+ // {suggestions.map((suggestion, index) => ( + // + // ))} + //
+ // ) + } +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css` + display: flex; + align-items: center; + height: 100%; + width: 100%; + `, + message: css` + text-align: center; + color: $text-muted; + font-size: $font-size-lg; + width: 100%; + `, + }; +}; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationPreview.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationPreview.tsx new file mode 100644 index 00000000000..ce1c749cf8d --- /dev/null +++ b/public/app/features/panel/components/VizTypePicker/VisualizationPreview.tsx @@ -0,0 +1,128 @@ +import React, { CSSProperties } from 'react'; +import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data'; +import { PanelRenderer } from '../PanelRenderer'; +import { css } from '@emotion/css'; +import { Tooltip, useStyles2 } from '@grafana/ui'; +import { VizTypeChangeDetails } from './types'; +import { cloneDeep } from 'lodash'; +import { selectors } from '@grafana/e2e-selectors'; + +export interface Props { + data: PanelData; + width: number; + suggestion: VisualizationSuggestion; + showTitle?: boolean; + onChange: (details: VizTypeChangeDetails) => void; +} + +export function VisualizationPreview({ data, suggestion, onChange, width, showTitle }: Props) { + const styles = useStyles2(getStyles); + const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width); + + const onClick = () => { + onChange({ + pluginId: suggestion.pluginId, + options: suggestion.options, + fieldConfig: suggestion.fieldConfig, + }); + }; + + let preview = suggestion; + if (suggestion.previewModifier) { + preview = cloneDeep(suggestion); + suggestion.previewModifier(preview); + } + + return ( +
+ {showTitle &&
{suggestion.name}
} +
+ +
+ +
+
+ +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + hoverPane: css({ + position: 'absolute', + top: 0, + right: 0, + left: 0, + borderRadius: theme.spacing(2), + bottom: 0, + }), + vizBox: css` + position: relative; + border-radius: ${theme.shape.borderRadius(1)}; + cursor: pointer; + border: 1px solid ${theme.colors.border.strong}; + + transition: ${theme.transitions.create(['background'], { + duration: theme.transitions.duration.short, + })}; + + &:hover { + background: ${theme.colors.background.secondary}; + } + `, + name: css` + font-size: ${theme.typography.bodySmall.fontSize}; + white-space: nowrap; + overflow: hidden; + color: ${theme.colors.text.secondary}; + font-weight: ${theme.typography.fontWeightMedium}; + text-overflow: ellipsis; + `, + renderContainer: css` + position: absolute; + transform-origin: left top; + top: 6px; + left: 6px; + `, + }; +}; + +interface PreviewDimensionsAndStyles { + renderWidth: number; + renderHeight: number; + innerStyles: CSSProperties; + outerStyles: CSSProperties; +} + +function getPreviewDimensionsAndStyles(width: number): PreviewDimensionsAndStyles { + const aspectRatio = 16 / 10; + const showWidth = width; + const showHeight = width * (1 / aspectRatio); + const renderWidth = 350; + const renderHeight = renderWidth * (1 / aspectRatio); + + const padding = 6; + const widthFactor = (showWidth - padding * 2) / renderWidth; + const heightFactor = (showHeight - padding * 2) / renderHeight; + + return { + renderHeight, + renderWidth, + outerStyles: { width: showWidth, height: showHeight }, + innerStyles: { + width: renderWidth, + height: renderHeight, + transform: `scale(${widthFactor}, ${heightFactor})`, + }, + }; +} diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx new file mode 100644 index 00000000000..22d45361707 --- /dev/null +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, PanelData, PanelPluginMeta, PanelModel, VisualizationSuggestion } from '@grafana/data'; +import { css } from '@emotion/css'; +import { VizTypeChangeDetails } from './types'; +import { VisualizationPreview } from './VisualizationPreview'; +import { getAllSuggestions } from '../../state/getAllSuggestions'; +import { useAsync, useLocalStorage } from 'react-use'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +export interface Props { + current: PanelPluginMeta; + data?: PanelData; + panel?: PanelModel; + onChange: (options: VizTypeChangeDetails) => void; + searchQuery: string; + onClose: () => void; +} + +export function VisualizationSuggestions({ onChange, data, panel, searchQuery }: Props) { + const styles = useStyles2(getStyles); + const { value: suggestions } = useAsync(() => getAllSuggestions(data, panel), [data, panel]); + // temp test + const [showTitle, setShowTitle] = useLocalStorage(`VisualizationSuggestions.showTitle`, false); + const filteredSuggestions = filterSuggestionsBySearch(searchQuery, suggestions); + + return ( + + {({ width }) => { + if (!width) { + return null; + } + + const columnCount = Math.floor(width / 170); + const spaceBetween = 8 * (columnCount! - 1); + const previewWidth = (width - spaceBetween) / columnCount!; + + return ( +
+
+
setShowTitle(!showTitle)}> + Based on current data +
+
+
+ {filteredSuggestions.map((suggestion, index) => ( + + ))} + {searchQuery && filteredSuggestions.length === 0 && ( +
No results matched your query
+ )} +
+
+ ); + }} +
+ ); +} + +function filterSuggestionsBySearch( + searchQuery: string, + suggestions?: VisualizationSuggestion[] +): VisualizationSuggestion[] { + if (!searchQuery || !suggestions) { + return suggestions || []; + } + + const regex = new RegExp(searchQuery, 'i'); + + return suggestions.filter((s) => regex.test(s.name) || regex.test(s.pluginId)); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + heading: css({ + ...theme.typography.h5, + margin: theme.spacing(0, 0.5, 1), + }), + filterRow: css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + paddingBottom: '8px', + }), + infoText: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + fontStyle: 'italic', + }), + grid: css({ + display: 'grid', + gridGap: theme.spacing(1), + gridTemplateColumns: 'repeat(auto-fill, 144px)', + marginBottom: theme.spacing(1), + justifyContent: 'space-evenly', + }), + }; +}; diff --git a/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx b/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx index b2929158d9b..01cb66ffd57 100644 --- a/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx +++ b/public/app/features/panel/components/VizTypePicker/VizTypePicker.tsx @@ -1,118 +1,63 @@ -import React, { useCallback, useMemo } from 'react'; - -import config from 'app/core/config'; +import React, { useMemo } from 'react'; import { VizTypePickerPlugin } from './VizTypePickerPlugin'; -import { EmptySearchResult, stylesFactory, useTheme } from '@grafana/ui'; -import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data'; +import { EmptySearchResult, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, PanelData, PanelPluginMeta } from '@grafana/data'; import { css } from '@emotion/css'; +import { filterPluginList, getAllPanelPluginMeta } from '../../state/util'; +import { VizTypeChangeDetails } from './types'; export interface Props { current: PanelPluginMeta; - onTypeChange: (newType: PanelPluginMeta, withModKey: boolean) => void; + data?: PanelData; + onChange: (options: VizTypeChangeDetails) => void; searchQuery: string; onClose: () => void; } -export function getAllPanelPluginMeta(): PanelPluginMeta[] { - const allPanels = config.panels; - - return Object.keys(allPanels) - .filter((key) => allPanels[key]['hideFromList'] === false) - .map((key) => allPanels[key]) - .sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort); -} - -export function filterPluginList( - pluginsList: PanelPluginMeta[], - searchQuery: string, - current: PanelPluginMeta -): PanelPluginMeta[] { - if (!searchQuery.length) { - return pluginsList.filter((p) => { - if (p.state === PluginState.deprecated) { - return current.id === p.id; - } - return true; - }); - } - - const query = searchQuery.toLowerCase(); - const first: PanelPluginMeta[] = []; - const match: PanelPluginMeta[] = []; - - for (const item of pluginsList) { - if (item.state === PluginState.deprecated && current.id !== item.id) { - continue; - } - - const name = item.name.toLowerCase(); - const idx = name.indexOf(query); - - if (idx === 0) { - first.push(item); - } else if (idx > 0) { - match.push(item); - } - } - - return first.concat(match); -} - -export const VizTypePicker: React.FC = ({ searchQuery, onTypeChange, current }) => { - const theme = useTheme(); - const styles = getStyles(theme); +export function VizTypePicker({ searchQuery, onChange, current, data }: Props) { + const styles = useStyles2(getStyles); const pluginsList: PanelPluginMeta[] = useMemo(() => { return getAllPanelPluginMeta(); }, []); - const getFilteredPluginList = useCallback((): PanelPluginMeta[] => { + const filteredPluginTypes = useMemo((): PanelPluginMeta[] => { return filterPluginList(pluginsList, searchQuery, current); }, [current, pluginsList, searchQuery]); - const renderVizPlugin = (plugin: PanelPluginMeta, index: number) => { - const isCurrent = plugin.id === current.id; - const filteredPluginList = getFilteredPluginList(); - - const matchesQuery = filteredPluginList.indexOf(plugin) > -1; - return ( - onTypeChange(plugin, Boolean(e.metaKey || e.ctrlKey || e.altKey))} - /> - ); - }; - - const filteredPluginList = getFilteredPluginList(); - const hasResults = filteredPluginList.length > 0; - const renderList = filteredPluginList.concat(pluginsList.filter((p) => filteredPluginList.indexOf(p) === -1)); + if (filteredPluginTypes.length === 0) { + return Could not find anything matching your query; + } return (
- {hasResults ? ( - renderList.map((plugin, index) => { - if (plugin.state === PluginState.deprecated) { - return null; + {filteredPluginTypes.map((plugin, index) => ( + + onChange({ + pluginId: plugin.id, + withModKey: Boolean(e.metaKey || e.ctrlKey || e.altKey), + }) } - return renderVizPlugin(plugin, index); - }) - ) : ( - Could not find anything matching your query - )} + /> + ))}
); -}; +} -VizTypePicker.displayName = 'VizTypePicker'; - -const getStyles = stylesFactory((theme: GrafanaTheme) => { +const getStyles = (theme: GrafanaTheme2) => { return { grid: css` max-width: 100%; display: grid; - grid-gap: ${theme.spacing.sm}; + grid-gap: ${theme.spacing(0.5)}; `, + heading: css({ + ...theme.typography.h5, + margin: theme.spacing(0, 0.5, 1), + }), }; -}); +}; diff --git a/public/app/features/panel/components/VizTypePicker/types.ts b/public/app/features/panel/components/VizTypePicker/types.ts new file mode 100644 index 00000000000..d656b2e19f8 --- /dev/null +++ b/public/app/features/panel/components/VizTypePicker/types.ts @@ -0,0 +1,8 @@ +import { FieldConfigSource } from '@grafana/data'; + +export interface VizTypeChangeDetails { + pluginId: string; + options?: any; + fieldConfig?: FieldConfigSource; + withModKey?: boolean; +} diff --git a/public/app/features/panel/state/actions.test.ts b/public/app/features/panel/state/actions.test.ts index e2606f53054..65cd5bcfbc0 100644 --- a/public/app/features/panel/state/actions.test.ts +++ b/public/app/features/panel/state/actions.test.ts @@ -4,6 +4,8 @@ import { changePanelPlugin } from './actions'; import { panelModelAndPluginReady } from './reducers'; import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; import { panelPluginLoaded } from 'app/features/plugins/admin/state/actions'; +import { standardEditorsRegistry, standardFieldConfigEditorRegistry } from '@grafana/data'; +import { mockStandardFieldConfigOptions } from 'test/helpers/fieldConfig'; jest.mock('app/features/plugins/importPanelPlugin', () => { return { @@ -11,12 +13,15 @@ jest.mock('app/features/plugins/importPanelPlugin', () => { return Promise.resolve( getPanelPlugin({ id: 'table', - }) + }).useFieldConfig() ); }, }; }); +standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions()); +standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions()); + describe('panel state actions', () => { describe('changePanelPlugin', () => { it('Should load plugin and call changePlugin', async () => { @@ -29,12 +34,43 @@ describe('panel state actions', () => { panels: {}, }) .givenThunk(changePanelPlugin) - .whenThunkIsDispatched(sourcePanel, 'table'); + .whenThunkIsDispatched({ + panel: sourcePanel, + pluginId: 'table', + }); expect(dispatchedActions.length).toBe(2); expect(dispatchedActions[0].type).toBe(panelPluginLoaded.type); expect(dispatchedActions[1].type).toBe(panelModelAndPluginReady.type); expect(sourcePanel.type).toBe('table'); }); + + it('Should apply options and fieldConfig', async () => { + const sourcePanel = new PanelModel({ id: 12, type: 'graph' }); + + await thunkTester({ + plugins: { + panels: {}, + }, + panels: {}, + }) + .givenThunk(changePanelPlugin) + .whenThunkIsDispatched({ + panel: sourcePanel, + pluginId: 'table', + options: { + showHeader: true, + }, + fieldConfig: { + defaults: { + unit: 'short', + }, + overrides: [], + }, + }); + + expect(sourcePanel.options.showHeader).toBe(true); + expect(sourcePanel.fieldConfig.defaults.unit).toBe('short'); + }); }); }); diff --git a/public/app/features/panel/state/actions.ts b/public/app/features/panel/state/actions.ts index d1374145061..4935cfbc1a5 100644 --- a/public/app/features/panel/state/actions.ts +++ b/public/app/features/panel/state/actions.ts @@ -6,6 +6,8 @@ import { panelModelAndPluginReady } from './reducers'; import { LibraryElementDTO } from 'app/features/library-panels/types'; import { toPanelModelLibraryPanel } from 'app/features/library-panels/utils'; import { PanelOptionsChangedEvent, PanelQueriesChangedEvent } from 'app/types/events'; +import { DataTransformerConfig, FieldConfigSource } from '@grafana/data'; +import { getPanelOptionsWithDefaults } from 'app/features/dashboard/state/getPanelOptionsWithDefaults'; export function initPanelState(panel: PanelModel): ThunkResult { return async (dispatch, getStore) => { @@ -29,10 +31,23 @@ export function initPanelState(panel: PanelModel): ThunkResult { }; } -export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkResult { +export interface ChangePanelPluginAndOptionsArgs { + panel: PanelModel; + pluginId: string; + options?: any; + fieldConfig?: FieldConfigSource; + transformations?: DataTransformerConfig[]; +} + +export function changePanelPlugin({ + panel, + pluginId, + options, + fieldConfig, +}: ChangePanelPluginAndOptionsArgs): ThunkResult { return async (dispatch, getStore) => { // ignore action is no change - if (panel.type === pluginId) { + if (panel.type === pluginId && !options && !fieldConfig) { return; } @@ -43,12 +58,28 @@ export function changePanelPlugin(panel: PanelModel, pluginId: string): ThunkRes plugin = await dispatch(loadPanelPlugin(pluginId)); } - const oldKey = panel.key; + let cleanUpKey = panel.key; + + if (panel.type !== pluginId) { + panel.changePlugin(plugin); + } + + if (options || fieldConfig) { + const newOptions = getPanelOptionsWithDefaults({ + plugin, + currentOptions: options || panel.options, + currentFieldConfig: fieldConfig || panel.fieldConfig, + isAfterPluginChange: false, + }); + + panel.options = newOptions.options; + panel.fieldConfig = newOptions.fieldConfig; + panel.configRev++; + } - panel.changePlugin(plugin); panel.generateNewKey(); - dispatch(panelModelAndPluginReady({ key: panel.key, plugin, cleanUpKey: oldKey })); + dispatch(panelModelAndPluginReady({ key: panel.key, plugin, cleanUpKey })); }; } diff --git a/public/app/features/panel/state/getAllSuggestions.test.ts b/public/app/features/panel/state/getAllSuggestions.test.ts new file mode 100644 index 00000000000..7bffe2a6cec --- /dev/null +++ b/public/app/features/panel/state/getAllSuggestions.test.ts @@ -0,0 +1,305 @@ +import { + DataFrame, + FieldType, + getDefaultTimeRange, + LoadingState, + PanelData, + toDataFrame, + VisualizationSuggestion, +} from '@grafana/data'; +import { config } from 'app/core/config'; +import { SuggestionName } from 'app/types/suggestions'; +import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions'; + +jest.unmock('app/core/core'); +jest.unmock('app/features/plugins/plugin_loader'); + +for (const pluginId of panelsToCheckFirst) { + config.panels[pluginId] = { + module: `app/plugins/panel/${pluginId}/module`, + } as any; +} + +class ScenarioContext { + data: DataFrame[] = []; + suggestions: VisualizationSuggestion[] = []; + + setData(scenarioData: DataFrame[]) { + this.data = scenarioData; + + beforeAll(async () => { + await this.run(); + }); + } + + async run() { + const panelData: PanelData = { + series: this.data, + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }; + + this.suggestions = await getAllSuggestions(panelData); + } + + names() { + return this.suggestions.map((x) => x.name); + } +} + +function scenario(name: string, setup: (ctx: ScenarioContext) => void) { + describe(name, () => { + const ctx = new ScenarioContext(); + setup(ctx); + }); +} + +scenario('No series', (ctx) => { + ctx.setData([]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); + }); +}); + +scenario('No rows', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [] }, + { name: 'Max', type: FieldType.number, values: [] }, + ], + }), + ]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); + }); +}); + +scenario('Single frame with time and number field', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] }, + { name: 'Max', type: FieldType.number, values: [1, 10, 50, 2, 5] }, + ], + }), + ]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([ + SuggestionName.LineChart, + SuggestionName.LineChartSmooth, + SuggestionName.AreaChart, + SuggestionName.BarChart, + SuggestionName.Gauge, + SuggestionName.GaugeNoThresholds, + SuggestionName.Stat, + SuggestionName.StatColoredBackground, + SuggestionName.BarGaugeBasic, + SuggestionName.BarGaugeLCD, + SuggestionName.Table, + SuggestionName.StateTimeline, + ]); + }); + + it('Bar chart suggestion should be using timeseries panel', () => { + expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)?.pluginId).toBe('timeseries'); + }); + + it('Stat panels have reduce values disabled', () => { + for (const suggestion of ctx.suggestions) { + if (suggestion.options?.reduceOptions?.values) { + throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); + } + } + }); +}); + +scenario('Single frame with time 2 number fields', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 3, 4, 5] }, + { name: 'ServerA', type: FieldType.number, values: [1, 10, 50, 2, 5] }, + { name: 'ServerB', type: FieldType.number, values: [1, 10, 50, 2, 5] }, + ], + }), + ]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([ + SuggestionName.LineChart, + SuggestionName.LineChartSmooth, + SuggestionName.AreaChartStacked, + SuggestionName.AreaChartStackedPercent, + SuggestionName.BarChartStacked, + SuggestionName.BarChartStackedPercent, + SuggestionName.Gauge, + SuggestionName.GaugeNoThresholds, + SuggestionName.Stat, + SuggestionName.StatColoredBackground, + SuggestionName.PieChart, + SuggestionName.PieChartDonut, + SuggestionName.BarGaugeBasic, + SuggestionName.BarGaugeLCD, + SuggestionName.Table, + SuggestionName.StateTimeline, + ]); + }); + + it('Stat panels have reduceOptions.values disabled', () => { + for (const suggestion of ctx.suggestions) { + if (suggestion.options?.reduceOptions?.values) { + throw new Error(`Suggestion ${suggestion.name} reduce.values set to true when it should be false`); + } + } + }); +}); + +scenario('Single time series with 100 data points', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [...Array(100).keys()] }, + { name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] }, + ], + }), + ]); + + it('should not suggest bar chart', () => { + expect(ctx.suggestions.find((x) => x.name === SuggestionName.BarChart)).toBe(undefined); + }); +}); + +scenario('30 time series with 100 data points', (ctx) => { + ctx.setData( + repeatFrame( + 30, + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [...Array(100).keys()] }, + { name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] }, + ], + }) + ) + ); + + it('should not suggest timeline', () => { + expect(ctx.suggestions.find((x) => x.pluginId === 'state-timeline')).toBe(undefined); + }); +}); + +scenario('50 time series with 100 data points', (ctx) => { + ctx.setData( + repeatFrame( + 50, + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [...Array(100).keys()] }, + { name: 'ServerA', type: FieldType.number, values: [...Array(100).keys()] }, + ], + }) + ) + ); + + it('should not suggest gauge', () => { + expect(ctx.suggestions.find((x) => x.pluginId === 'gauge')).toBe(undefined); + }); +}); + +scenario('Single frame with string and number field', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [ + { name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }, + { name: 'ServerA', type: FieldType.number, values: [1, 2, 3] }, + ], + }), + ]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([ + SuggestionName.BarChart, + SuggestionName.BarChartHorizontal, + SuggestionName.Gauge, + SuggestionName.GaugeNoThresholds, + SuggestionName.Stat, + SuggestionName.StatColoredBackground, + SuggestionName.PieChart, + SuggestionName.PieChartDonut, + SuggestionName.BarGaugeBasic, + SuggestionName.BarGaugeLCD, + SuggestionName.Table, + ]); + }); + + it('Stat/Gauge/BarGauge/PieChart panels to have reduceOptions.values enabled', () => { + for (const suggestion of ctx.suggestions) { + if (suggestion.options?.reduceOptions && !suggestion.options?.reduceOptions?.values) { + throw new Error(`Suggestion ${suggestion.name} reduce.values set to false when it should be true`); + } + } + }); +}); + +scenario('Single frame with string and 2 number field', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [ + { name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }, + { name: 'ServerA', type: FieldType.number, values: [1, 2, 3] }, + { name: 'ServerB', type: FieldType.number, values: [1, 2, 3] }, + ], + }), + ]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([ + SuggestionName.BarChart, + SuggestionName.BarChartStacked, + SuggestionName.BarChartStackedPercent, + SuggestionName.BarChartHorizontal, + SuggestionName.BarChartHorizontalStacked, + SuggestionName.BarChartHorizontalStackedPercent, + SuggestionName.Gauge, + SuggestionName.GaugeNoThresholds, + SuggestionName.Stat, + SuggestionName.StatColoredBackground, + SuggestionName.PieChart, + SuggestionName.PieChartDonut, + SuggestionName.BarGaugeBasic, + SuggestionName.BarGaugeLCD, + SuggestionName.Table, + ]); + }); +}); + +scenario('Single frame with string with only string field', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }], + }), + ]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.StatColoredBackground, SuggestionName.Table]); + }); + + it('Stat panels have reduceOptions.fields set to show all fields', () => { + for (const suggestion of ctx.suggestions) { + if (suggestion.options?.reduceOptions) { + expect(suggestion.options.reduceOptions.fields).toBe('/.*/'); + } + } + }); +}); + +function repeatFrame(count: number, frame: DataFrame): DataFrame[] { + const frames: DataFrame[] = []; + for (let i = 0; i < count; i++) { + frames.push(frame); + } + return frames; +} diff --git a/public/app/features/panel/state/getAllSuggestions.ts b/public/app/features/panel/state/getAllSuggestions.ts new file mode 100644 index 00000000000..c86ee02c68e --- /dev/null +++ b/public/app/features/panel/state/getAllSuggestions.ts @@ -0,0 +1,30 @@ +import { PanelData, VisualizationSuggestion, VisualizationSuggestionsBuilder, PanelModel } from '@grafana/data'; +import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; + +export const panelsToCheckFirst = [ + 'timeseries', + 'barchart', + 'gauge', + 'stat', + 'piechart', + 'bargauge', + 'table', + 'state-timeline', + 'text', + 'dashlist', +]; + +export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): Promise { + const builder = new VisualizationSuggestionsBuilder(data, panel); + + for (const pluginId of panelsToCheckFirst) { + const plugin = await importPanelPlugin(pluginId); + const supplier = plugin.getSuggestionsSupplier(); + + if (supplier) { + supplier.getSuggestionsForData(builder); + } + } + + return builder.getList(); +} diff --git a/public/app/features/panel/state/getOptionSuggestions.ts b/public/app/features/panel/state/getOptionSuggestions.ts new file mode 100644 index 00000000000..09b04d83787 --- /dev/null +++ b/public/app/features/panel/state/getOptionSuggestions.ts @@ -0,0 +1,17 @@ +import { VisualizationSuggestion, PanelModel, PanelPlugin, PanelData } from '@grafana/data'; + +export function getOptionSuggestions( + plugin: PanelPlugin, + panel: PanelModel, + data?: PanelData +): VisualizationSuggestion[] { + // const supplier = plugin.getSuggestionsSupplier(); + + // if (supplier && supplier.getOptionSuggestions) { + // const builder = new VisualizationSuggestionsBuilder(data, panel); + // supplier.getOptionSuggestions(builder); + // return builder.getList(); + // } + + return []; +} diff --git a/public/app/features/panel/state/util.ts b/public/app/features/panel/state/util.ts new file mode 100644 index 00000000000..2a70e521864 --- /dev/null +++ b/public/app/features/panel/state/util.ts @@ -0,0 +1,47 @@ +import { PanelPluginMeta, PluginState } from '@grafana/data'; +import { config } from 'app/core/config'; + +export function getAllPanelPluginMeta(): PanelPluginMeta[] { + const allPanels = config.panels; + + return Object.keys(allPanels) + .filter((key) => allPanels[key]['hideFromList'] === false) + .map((key) => allPanels[key]) + .sort((a: PanelPluginMeta, b: PanelPluginMeta) => a.sort - b.sort); +} + +export function filterPluginList( + pluginsList: PanelPluginMeta[], + searchQuery: string, + current: PanelPluginMeta +): PanelPluginMeta[] { + if (!searchQuery.length) { + return pluginsList.filter((p) => { + if (p.state === PluginState.deprecated) { + return current.id === p.id; + } + return true; + }); + } + + const query = searchQuery.toLowerCase(); + const first: PanelPluginMeta[] = []; + const match: PanelPluginMeta[] = []; + + for (const item of pluginsList) { + if (item.state === PluginState.deprecated && current.id !== item.id) { + continue; + } + + const name = item.name.toLowerCase(); + const idx = name.indexOf(query); + + if (idx === 0) { + first.push(item); + } else if (idx > 0) { + match.push(item); + } + } + + return first.concat(match); +} diff --git a/public/app/features/plugins/admin/__mocks__/mockHelpers.ts b/public/app/features/plugins/admin/__mocks__/mockHelpers.ts index 23d586ba269..14e31fae777 100644 --- a/public/app/features/plugins/admin/__mocks__/mockHelpers.ts +++ b/public/app/features/plugins/admin/__mocks__/mockHelpers.ts @@ -1,3 +1,4 @@ +import { mocked } from 'ts-jest/utils'; import { setBackendSrv } from '@grafana/runtime'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; import { @@ -9,6 +10,7 @@ import { RequestStatus, PluginListDisplayMode, } from '../types'; +import * as permissions from '../permissions'; import remotePluginMock from './remotePlugin.mock'; import localPluginMock from './localPlugin.mock'; import catalogPluginMock from './catalogPlugin.mock'; @@ -98,3 +100,18 @@ export const mockPluginApis = ({ }, }); }; + +type UserAccessTestContext = { + isAdmin: boolean; + isOrgAdmin: boolean; + isDataSourceEditor: boolean; +}; + +jest.mock('../permissions'); + +export function mockUserPermissions(options: UserAccessTestContext): void { + const mock = mocked(permissions); + mock.isDataSourceEditor.mockReturnValue(options.isDataSourceEditor); + mock.isOrgAdmin.mockReturnValue(options.isOrgAdmin); + mock.isGrafanaAdmin.mockReturnValue(options.isAdmin); +} diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx index 65bb88ed27f..55e7c3f29ae 100644 --- a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithDataSource.tsx @@ -3,13 +3,14 @@ import { Button } from '@grafana/ui'; import { addDataSource } from 'app/features/datasources/state/actions'; import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { isDataSourceEditor } from '../../permissions'; import { CatalogPlugin } from '../../types'; type Props = { plugin: CatalogPlugin; }; -export function GetStartedWithDataSource({ plugin }: Props): React.ReactElement { +export function GetStartedWithDataSource({ plugin }: Props): React.ReactElement | null { const dispatch = useDispatch(); const onAddDataSource = useCallback(() => { const meta = { @@ -20,6 +21,10 @@ export function GetStartedWithDataSource({ plugin }: Props): React.ReactElement dispatch(addDataSource(meta)); }, [dispatch, plugin]); + if (!isDataSourceEditor()) { + return null; + } + return (