diff --git a/.betterer.results b/.betterer.results index 52dfa2aa57c..32bd5512dd8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -8,14 +8,14 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/QueryField/QueryField.test.tsx:2976628669": [ [0, 26, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.js:1676554632": [ - [14, 19, 13, "RegExp match", "2409514259"] + "packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.tsx:793800575": [ + [14, 35, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.js:186764954": [ - [14, 19, 13, "RegExp match", "2409514259"] + "packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.tsx:596989456": [ + [14, 35, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js:1734982398": [ - [14, 26, 13, "RegExp match", "2409514259"] + "packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.tsx:3266788928": [ + [14, 56, 13, "RegExp match", "2409514259"] ], "packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.test.js:989353473": [ [15, 17, 13, "RegExp match", "2409514259"] @@ -2907,9 +2907,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], - "public/app/features/alerting/unified/RuleEditor.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/alerting/unified/RuleList.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -6309,22 +6306,12 @@ exports[`better eslint`] = { "public/app/plugins/datasource/prometheus/components/PromExploreExtraField.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/plugins/datasource/prometheus/components/PromLink.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/datasource/prometheus/components/PromLink.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/.betterer.ts b/.betterer.ts index 6d40d7a9179..bf1d956fe3b 100644 --- a/.betterer.ts +++ b/.betterer.ts @@ -2,6 +2,9 @@ import { regexp } from '@betterer/regexp'; import { BettererFileTest } from '@betterer/betterer'; import { ESLint, Linter } from 'eslint'; import { existsSync } from 'fs'; +import { exec } from 'child_process'; +import path from 'path'; +import glob from 'glob'; export default { 'no enzyme tests': () => regexp(/from 'enzyme'/g).include('**/*.test.*'), @@ -22,54 +25,79 @@ function countUndocumentedStories() { }); } +async function findEslintConfigFiles(): Promise { + return new Promise((resolve, reject) => { + glob('**/.eslintrc', (err, files) => { + if (err) { + reject(err); + } + resolve(files); + }); + }); +} + function countEslintErrors() { return new BettererFileTest(async (filePaths, fileTestResult, resolver) => { const { baseDirectory } = resolver; const cli = new ESLint({ cwd: baseDirectory }); - await Promise.all( - filePaths.map(async (filePath) => { - const linterOptions = (await cli.calculateConfigForFile(filePath)) as Linter.Config; + const eslintConfigFiles = await findEslintConfigFiles(); + const eslintConfigMainPaths = eslintConfigFiles.map((file) => path.resolve(path.dirname(file))); - const rules: Partial = { - '@typescript-eslint/no-explicit-any': 'error', - }; + const baseRules: Partial = { + '@typescript-eslint/no-explicit-any': 'error', + }; - const isTestFile = - filePath.endsWith('.test.tsx') || - filePath.endsWith('.test.ts') || - filePath.includes('__mocks__') || - filePath.includes('public/test/'); + const nonTestFilesRules: Partial = { + ...baseRules, + '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }], + }; - if (!isTestFile) { - rules['@typescript-eslint/consistent-type-assertions'] = [ - 'error', - { - assertionStyle: 'never', - }, - ]; - } + // group files by eslint config file + // this will create two file groups for each eslint config file + // one for test files and one for non-test files + const fileGroups: Record = {}; - const runner = new ESLint({ - baseConfig: { - ...linterOptions, - rules, - }, - useEslintrc: false, - cwd: baseDirectory, - }); + for (const filePath of filePaths) { + let configPath = eslintConfigMainPaths.find((configPath) => filePath.startsWith(configPath)) ?? ''; + const isTestFile = + filePath.endsWith('.test.tsx') || + filePath.endsWith('.test.ts') || + filePath.includes('__mocks__') || + filePath.includes('public/test/'); - const lintResults = await runner.lintFiles([filePath]); - lintResults - .filter((lintResult) => lintResult.source) - .forEach((lintResult) => { - const { messages } = lintResult; - const file = fileTestResult.addFile(filePath, ''); - messages.forEach((message, index) => { - file.addIssue(0, 0, message.message, `${index}`); - }); + if (isTestFile) { + configPath += '-test'; + } + if (!fileGroups[configPath]) { + fileGroups[configPath] = []; + } + fileGroups[configPath].push(filePath); + } + + for (const configPath of Object.keys(fileGroups)) { + const rules = configPath.endsWith('-test') ? baseRules : nonTestFilesRules; + // this is by far the slowest part of this code. It takes eslint about 2 seconds just to find the config + const linterOptions = (await cli.calculateConfigForFile(fileGroups[configPath][0])) as Linter.Config; + const runner = new ESLint({ + baseConfig: { + ...linterOptions, + rules: rules, + }, + useEslintrc: false, + cwd: baseDirectory, + }); + const lintResults = await runner.lintFiles(fileGroups[configPath]); + lintResults + .filter((lintResult) => lintResult.source) + .forEach((lintResult) => { + const { messages } = lintResult; + const filePath = lintResult.filePath; + const file = fileTestResult.addFile(filePath, ''); + messages.forEach((message, index) => { + file.addIssue(0, 0, message.message, `${index}`); }); - }) - ); + }); + } }); } diff --git a/.drone.star b/.drone.star index 2504f1a394c..1051ac1918c 100644 --- a/.drone.star +++ b/.drone.star @@ -7,17 +7,45 @@ load('scripts/drone/events/pr.star', 'pr_pipelines') load('scripts/drone/events/main.star', 'main_pipelines') load('scripts/drone/pipelines/docs.star', 'docs_pipelines') -load('scripts/drone/events/release.star', 'oss_pipelines', 'enterprise_pipelines', 'enterprise2_pipelines', 'publish_artifacts_pipelines', 'publish_npm_pipelines', 'publish_packages_pipeline', 'artifacts_page_pipeline') -load('scripts/drone/pipelines/publish_images.star', 'publish_image_pipelines_public', 'publish_image_pipelines_security') +load( + 'scripts/drone/events/release.star', + 'oss_pipelines', + 'enterprise_pipelines', + 'enterprise2_pipelines', + 'publish_artifacts_pipelines', + 'publish_npm_pipelines', + 'publish_packages_pipeline', + 'artifacts_page_pipeline', +) +load( + 'scripts/drone/pipelines/publish_images.star', + 'publish_image_pipelines_public', + 'publish_image_pipelines_security', +) load('scripts/drone/version.star', 'version_branch_pipelines') load('scripts/drone/events/cron.star', 'cronjobs') load('scripts/drone/vault.star', 'secrets') + def main(ctx): - edition = 'oss' - return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + oss_pipelines() + enterprise_pipelines() + enterprise2_pipelines() + \ - enterprise2_pipelines(prefix='custom-', trigger = {'event': ['custom']},) + \ - publish_image_pipelines_public() + publish_image_pipelines_security() + \ - publish_artifacts_pipelines('security') + publish_artifacts_pipelines('public') + \ - publish_npm_pipelines('public') + publish_packages_pipeline() + artifacts_page_pipeline() + \ - version_branch_pipelines() + cronjobs(edition=edition) + secrets() + return ( + pr_pipelines() + + main_pipelines() + + oss_pipelines() + + enterprise_pipelines() + + enterprise2_pipelines() + + enterprise2_pipelines( + prefix='custom-', + trigger={'event': ['custom']}, + ) + + publish_image_pipelines_public() + + publish_image_pipelines_security() + + publish_artifacts_pipelines('security') + + publish_artifacts_pipelines('public') + + publish_npm_pipelines() + + publish_packages_pipeline() + + artifacts_page_pipeline() + + version_branch_pipelines() + + cronjobs() + + secrets() + ) diff --git a/.drone.yml b/.drone.yml index afa440e9753..b8ebfe2ace7 100644 --- a/.drone.yml +++ b/.drone.yml @@ -575,7 +575,7 @@ steps: - failure - commands: - yarn storybook:build - - ./bin/grabpl verify-storybook + - ./bin/build verify-storybook depends_on: - build-frontend - build-frontend-packages @@ -1430,7 +1430,7 @@ steps: - failure - commands: - yarn storybook:build - - ./bin/grabpl verify-storybook + - ./bin/build verify-storybook depends_on: - build-frontend - build-frontend-packages @@ -2155,7 +2155,7 @@ steps: - failure - commands: - yarn storybook:build - - ./bin/grabpl verify-storybook + - ./bin/build verify-storybook depends_on: - build-frontend - build-frontend-packages @@ -3857,6 +3857,10 @@ platform: os: linux services: [] steps: +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15.6 + name: identify-runner - commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl @@ -3949,6 +3953,10 @@ platform: os: linux services: [] steps: +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15.6 + name: identify-runner - commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl @@ -4024,6 +4032,10 @@ platform: os: linux services: [] steps: +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15.6 + name: identify-runner - commands: - mkdir -p bin - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl @@ -4687,7 +4699,7 @@ steps: - failure - commands: - yarn storybook:build - - ./bin/grabpl verify-storybook + - ./bin/build verify-storybook depends_on: - build-frontend - build-frontend-packages @@ -6306,6 +6318,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: e7746a4b35fba9e1a7cb3096b947a874786b082f41e4252448ac6acde7ee3ccf +hmac: dcf24226fae30872050cdc031430374d811e6bbe13158ce0fbf234c90c1d83f9 ... diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 235131251d8..8e2f1bd364e 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -30,6 +30,7 @@ "react-dom", "react-test-renderer" ], + "includePaths": ["package.json", "packages/*"], "ignorePaths": ["packages/grafana-toolkit/package.json", "emails/**", "plugins-bundled/**", "**/mocks/**"], "labels": ["area/frontend", "dependencies", "no-backport", "no-changelog"], "packageRules": [ @@ -79,6 +80,7 @@ "enabled": false }, "prConcurrentLimit": 10, + "rebaseWhen": "conflicted", "reviewers": ["team:grafana/frontend-ops"], "separateMajorMinor": false, "vulnerabilityAlerts": { diff --git a/Makefile b/Makefile index d780ee08657..5c3cf3f5256 100644 --- a/Makefile +++ b/Makefile @@ -233,5 +233,8 @@ drone: $(DRONE) $(DRONE) lint .drone.yml --trusted $(DRONE) --server https://drone.grafana.net sign --save grafana/grafana +format-drone: + black --include '\.star$$' -S scripts/drone/ .drone.star + help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) diff --git a/conf/defaults.ini b/conf/defaults.ini index fd34cb644cc..cedead7db8e 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -162,6 +162,12 @@ type = database # memcache: 127.0.0.1:11211 connstr = +# prefix prepended to all the keys in the remote cache +prefix = + +# This enables encryption of values stored in the remote cache +encryption = + #################################### Data proxy ########################### [dataproxy] diff --git a/conf/sample.ini b/conf/sample.ini index 91ac70a0eef..30e4e65cdcc 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -169,6 +169,12 @@ # memcache: 127.0.0.1:11211 ;connstr = +# prefix prepended to all the keys in the remote cache +; prefix = + +# This enables encryption of values stored in the remote cache +;encryption = + #################################### Data proxy ########################### [dataproxy] diff --git a/docs/sources/administration/data-source-management/index.md b/docs/sources/administration/data-source-management/index.md index 42d56c8fda3..9aaaa39a771 100644 --- a/docs/sources/administration/data-source-management/index.md +++ b/docs/sources/administration/data-source-management/index.md @@ -195,7 +195,7 @@ To view available data source plugins, go to the [plugin catalog](/grafana/plugi For details about the plugin catalog, refer to [Plugin management]({{< relref "../../administration/plugin-management/" >}}). You can further filter the plugin catalog's results for data sources provided by the Grafana community, Grafana Labs, and partners. -If you use [Grafana Enterprise]{{< relref "../../enterprise/" >}}, you can also filter by Enterprise-supported plugins. +If you use [Grafana Enterprise]({{< relref "../../introduction/grafana-enterprise/" >}}), you can also filter by Enterprise-supported plugins. For more documentation on a specific data source plugin's features, including its query language and editor, refer to its plugin catalog page. diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index ee92d91a5cf..9060e7d2517 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -21,7 +21,7 @@ The following tables list permissions associated with basic and fixed roles. | Basic role | Associated fixed roles | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Grafana Admin | `fixed:roles:reader`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:organization:reader`
`fixed:organization:maintainer`
`fixed:licensing:reader`
`fixed:licensing:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Grafana server administrator]({{< relref "../#grafana-server-administrators" >}}) assignments. | -| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:reader`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:reader`
`fixed:dashboards.permissions:writer`
`fixed:folders:reader`
`fixes:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer`
`fixed:alerting:writer`
`fixed:apikeys:reader`
`fixed:apikeys:writer`
`fixed:alerting.provisioning:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Grafana organization administrator]({{< relref "../#organization-users-and-permissions" >}}) assignments. | +| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:reader`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:reader`
`fixed:dashboards.permissions:writer`
`fixed:folders:reader`
`fixed:folders:writer`
`fixed:folders.permissions:reader`
`fixed:folders.permissions:writer`
`fixed:alerting:writer`
`fixed:apikeys:reader`
`fixed:apikeys:writer`
`fixed:alerting.provisioning:writer`
`fixed:datasources.caching:reader`
`fixed:datasources.caching:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Grafana organization administrator]({{< relref "../#organization-users-and-permissions" >}}) assignments. | | Editor | `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled
`fixed:alerting:writer`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Editor]({{< relref "../#organization-users-and-permissions" >}}) assignments. | | Viewer | `fixed:datasources:id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader`
`fixed:plugins.app:reader`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader` | Default [Viewer]({{< relref "../#organization-users-and-permissions" >}}) assignments. | diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md index 29a5b6eac9a..7215f5ab73d 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md @@ -26,13 +26,17 @@ Grafana performs provisioning during startup. After you make a change to the con 1. Sign in to the Grafana server. -2. Locate the Grafana provisioning folder. +1. Locate the Grafana provisioning folder. -3. Create a new YAML in the following folder: **provisioning/access-control**. For example, `provisioning/access-control/custom-roles.yml` +1. Create a new YAML in the following folder: **provisioning/access-control**. For example, `provisioning/access-control/custom-roles.yml` -4. Add RBAC provisioning details to the configuration file. See [manage RBAC roles]({{< relref "./manage-rbac-roles/" >}}) and [assign RBAC roles]({{< relref "./assign-rbac-roles/" >}}) for instructions, and see this [example role provisioning file]({{< relref "./rbac-provisioning/#example" >}}) for a complete example of a provisioning file. +1. Add RBAC provisioning details to the configuration file. -5. Reload the provisioning configuration file. + Refer to [Manage RBAC roles]({{< relref "./manage-rbac-roles/" >}}) and [Assign RBAC roles]({{< relref "./assign-rbac-roles/" >}}) for instructions. + + Refer to [example role provisioning file]({{< relref "#example-role-configuration-file-using-grafana-provisioning" >}}) for a complete example of a provisioning file. + +1. Reload the provisioning configuration file. For more information about reloading the provisioning configuration at runtime, refer to [Reload provisioning configurations]({{< relref "../../../../developers/http_api/admin/#reload-provisioning-configurations" >}}). @@ -46,7 +50,7 @@ The following example shows a complete YAML configuration file that: - Assign roles to teams - Revoke assignments of roles to teams -## Example +### Example ```yaml --- diff --git a/docs/sources/alerting/performance-limitations/index.md b/docs/sources/alerting/performance-limitations/index.md index adb926af43f..73b734f2a9c 100644 --- a/docs/sources/alerting/performance-limitations/index.md +++ b/docs/sources/alerting/performance-limitations/index.md @@ -42,10 +42,10 @@ We support the latest two minor versions of both Prometheus and Alertmanager. We As an example, if the current Prometheus version is `2.31.1`, we support >= `2.29.0`. -## Grafana is not an alert receiver +## The Grafana Alertmanager can only receive Grafana managed alerts -Grafana is not an alert receiver; it is an alert generator. This means that Grafana cannot receive alerts from anything other than its internal alert generator. +Grafana cannot be used to receive external alerts. You can only send alerts to the Grafana Alertmanager using Grafana managed alerts. -Receiving alerts from Prometheus (or anything else) is not supported at the time. +You have the option to send Grafana managed alerts to an external Alertmanager, you can find this option in the admin tab on the Alerting page. -For more information, refer to [this GitHub discussion](https://github.com/grafana/grafana/discussions/45773). +For more information, refer to [this GitHub discussion](https://github.com/grafana/grafana/discussions/45773). To learn more about the different Alertmanagers, read [this documentation]({{< relref "../alerting/manage-notifications/alertmanager/" >}}) diff --git a/docs/sources/datasources/alertmanager/_index.md b/docs/sources/datasources/alertmanager/_index.md index 120c4cc3dc7..dfacbf4a668 100644 --- a/docs/sources/datasources/alertmanager/_index.md +++ b/docs/sources/datasources/alertmanager/_index.md @@ -17,17 +17,22 @@ weight: 150 # Alertmanager data source -Grafana includes built-in support for Prometheus Alertmanager. Once you add it as a data source, you can use the [Grafana Alerting UI](/docs/grafana/latest/alerting/) to manage silences, contact points as well as notification policies. A drop-down option in these pages allows you to switch between Grafana and any configured Alertmanager data sources. +Grafana includes built-in support for Alertmanager implementations in Prometheus and Mimir. +Once you add it as a data source, you can use the [Grafana Alerting UI](/docs/grafana/latest/alerting/) to manage silences, contact points, and notification policies. +To switch between Grafana and any configured Alertmanager data sources, you can select your preference from a drop-down option in those databases' data source settings pages. ## Alertmanager implementations -[Prometheus](https://prometheus.io/) and [Grafana Mimir](/docs/mimir/latest/) (default) implementations of Alertmanager are supported. You can specify implementation in the data source settings page. In case of Prometheus contact points and notification policies are read-only in the Grafana Alerting UI, as it does not support updating configuration via HTTP API. +The data source supports [Prometheus](https://prometheus.io/) and [Grafana Mimir](https://grafana.com/docs/mimir/latest/) (default) implementations of Alertmanager. +You can specify the implementation in the data source's Settings page. +When using Prometheus, contact points and notification policies are read-only in the Grafana Alerting UI, because it doesn't support updates to the configuration using HTTP API. -## Provision the data source +## Provision the Alertmanager data source -Configure the Alertmanager data sources by updating Grafana's configuration files. For more information on how it works and the settings available, refer to the [provisioning docs page]({{< relref "../../administration/provisioning#data-sources" >}}). +You can provision Alertmanager data sources by updating Grafana's configuration files. +For more information on provisioning, and common settings available, refer to the [provisioning docs page]({{< relref "../administration/provisioning/#datasources" >}}). -For example, this YAML provisions an Alertmanager data source running on port 9093, with proxy access and basic authentication: +Here is an example for provisioning the Alertmanager data source: ```yaml apiVersion: 1 @@ -38,6 +43,8 @@ datasources: url: http://localhost:9093 access: proxy jsonData: + # Options for implementation include prometheus and mimir + implementation: prometheus # optionally basicAuth: true basicAuthUser: my_user diff --git a/docs/sources/datasources/google-cloud-monitoring/_index.md b/docs/sources/datasources/google-cloud-monitoring/_index.md index 49fe7d0ff44..26482b21147 100644 --- a/docs/sources/datasources/google-cloud-monitoring/_index.md +++ b/docs/sources/datasources/google-cloud-monitoring/_index.md @@ -38,7 +38,7 @@ Once you've added the Google Cloud Monitoring data source, you can [configure it 1. Hover the cursor over the **Configuration** (gear) icon. 1. Select **Data Sources**. -1. Select the AWS CloudWatch data source. +1. Select the **Google Cloud Monitoring** data source. Set the data source's basic configuration options carefully: diff --git a/docs/sources/datasources/prometheus/_index.md b/docs/sources/datasources/prometheus/_index.md index a275990a658..e6e0394dcc6 100644 --- a/docs/sources/datasources/prometheus/_index.md +++ b/docs/sources/datasources/prometheus/_index.md @@ -43,21 +43,28 @@ For more information on how to query other Prometheus-compatible projects from G Set the data source's basic configuration options carefully: -| Name | Description | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Name** | Sets the name you use to refer to the data source in panels and queries. | -| **Default** | Sets whether the data source is pre-selected for new panels. | -| **Url** | Sets the URL of your Prometheus server, such as `http://prometheus.example.org:9090`. | -| **Access** | Only Server access mode is functional. If Server mode is already selected, this option is hidden. Otherwise, change this to Server mode to prevent errors. | -| **Basic Auth** | Enables basic authentication to the Prometheus data source. | -| **User** | Sets the user name for basic authentication. | -| **Password** | Sets the password for basic authentication. | -| **Scrape interval** | Sets the scrape and evaluation interval. We recommend the same value as the typical configured in Prometheus. Defaults to 15s. | -| **Type** | Defines the type of your Prometheus server. Valid values are `Prometheus`, `Cortex`, `Thanos`, `Mimir`. When selected, the Prometheus version field attempts to detect the version automatically using the Prometheus [buildinfo](https://semver.org/) API. Some Prometheus types, such as Cortex, don't support this API, and you must provide their version. | -| **Version** | Defines the version of your Prometheus server. This field is visible only after the **Type** field is defined. | -| **HTTP method** | Sets the HTTP method used to query your data source. We recommend POST, which is pre-selected, because it allows for larger queries. Use GET if the Prometheus version is older than 2.1, or if POST requests are restricted in your network. | -| **Disable metrics lookup** | Disables the metrics chooser and metric/label support in the query field's autocompletion. This can prevent performance issues with larger Prometheus instances. | -| **Custom query parameters** | Adds custom parameters to the Prometheus query URL, such as `timeout`, `partial_response`, `dedup`, or `max_source_resolution`. Concatenate multiple parameters with '&'. | +| Name | Description | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Name` | The data source name. This is how you refer to the data source in panels and queries. | +| `Default` | Default data source that is pre-selected for new panels. | +| `URL` | The URL of your Prometheus server, for example, `http://prometheus.example.org:9090`. | +| `Access` | Only Server access mode is functional. If Server mode is already selected this option is hidden. Otherwise change to Server mode to prevent errors. | +| `Basic Auth` | Enable basic authentication to the Prometheus data source. | +| `User` | User name for basic authentication. | +| `Password` | Password for basic authentication. | +| `Manage alerts via Alerting UI` | Toggle whether to enable Alertmanager integration for this data source. | +| `Scrape interval` | Set this to the typical scrape and evaluation interval configured in Prometheus. Defaults to 15s. | +| `HTTP method` | Use either POST or GET HTTP method to query your data source. POST is the recommended and pre-selected method as it allows bigger queries. Change this to GET if you have a Prometheus version older than 2.1 or if POST requests are restricted in your network. | +| `Type` | The type of your Prometheus server; `Prometheus`, `Cortex`, `Thanos`, `Mimir`. When selected, the **Version** field attempts to populate automatically using the Prometheus [buildinfo](https://semver.org/) API. Some Prometheus types, such as Cortex, don't support this API and must be manually populated. | +| `Version` | The version of your Prometheus server, note that this field is not visible until the Prometheus type is selected. | +| `Disable metrics lookup` | Checking this option will disable the metrics chooser and metric/label support in the query field's autocomplete. This helps if you have performance issues with bigger Prometheus instances. | +| `Custom query parameters` | Add custom parameters to the Prometheus query URL. For example `timeout`, `partial_response`, `dedup`, or `max_source_resolution`. Multiple parameters should be concatenated together with an '&'. | +| **Exemplars configuration** | | +| `Internal link` | Enable this option is you have an internal link. When you enable this option, you will see a data source selector. Select the backend tracing data store for your exemplar data. | +| `Data source` | You will see this option only if you enable `Internal link` option. Select the backend tracing data store for your exemplar data. | +| `URL` | You will see this option only if the `Internal link` option is disabled. Enter the full URL of the external link. You can interpolate the value from the field with `${__value.raw }` macro. | +| `URL Label` | (Optional) add a custom display label to override the value of the `Label name` field. | +| `Label name` | Add a name for the exemplar traceID property. | **Exemplars configuration:** @@ -87,6 +94,9 @@ datasources: url: http://localhost:9090 jsonData: httpMethod: POST + manageAlerts: true + prometheusType: Prometheus + prometheusVersion: 2.37.0 exemplarTraceIdDestinations: # Field with internal link pointing to data source in Grafana. # datasourceUid value can be anything, but it should be unique across all defined data source uids. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index bd44bd27723..e5ed210d965 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -53,6 +53,7 @@ Alpha features might be changed or removed without prior notice. | Feature toggle name | Description | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `returnUnameHeader` | Return user login as header for authenticated requests | | `alertingBigTransactions` | Use big transactions for alerting database writes | | `dashboardPreviews` | Create and show thumbnails for dashboard search results | | `live-config` | Save Grafana Live configuration in SQL tables | @@ -89,8 +90,10 @@ Alpha features might be changed or removed without prior notice. | `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema | | `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query | | `elasticsearchBackendMigration` | Use Elasticsearch as backend data source | +| `datasourceOnboarding` | Enable data source onboarding page | | `secureSocksDatasourceProxy` | Enable secure socks tunneling for supported core datasources | | `authnService` | Use new auth service to perform authentication | +| `sessionRemoteCache` | Enable using remote cache for user sessions | ## Development feature toggles diff --git a/docs/sources/setup-grafana/configure-security/export-logs.md b/docs/sources/setup-grafana/configure-security/export-logs.md index 89baf385275..6d44b982dbb 100644 --- a/docs/sources/setup-grafana/configure-security/export-logs.md +++ b/docs/sources/setup-grafana/configure-security/export-logs.md @@ -44,6 +44,7 @@ Logs of usage insights contain the following fields, where the fields followed b | `panelName` | string | Name of the panel of the query. | | `error` | string | Error returned by the query. | | `duration` | number | Duration of the query. | +| `source` | string | Source of the query. For example, `dashboard` or `explore`. | | `orgId`\* | number | ID of the user’s organization. | | `orgName`\* | string | Name of the user’s organization. | | `timestamp`\* | string | The date and time that the request was made, in Coordinated Universal Time (UTC) in [RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format. | diff --git a/go.mod b/go.mod index 16fb97f6a2f..1fe70309c60 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/grafana/cuetsy v0.1.1 github.com/grafana/grafana-aws-sdk v0.11.0 github.com/grafana/grafana-azure-sdk-go v1.3.1 - github.com/grafana/grafana-plugin-sdk-go v0.142.0 + github.com/grafana/grafana-plugin-sdk-go v0.145.0 github.com/grafana/thema v0.0.0-20221113112305-b441ed85a1fd github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/hashicorp/go-hclog v1.0.0 @@ -121,7 +121,7 @@ require ( gopkg.in/ldap.v3 v3.1.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/square/go-jose.v2 v2.5.1 - gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 xorm.io/builder v0.3.6 xorm.io/core v0.7.3 @@ -304,7 +304,11 @@ require ( github.com/segmentio/asm v1.1.4 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/spf13/cast v1.3.1 // indirect + github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect + github.com/unknwon/com v1.0.1 // indirect + github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect go.starlark.net v0.0.0-20221020143700-22309ac47eac // indirect + gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect ) require ( diff --git a/go.sum b/go.sum index 91cfa8ec3a9..48560c53758 100644 --- a/go.sum +++ b/go.sum @@ -1342,6 +1342,8 @@ github.com/gophercloud/gophercloud v0.18.0/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075 github.com/gophercloud/gophercloud v0.20.0/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075O6i+LY+pXsKCBsb4= github.com/gophercloud/gophercloud v0.24.0 h1:jDsIMGJ1KZpAjYfQgGI2coNQj5Q83oPzuiGJRFWgMzw= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de h1:F7WD09S8QB4LrkEpka0dFPLSotH11HRpCsLIbIcJ7sU= github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= @@ -1376,8 +1378,8 @@ github.com/grafana/grafana-azure-sdk-go v1.3.1/go.mod h1:rgrnK9m6CgKlgx4rH3FFP/6 github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58 h1:2ud7NNM7LrGPO4x0NFR8qLq68CqI4SmB7I2yRN2w9oE= github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= -github.com/grafana/grafana-plugin-sdk-go v0.142.0 h1:fDgA0EmWWy5+/7nX7fdHBfADR6pWuR1TZA5QL36VX7U= -github.com/grafana/grafana-plugin-sdk-go v0.142.0/go.mod h1:srvRQ+de4C5h7FqA5lSFUkFCs5pJolWT+PGV2AyBOFk= +github.com/grafana/grafana-plugin-sdk-go v0.145.0 h1:ZlRxxV3C6RA+wNWeGr+rLVD70pgsZwiLI9etzE0zu+Q= +github.com/grafana/grafana-plugin-sdk-go v0.145.0/go.mod h1:dFof/7GenWBFTmrfcPRCpLau7tgIED0ykzupWAlB0o0= github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293 h1:dJIdfHqu+XjKz+w9zXLqXKPdp6Jjx/UPSOwdeSfWdeQ= github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293/go.mod h1:HVHqK+BVPa/tmL8EMhLCCrPt2a1GdJpEyxr5hgur2UI= github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc h1:1PY8n+rXuBNr3r1JQhoytWDCpc+pq+BibxV0SZv+Cr4= @@ -1665,6 +1667,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jsternberg/zap-logfmt v1.0.0/go.mod h1:uvPs/4X51zdkcm5jXl5SYoN+4RK21K8mysFmDaM/h+o= github.com/jsternberg/zap-logfmt v1.2.0/go.mod h1:kz+1CUmCutPWABnNkOu9hOHKdT2q3TDYCcsFy9hpqb0= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= @@ -2309,8 +2313,12 @@ github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/snowflakedb/gosnowflake v1.3.4/go.mod h1:NsRq2QeiMUuoNUJhp5Q6xGC4uBrsS9g6LwZVEkTWgsE= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= @@ -2435,6 +2443,12 @@ github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= +github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8/go.mod h1:fVle4kNr08ydeohzYafr20oZzbAkhQT39gKK/pFQ5M4= +github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= +github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= +github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 h1:4EYQaWAatQokdji3zqZloVIW/Ke1RQjYw2zHULyrHJg= +github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= @@ -2979,6 +2993,7 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/kinds/gen.go b/kinds/gen.go index 71ff849e8da..8a16ebc3858 100644 --- a/kinds/gen.go +++ b/kinds/gen.go @@ -30,7 +30,7 @@ func main() { // Core kinds composite code generator. Produces all generated code in // grafana/grafana that derives from raw and structured core kinds. coreKindsGen := codejen.JennyListWithNamer(func(decl *codegen.DeclForGen) string { - return decl.Meta.Common().MachineName + return decl.Properties.Common().MachineName }) // All the jennies that comprise the core kinds generator pipeline @@ -63,12 +63,12 @@ func main() { continue } rel := filepath.Join(kindsys.CoreStructuredDeclParentPath, ent.Name()) - decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rel, rt.Context(), nil) + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredProperties](rel, rt.Context(), nil) if err != nil { die(fmt.Errorf("%s is not a valid kind: %s", rel, errors.Details(err, nil))) } - if decl.Meta.MachineName != ent.Name() { - die(fmt.Errorf("%s: kind's machine name (%s) must equal parent dir name (%s)", rel, decl.Meta.Name, ent.Name())) + if decl.Properties.MachineName != ent.Name() { + die(fmt.Errorf("%s: kind's machine name (%s) must equal parent dir name (%s)", rel, decl.Properties.Name, ent.Name())) } all = append(all, elsedie(codegen.ForGen(rt, decl.Some()))(rel)) @@ -82,19 +82,19 @@ func main() { continue } rel := filepath.Join(kindsys.RawDeclParentPath, ent.Name()) - decl, err := kindsys.LoadCoreKind[kindsys.RawMeta](rel, rt.Context(), nil) + decl, err := kindsys.LoadCoreKind[kindsys.RawProperties](rel, rt.Context(), nil) if err != nil { die(fmt.Errorf("%s is not a valid kind: %s", rel, errors.Details(err, nil))) } - if decl.Meta.MachineName != ent.Name() { - die(fmt.Errorf("%s: kind's machine name (%s) must equal parent dir name (%s)", rel, decl.Meta.Name, ent.Name())) + if decl.Properties.MachineName != ent.Name() { + die(fmt.Errorf("%s: kind's machine name (%s) must equal parent dir name (%s)", rel, decl.Properties.Name, ent.Name())) } dfg, _ := codegen.ForGen(nil, decl.Some()) all = append(all, dfg) } sort.Slice(all, func(i, j int) bool { - return nameFor(all[i].Meta) < nameFor(all[j].Meta) + return nameFor(all[i].Properties) < nameFor(all[j].Properties) }) jfs, err := coreKindsGen.GenerateFS(all...) @@ -111,18 +111,18 @@ func main() { } } -func nameFor(m kindsys.SomeKindMeta) string { +func nameFor(m kindsys.SomeKindProperties) string { switch x := m.(type) { - case kindsys.RawMeta: + case kindsys.RawProperties: return x.Name - case kindsys.CoreStructuredMeta: + case kindsys.CoreStructuredProperties: return x.Name - case kindsys.CustomStructuredMeta: + case kindsys.CustomStructuredProperties: return x.Name - case kindsys.ComposableMeta: + case kindsys.ComposableProperties: return x.Name default: - // unreachable so long as all the possibilities in KindMetas have switch branches + // unreachable so long as all the possibilities in KindProperties have switch branches panic("unreachable") } } diff --git a/package.json b/package.json index d9a207d5128..044f5c0a9ae 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,6 @@ "@testing-library/user-event": "14.4.3", "@types/angular": "1.8.4", "@types/angular-route": "1.7.2", - "@types/classnames": "2.3.0", "@types/common-tags": "^1.8.0", "@types/d3": "7.4.0", "@types/d3-force": "^2.1.0", @@ -121,6 +120,7 @@ "@types/enzyme-adapter-react-16": "1.0.6", "@types/eslint": "8.4.9", "@types/file-saver": "2.0.5", + "@types/glob": "^8.0.0", "@types/google.analytics": "^0.0.42", "@types/gtag.js": "^0.0.12", "@types/history": "4.7.11", @@ -137,7 +137,6 @@ "@types/papaparse": "5.3.5", "@types/pluralize": "^0.0.29", "@types/prismjs": "1.26.0", - "@types/rc-time-picker": "3.4.1", "@types/react": "17.0.42", "@types/react-beautiful-dnd": "13.1.2", "@types/react-dom": "17.0.14", @@ -152,13 +151,11 @@ "@types/react-window": "1.8.5", "@types/react-window-infinite-loader": "^1", "@types/redux-mock-store": "1.0.3", - "@types/reselect": "2.2.0", "@types/semver": "7.3.13", "@types/slate": "0.47.11", "@types/slate-plain-serializer": "0.7.2", "@types/slate-react": "0.22.9", "@types/testing-library__jest-dom": "5.14.5", - "@types/testing-library__react-hooks": "^3.2.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "8.3.4", "@typescript-eslint/eslint-plugin": "5.42.0", @@ -279,11 +276,10 @@ "@react-stately/collections": "3.4.1", "@react-stately/menu": "3.4.1", "@react-stately/tree": "3.3.1", - "@reduxjs/toolkit": "1.8.6", + "@reduxjs/toolkit": "1.9.1", "@sentry/browser": "6.19.7", "@sentry/types": "6.19.7", "@sentry/utils": "6.19.7", - "@types/rc-tree": "^3.0.0", "@types/react-resizable": "3.0.3", "@types/webpack-env": "1.18.0", "@visx/event": "2.6.0", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index d3145ef5b35..6f0db335307 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -78,7 +78,6 @@ "@types/react-dom": "17.0.14", "@types/sinon": "10.0.13", "@types/testing-library__jest-dom": "5.14.5", - "@types/testing-library__react-hooks": "^3.2.0", "@types/tinycolor2": "1.4.3", "esbuild": "0.15.12", "react": "17.0.2", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 518bf5e1c87..eedcf76298e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -16,6 +16,7 @@ export interface FeatureToggles { [name: string]: boolean | undefined; // support any string value + returnUnameHeader?: boolean; alertingBigTransactions?: boolean; promQueryBuilder?: boolean; trimDefaults?: boolean; @@ -81,6 +82,8 @@ export interface FeatureToggles { nestedFolders?: boolean; accessTokenExpirationCheck?: boolean; elasticsearchBackendMigration?: boolean; + datasourceOnboarding?: boolean; secureSocksDatasourceProxy?: boolean; authnService?: boolean; + sessionRemoteCache?: boolean; } diff --git a/packages/grafana-e2e/cypress.json b/packages/grafana-e2e/cypress.json index eaa48eb162f..7eafaf4bf2b 100644 --- a/packages/grafana-e2e/cypress.json +++ b/packages/grafana-e2e/cypress.json @@ -1,5 +1,7 @@ { "projectId": "zb7k1c", "supportFile": "cypress/support/index.ts", - "videoCompression": 20 + "videoCompression": 20, + "viewportWidth": 1920, + "viewportHeight": 1080 } diff --git a/packages/grafana-e2e/src/flows/addDashboard.ts b/packages/grafana-e2e/src/flows/addDashboard.ts index 36dca49b86b..a8c02d8e1fe 100644 --- a/packages/grafana-e2e/src/flows/addDashboard.ts +++ b/packages/grafana-e2e/src/flows/addDashboard.ts @@ -236,9 +236,9 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2() .should('be.visible') .within(() => { - e2e.components.Select.singleValue().should('have.text', 'Query').click(); + e2e.components.Select.singleValue().should('have.text', 'Query').parent().click(); }); - e2e.components.Select.option().should('be.visible').contains(type).click(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().find('input').type(`${type}{enter}`); } if (label) { diff --git a/packages/grafana-runtime/src/analytics/types.ts b/packages/grafana-runtime/src/analytics/types.ts index 2a7ef01504e..5f6812ead7e 100644 --- a/packages/grafana-runtime/src/analytics/types.ts +++ b/packages/grafana-runtime/src/analytics/types.ts @@ -1,3 +1,5 @@ +import { CoreApp } from '@grafana/data'; + import { EchoEvent, EchoEventType } from '../services/EchoSrv'; /** @@ -20,6 +22,7 @@ export interface DashboardInfo { * @public */ export interface DataRequestInfo extends Partial { + source?: CoreApp | string; datasourceName: string; datasourceId: number; datasourceUid: string; diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh index 75d9038304a..8a99e1e649b 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/common.sh @@ -5,4 +5,4 @@ ## Find the latest tags on https://hub.docker.com/r/grafana/grafana-plugin-ci/tags?page=1&name=alpine ## -DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.6.0-alpine" +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.6.1-alpine" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh index 29325b5c380..94345950078 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh @@ -18,8 +18,8 @@ apk add --no-cache curl npm yarn build-base openssh git-lfs perl-utils coreutils # apk add --no-cache xvfb glib nss nspr gdk-pixbuf "gtk+3.0" pango atk cairo dbus-libs libxcomposite libxrender libxi libxtst libxrandr libxscrnsaver alsa-lib at-spi2-atk at-spi2-core cups-libs gcompat libc6-compat # Install Go -filename="go1.19.3.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "74b9640724fd4e6bb0ed2a1bc44ae813a03f1e72a4c76253e2d5c015494430ba" +filename="go1.19.4.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "c9c08f783325c4cf840a94333159cc937f05f75d36a8b307951d5bd959cf2ab8" untar_file "/tmp/$filename" # Install golangci-lint diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh index c1f4fad2b7f..29714cd5a84 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/common.sh @@ -6,5 +6,5 @@ ## DOCKER_IMAGE_BASE_NAME="grafana/grafana-plugin-ci-e2e" -DOCKER_IMAGE_VERSION="1.6.0" +DOCKER_IMAGE_VERSION="1.6.1" DOCKER_IMAGE_NAME="${DOCKER_IMAGE_BASE_NAME}:${DOCKER_IMAGE_VERSION}" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh index 16ec89b7560..99e711aee87 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh @@ -22,8 +22,8 @@ source "/etc/profile" npm i -g yarn # Install Go -filename="go1.19.3.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "74b9640724fd4e6bb0ed2a1bc44ae813a03f1e72a4c76253e2d5c015494430ba" +filename="go1.19.4.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "c9c08f783325c4cf840a94333159cc937f05f75d36a8b307951d5bd959cf2ab8" untar_file "/tmp/$filename" # Install golangci-lint diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh index f0143a2a393..47d2b068d8d 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/common.sh @@ -5,4 +5,4 @@ ## Find the latest tags on https://hub.docker.com/r/grafana/grafana-plugin-ci/tags ## -DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.6.0" +DOCKER_IMAGE_NAME="grafana/grafana-plugin-ci:1.6.1" diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh index 1ad7002b8cb..3ee0bff1e16 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh @@ -2,8 +2,8 @@ source "./deploy-common.sh" # Install Go -filename="go1.19.3.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "74b9640724fd4e6bb0ed2a1bc44ae813a03f1e72a4c76253e2d5c015494430ba" +filename="go1.19.4.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "c9c08f783325c4cf840a94333159cc937f05f75d36a8b307951d5bd959cf2ab8" untar_file "/tmp/$filename" # Install golangci-lint diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index dd46eaef2fa..352c70eb73e 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -140,7 +140,6 @@ "@testing-library/react": "12.1.4", "@testing-library/react-hooks": "8.0.1", "@testing-library/user-event": "14.4.3", - "@types/classnames": "2.3.0", "@types/common-tags": "^1.8.0", "@types/d3": "7.4.0", "@types/enzyme": "3.10.12", @@ -168,7 +167,6 @@ "@types/slate-plain-serializer": "0.7.2", "@types/slate-react": "0.22.9", "@types/testing-library__jest-dom": "5.14.5", - "@types/testing-library__react-hooks": "^3.2.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "8.3.4", "@wojtekmaj/enzyme-adapter-react-17": "0.7.0", diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx index 2c9e721d332..228da8ffe24 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx @@ -7,7 +7,6 @@ import { stylesFactory, useTheme2 } from '../../../themes'; import { isCompactUrl } from '../../../utils/dataLinks'; import { FieldValidationMessage } from '../../Forms/FieldValidationMessage'; import { IconButton } from '../../IconButton/IconButton'; -import { HorizontalGroup, VerticalGroup } from '../../Layout/Layout'; export interface DataLinksListItemProps { index: number; @@ -31,26 +30,24 @@ export const DataLinksListItem: FC = ({ link, onEdit, on return (
- - -
- {hasTitle ? title : 'Data link title not provided'} -
- - - - -
-
- {hasUrl ? url : 'Data link url not provided'} +
+
+ {hasTitle ? title : 'Data link title not provided'}
- {isCompactExploreUrl && ( - Explore data link may not work in the future. Please edit. - )} - +
+ + +
+
+
+ {hasUrl ? url : 'Data link url not provided'} +
+ {isCompactExploreUrl && ( + Explore data link may not work in the future. Please edit. + )}
); }; @@ -63,6 +60,19 @@ const getDataLinkListItemStyles = stylesFactory((theme: GrafanaTheme2) => { &:last-child { margin-bottom: 0; } + display: flex; + flex-direction: column; + `, + titleWrapper: css` + label: data-links-list-item-title; + justify-content: space-between; + display: flex; + width: 100%; + align-items: center; + `, + actionButtons: css` + margin-left: ${theme.spacing(1)}; + display: flex; `, errored: css` color: ${theme.colors.error.text}; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index 0f9b89da1c2..8cdb82af782 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -127,8 +127,8 @@ export function TimeRangePicker(props: TimeRangePickerProps) { {isOpen && ( <>
- -
+
+ -
- + +
)} diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx index 0bf5ad1cade..65f106b8f98 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx @@ -160,7 +160,6 @@ const NarrowScreenForm = (props: FormProps) => {
-

{showHistory && ( mapRangeToTimeOption(range, timeZone)); + + return ranges.map((range) => mapRangeToTimeOption(range, timeZone)); } EmptyRecentList.displayName = 'EmptyRecentList'; diff --git a/packages/grafana-ui/src/components/Dropdown/ButtonSelect.test.tsx b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.test.tsx new file mode 100644 index 00000000000..f1c5c63a055 --- /dev/null +++ b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { SelectableValue } from '@grafana/data'; + +import { ButtonSelect } from './ButtonSelect'; + +const OPTIONS: SelectableValue[] = [ + { + label: 'Hello', + value: 'a', + }, + { + label: 'World', + value: 'b', + }, +]; + +describe('ButtonSelect', () => { + it('initially renders the selected value with the menu closed', () => { + const selected = OPTIONS[0]; + render( {}} />); + + expect(screen.getByText('Hello')).toBeInTheDocument(); + expect(screen.queryAllByRole('menuitemradio')).toHaveLength(0); + }); + + it('opens the menu when clicking the button', async () => { + const selected = OPTIONS[0]; + render( {}} />); + + const button = screen.getByText('Hello'); + await userEvent.click(button); + + expect(screen.queryAllByRole('menuitemradio')).toHaveLength(2); + }); + + it('closes the menu when clicking an option', async () => { + const selected = OPTIONS[0]; + const onChange = jest.fn(); + render(); + + const button = screen.getByText('Hello'); + await userEvent.click(button); + + const option = screen.getByText('World'); + await userEvent.click(option); + + expect(screen.queryAllByRole('menuitemradio')).toHaveLength(0); + expect(onChange).toHaveBeenCalledWith({ + label: 'World', + value: 'b', + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Monaco/theme.ts b/packages/grafana-ui/src/components/Monaco/theme.ts index d073f40377e..d5d14dd3e30 100644 --- a/packages/grafana-ui/src/components/Monaco/theme.ts +++ b/packages/grafana-ui/src/components/Monaco/theme.ts @@ -1,3 +1,5 @@ +import tinycolor from 'tinycolor2'; + import { GrafanaTheme2 } from '@grafana/data'; import { Monaco, monacoTypes } from './types'; @@ -6,13 +8,24 @@ function getColors(theme?: GrafanaTheme2): monacoTypes.editor.IColors { if (theme === undefined) { return {}; } else { - return { + const colors: Record = { 'editor.background': theme.components.input.background, 'minimap.background': theme.colors.background.secondary, }; + + Object.keys(colors).forEach((resultKey) => { + colors[resultKey] = normalizeColorForMonaco(colors[resultKey]); + }); + return colors; } } +function normalizeColorForMonaco(color?: string): string { + // monaco needs 6char hex colors + // see https://github.com/grafana/grafana/issues/43158 + return tinycolor(color).toHexString(); +} + // 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) { @@ -24,9 +37,9 @@ export default function defineThemes(monaco: Monaco, theme?: GrafanaTheme2) { colors: colors, // fallback syntax highlighting for languages that microsoft doesn't handle (ex cloudwatch's metric math) rules: [ - { token: 'predefined', foreground: theme?.visualization.getColorByName('purple') }, - { token: 'operator', foreground: theme?.visualization.getColorByName('orange') }, - { token: 'tag', foreground: theme?.visualization.getColorByName('green') }, + { token: 'predefined', foreground: normalizeColorForMonaco(theme?.visualization.getColorByName('purple')) }, + { token: 'operator', foreground: normalizeColorForMonaco(theme?.visualization.getColorByName('orange')) }, + { token: 'tag', foreground: normalizeColorForMonaco(theme?.visualization.getColorByName('green')) }, ], }); @@ -36,9 +49,9 @@ export default function defineThemes(monaco: Monaco, theme?: GrafanaTheme2) { colors: colors, // fallback syntax highlighting for languages that microsoft doesn't handle (ex cloudwatch's metric math) rules: [ - { token: 'predefined', foreground: theme?.visualization.getColorByName('purple') }, - { token: 'operator', foreground: theme?.visualization.getColorByName('orange') }, - { token: 'tag', foreground: theme?.visualization.getColorByName('green') }, + { token: 'predefined', foreground: normalizeColorForMonaco(theme?.visualization.getColorByName('purple')) }, + { token: 'operator', foreground: normalizeColorForMonaco(theme?.visualization.getColorByName('orange')) }, + { token: 'tag', foreground: normalizeColorForMonaco(theme?.visualization.getColorByName('green')) }, ], }); } diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 839935cd1e5..f3c8a3824d4 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -39,7 +39,7 @@ type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData; type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData; export class UPlotConfigBuilder { - private series: UPlotSeriesBuilder[] = []; + series: UPlotSeriesBuilder[] = []; private axes: Record = {}; private scales: UPlotScaleBuilder[] = []; private bands: Band[] = []; diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index 151c54674f4..63438c1c2a8 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -13,15 +13,14 @@ "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.4", "@testing-library/user-event": "14.4.3", - "@types/classnames": "^2.2.7", "@types/deep-freeze": "^0.1.1", + "@types/enzyme": "3.10.12", "@types/hoist-non-react-statics": "^3.3.1", "@types/jest": "29.2.3", "@types/lodash": "4.14.187", "@types/prop-types": "15.7.5", "@types/react": "17.0.42", "@types/react-icons": "2.2.7", - "@types/reselect": "2.2.0", "@types/slate-react": "0.22.9", "@types/testing-library__jest-dom": "5.14.5", "@types/tinycolor2": "1.4.3", diff --git a/packages/jaeger-ui-components/src/ScrollManager.test.js b/packages/jaeger-ui-components/src/ScrollManager.test.ts similarity index 71% rename from packages/jaeger-ui-components/src/ScrollManager.test.js rename to packages/jaeger-ui-components/src/ScrollManager.test.ts index f09cebe7c25..7f7e1368279 100644 --- a/packages/jaeger-ui-components/src/ScrollManager.test.js +++ b/packages/jaeger-ui-components/src/ScrollManager.test.ts @@ -14,27 +14,30 @@ jest.mock('./scroll-page'); -import ScrollManager from './ScrollManager'; +import traceGenerator from '../src/demo/trace-generators'; + +import ScrollManager, { Accessors } from './ScrollManager'; import { scrollBy, scrollTo } from './scroll-page'; +import { Trace, TraceSpanData, TraceSpanReference } from './types/trace'; const SPAN_HEIGHT = 2; -function getTrace() { - const spans = []; - const trace = { - spans, - duration: 2000, - startTime: 1000, - }; - for (let i = 0; i < 10; i++) { - spans.push({ duration: 1, startTime: 1000, spanID: i + 1 }); - } - return trace; +function getTrace(): Trace { + const generatedTrace = traceGenerator.trace({ numberOfSpans: 10 }); + generatedTrace.duration = 2000; + generatedTrace.startTime = 1000; + + generatedTrace.spans.forEach((span: TraceSpanData, index: number) => { + span.duration = 1; + span.startTime = 1000; + span.spanID = (index + 1).toString(); + }); + return generatedTrace; } function getAccessors() { return { - getViewRange: jest.fn(() => [0, 1]), + getViewRange: jest.fn(() => [0, 1] as [number, number]), getSearchedSpanIDs: jest.fn(), getCollapsedChildren: jest.fn(), getViewHeight: jest.fn(() => SPAN_HEIGHT * 2), @@ -47,13 +50,13 @@ function getAccessors() { } describe('ScrollManager', () => { - let trace; - let accessors; - let manager; + let trace: Trace; + let accessors: Accessors; + let manager: ScrollManager; beforeEach(() => { - scrollBy.mockReset(); - scrollTo.mockReset(); + jest.mocked(scrollBy).mockReset(); + jest.mocked(scrollTo).mockReset(); trace = getTrace(); accessors = getAccessors(); manager = new ScrollManager(trace, { scrollBy, scrollTo }); @@ -61,14 +64,13 @@ describe('ScrollManager', () => { }); it('saves the accessors', () => { - const n = Math.random(); - manager.setAccessors(n); - expect(manager._accessors).toBe(n); + accessors = getAccessors(); + manager.setAccessors(accessors); + expect(manager._accessors).toBe(accessors); }); describe('_scrollPast()', () => { it('throws if accessors is not set', () => { - manager.setAccessors(null); expect(manager._scrollPast).toThrow(); }); @@ -77,10 +79,10 @@ describe('ScrollManager', () => { const oldWarn = console.warn; // eslint-disable-next-line no-console console.warn = () => {}; - manager._scrollPast(null, null); - expect(accessors.getRowPosition.mock.calls.length).toBe(1); - expect(accessors.getViewHeight.mock.calls.length).toBe(0); - expect(scrollTo.mock.calls.length).toBe(0); + manager._scrollPast(-2, 1); + expect(jest.mocked(accessors.getRowPosition).mock.calls.length).toBe(1); + expect(jest.mocked(accessors.getViewHeight).mock.calls.length).toBe(0); + expect(jest.mocked(scrollTo).mock.calls.length).toBe(0); // eslint-disable-next-line no-console console.warn = oldWarn; }); @@ -88,44 +90,43 @@ describe('ScrollManager', () => { it('scrolls up with direction is `-1`', () => { const y = 10; const expectTo = y - 0.5 * accessors.getViewHeight(); - accessors.getRowPosition.mockReturnValue({ y, height: SPAN_HEIGHT }); + jest.mocked(accessors.getRowPosition).mockReturnValue({ y, height: SPAN_HEIGHT }); manager._scrollPast(NaN, -1); - expect(scrollTo.mock.calls).toEqual([[expectTo]]); + expect(jest.mocked(scrollTo).mock.calls).toEqual([[expectTo]]); }); it('scrolls down with direction `1`', () => { const y = 10; const vh = accessors.getViewHeight(); const expectTo = y + SPAN_HEIGHT - 0.5 * vh; - accessors.getRowPosition.mockReturnValue({ y, height: SPAN_HEIGHT }); + jest.mocked(accessors.getRowPosition).mockReturnValue({ y, height: SPAN_HEIGHT }); manager._scrollPast(NaN, 1); - expect(scrollTo.mock.calls).toEqual([[expectTo]]); + expect(jest.mocked(scrollTo).mock.calls).toEqual([[expectTo]]); }); }); describe('_scrollToVisibleSpan()', () => { - function getRefs(spanID) { - return [{ refType: 'CHILD_OF', spanID }]; + function getRefs(spanID: string | undefined) { + return [{ refType: 'CHILD_OF', spanID }] as TraceSpanReference[]; } - let scrollPastMock; + let scrollPastMock: jest.Mock; beforeEach(() => { scrollPastMock = jest.fn(); manager._scrollPast = scrollPastMock; }); it('throws if accessors is not set', () => { - manager.setAccessors(null); expect(manager._scrollToVisibleSpan).toThrow(); }); it('exits if the trace is not set', () => { manager.setTrace(null); - manager._scrollToVisibleSpan(); + manager._scrollToVisibleSpan(1); expect(scrollPastMock.mock.calls.length).toBe(0); }); it('does nothing if already at the boundary', () => { - accessors.getTopRowIndexVisible.mockReturnValue(0); - accessors.getBottomRowIndexVisible.mockReturnValue(trace.spans.length - 1); + jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(0); + jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(trace.spans.length - 1); manager._scrollToVisibleSpan(-1); expect(scrollPastMock.mock.calls.length).toBe(0); manager._scrollToVisibleSpan(1); @@ -133,8 +134,8 @@ describe('ScrollManager', () => { }); it('centers the current top or bottom span', () => { - accessors.getTopRowIndexVisible.mockReturnValue(5); - accessors.getBottomRowIndexVisible.mockReturnValue(5); + jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(5); + jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(5); manager._scrollToVisibleSpan(-1); expect(scrollPastMock).lastCalledWith(5, -1); manager._scrollToVisibleSpan(1); @@ -144,8 +145,8 @@ describe('ScrollManager', () => { it('skips spans that are out of view', () => { trace.spans[4].startTime = trace.startTime + trace.duration * 0.5; accessors.getViewRange = () => [0.4, 0.6]; - accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); - accessors.getBottomRowIndexVisible.mockReturnValue(0); + jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(trace.spans.length - 1); + jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0); manager._scrollToVisibleSpan(1); expect(scrollPastMock).lastCalledWith(4, 1); manager._scrollToVisibleSpan(-1); @@ -153,8 +154,8 @@ describe('ScrollManager', () => { }); it('skips spans that do not match the text search', () => { - accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); - accessors.getBottomRowIndexVisible.mockReturnValue(0); + jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(trace.spans.length - 1); + jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0); accessors.getSearchedSpanIDs = () => new Set([trace.spans[4].spanID]); manager._scrollToVisibleSpan(1); expect(scrollPastMock).lastCalledWith(4, 1); @@ -164,8 +165,8 @@ describe('ScrollManager', () => { it('scrolls to boundary when scrolling away from closest spanID in findMatches', () => { const closetFindMatchesSpanID = 4; - accessors.getTopRowIndexVisible.mockReturnValue(closetFindMatchesSpanID - 1); - accessors.getBottomRowIndexVisible.mockReturnValue(closetFindMatchesSpanID + 1); + jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(closetFindMatchesSpanID - 1); + jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(closetFindMatchesSpanID + 1); accessors.getSearchedSpanIDs = () => new Set([trace.spans[closetFindMatchesSpanID].spanID]); manager._scrollToVisibleSpan(1); @@ -177,7 +178,7 @@ describe('ScrollManager', () => { it('scrolls to last visible row when boundary is hidden', () => { const parentOfLastRowWithHiddenChildrenIndex = trace.spans.length - 2; - accessors.getBottomRowIndexVisible.mockReturnValue(0); + jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0); accessors.getCollapsedChildren = () => new Set([trace.spans[parentOfLastRowWithHiddenChildrenIndex].spanID]); accessors.getSearchedSpanIDs = () => new Set([trace.spans[0].spanID]); trace.spans[trace.spans.length - 1].references = getRefs( @@ -204,9 +205,9 @@ describe('ScrollManager', () => { } } // set which spans are "in-view" and which have collapsed children - accessors.getTopRowIndexVisible.mockReturnValue(trace.spans.length - 1); - accessors.getBottomRowIndexVisible.mockReturnValue(0); - accessors.getCollapsedChildren.mockReturnValue(new Set([spans[0].spanID, spans[4].spanID])); + jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(trace.spans.length - 1); + jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0); + jest.mocked(accessors.getCollapsedChildren).mockReturnValue(new Set([spans[0].spanID, spans[4].spanID])); }); it('skips spans that are hidden because their parent is collapsed', () => { @@ -219,7 +220,7 @@ describe('ScrollManager', () => { it('ignores references with unknown types', () => { // modify spans[2] so that it has an unknown refType const spans = trace.spans; - spans[2].references = [{ refType: 'OTHER' }]; + spans[2].references = [{ refType: 'OTHER' }] as unknown as TraceSpanReference[]; manager.scrollToNextVisibleSpan(); expect(scrollPastMock).lastCalledWith(2, 1); manager.scrollToPrevVisibleSpan(); @@ -239,7 +240,7 @@ describe('ScrollManager', () => { describe('scrollToFirstVisibleSpan', () => { beforeEach(() => { - jest.spyOn(manager, '_scrollToVisibleSpan').mockImplementationOnce(); + jest.spyOn(manager, '_scrollToVisibleSpan'); }); it('calls _scrollToVisibleSpan searching downwards from first span', () => { @@ -261,12 +262,12 @@ describe('ScrollManager', () => { manager._accessors = null; manager.scrollPageDown(); manager.scrollPageUp(); - expect(scrollBy.mock.calls.length).toBe(0); + expect(jest.mocked(scrollBy).mock.calls.length).toBe(0); manager._accessors = accessors; manager._scroller = null; manager.scrollPageDown(); manager.scrollPageUp(); - expect(scrollBy.mock.calls.length).toBe(0); + expect(jest.mocked(scrollBy).mock.calls.length).toBe(0); }); }); diff --git a/packages/jaeger-ui-components/src/ScrollManager.tsx b/packages/jaeger-ui-components/src/ScrollManager.tsx index 262896f21f6..85fc84f9333 100644 --- a/packages/jaeger-ui-components/src/ScrollManager.tsx +++ b/packages/jaeger-ui-components/src/ScrollManager.tsx @@ -87,7 +87,7 @@ function isSpanHidden(span: TraceSpan, childrenAreHidden: Set, spansMap: */ export default class ScrollManager { _trace: Trace | TNil; - _scroller: Scroller; + _scroller: Scroller | TNil; _accessors: Accessors | TNil; constructor(trace: Trace | TNil, scroller: Scroller) { @@ -117,7 +117,7 @@ export default class ScrollManager { y -= vh; } y += direction * 0.5 * vh; - this._scroller.scrollTo(y); + this._scroller?.scrollTo(y); } _scrollToVisibleSpan(direction: 1 | -1, startRow?: number) { diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.js b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.tsx similarity index 100% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/CanvasSpanGraph.test.tsx diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.js b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.tsx similarity index 88% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.tsx index ffb2af7c879..ade36dcc5dc 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.test.tsx @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { render, screen, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; -import GraphTicks from './GraphTicks'; +import GraphTicks, { GraphTicksProps } from './GraphTicks'; -const setup = (propOverrides) => { +const setup = (propOverrides?: GraphTicksProps) => { const defaultProps = { items: [ { valueWidth: 100, valueOffset: 25, serviceName: 'a' }, diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx index ce2244456ee..5827fd07110 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/GraphTicks.tsx @@ -27,7 +27,7 @@ const getStyles = () => { }; }; -type GraphTicksProps = { +export type GraphTicksProps = { numTicks: number; }; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.js b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.tsx similarity index 89% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.tsx index 1f1ca955b78..384e047bcc9 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.test.tsx @@ -15,19 +15,19 @@ import { render, screen, fireEvent, within } from '@testing-library/react'; import React from 'react'; -import Scrubber from './Scrubber'; +import Scrubber, { ScrubberProps } from './Scrubber'; describe('', () => { const defaultProps = { position: 0, }; - let rerender; + let rerender: (arg0: JSX.Element) => void; beforeEach(() => { ({ rerender } = render( - + )); }); @@ -45,7 +45,7 @@ describe('', () => { it('calculates the correct x% for a timestamp', () => { rerender( - + ); const line = screen.getByTestId('scrubber-component-line'); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx index 94bec0f1769..077eb2a9f2d 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx @@ -72,7 +72,7 @@ export const getStyles = () => { }; }; -type ScrubberProps = { +export type ScrubberProps = { isDragging: boolean; position: number; onMouseDown: (evt: React.MouseEvent) => void; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.js b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.tsx similarity index 79% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.tsx index b65184021ed..e83e65f778c 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.test.tsx @@ -12,22 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; import { createTheme } from '@grafana/data'; -import { EUpdateTypes } from '../../utils/DraggableManager'; +import { ViewRangeTime } from '../../TraceTimelineViewer/types'; +import DraggableManager, { DraggingUpdate, EUpdateTypes } from '../../utils/DraggableManager'; import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame'; import GraphTicks from './GraphTicks'; -import Scrubber from './Scrubber'; -import ViewingLayer, { dragTypes, getStyles } from './ViewingLayer'; +import Scrubber, { ScrubberProps } from './Scrubber'; +import ViewingLayer, { dragTypes, getStyles, ViewingLayerProps, UnthemedViewingLayer } from './ViewingLayer'; -function getViewRange(viewStart, viewEnd) { +function getViewRange(viewStart: number, viewEnd: number) { return { time: { - current: [viewStart, viewEnd], + current: [viewStart, viewEnd] as [number, number], }, }; } @@ -35,8 +36,8 @@ function getViewRange(viewStart, viewEnd) { describe('', () => { polyfillAnimationFrame(window); - let props; - let wrapper; + let props: ViewingLayerProps; + let wrapper: ShallowWrapper; beforeEach(() => { props = { @@ -45,7 +46,8 @@ describe('', () => { updateNextViewRangeTime: jest.fn(), updateViewRangeTime: jest.fn(), viewRange: getViewRange(0, 1), - }; + } as unknown as ViewingLayerProps; + wrapper = shallow() .dive() .dive(); @@ -57,11 +59,12 @@ describe('', () => { wrapper = shallow() .dive() .dive(); + wrapper.instance()._setRoot({ getBoundingClientRect() { return { left: 10, width: 100 }; }, - }); + } as SVGElement); }); it('throws if _root is not set', () => { @@ -105,81 +108,81 @@ describe('', () => { describe('reframe', () => { it('handles mousemove', () => { const value = 0.5; - wrapper.instance()._handleReframeMouseMove({ value }); - const calls = props.updateNextViewRangeTime.mock.calls; + wrapper.instance()._handleReframeMouseMove({ value } as DraggingUpdate); + const calls = jest.mocked(props.updateNextViewRangeTime).mock.calls; expect(calls).toEqual([[{ cursor: value }]]); }); it('handles mouseleave', () => { wrapper.instance()._handleReframeMouseLeave(); - const calls = props.updateNextViewRangeTime.mock.calls; + const calls = jest.mocked(props.updateNextViewRangeTime).mock.calls; expect(calls).toEqual([[{ cursor: null }]]); }); describe('drag update', () => { it('handles sans anchor', () => { const value = 0.5; - wrapper.instance()._handleReframeDragUpdate({ value }); - const calls = props.updateNextViewRangeTime.mock.calls; + wrapper.instance()._handleReframeDragUpdate({ value } as DraggingUpdate); + const calls = jest.mocked(props.updateNextViewRangeTime).mock.calls; expect(calls).toEqual([[{ reframe: { anchor: value, shift: value } }]]); }); it('handles the existing anchor', () => { const value = 0.5; const anchor = 0.1; - const time = { ...props.viewRange.time, reframe: { anchor } }; + const time = { ...props.viewRange.time, reframe: { anchor } } as ViewRangeTime; props = { ...props, viewRange: { time } }; wrapper = shallow() .dive() .dive(); - wrapper.instance()._handleReframeDragUpdate({ value }); - const calls = props.updateNextViewRangeTime.mock.calls; + wrapper.instance()._handleReframeDragUpdate({ value } as DraggingUpdate); + const calls = jest.mocked(props.updateNextViewRangeTime).mock.calls; expect(calls).toEqual([[{ reframe: { anchor, shift: value } }]]); }); }); describe('drag end', () => { - let manager; + let manager: DraggableManager; beforeEach(() => { - manager = { resetBounds: jest.fn() }; + manager = { resetBounds: jest.fn() } as unknown as DraggableManager; }); it('handles sans anchor', () => { const value = 0.5; - wrapper.instance()._handleReframeDragEnd({ manager, value }); - expect(manager.resetBounds.mock.calls).toEqual([[]]); - const calls = props.updateViewRangeTime.mock.calls; + wrapper.instance()._handleReframeDragEnd({ manager, value } as DraggingUpdate); + expect((manager.resetBounds as jest.Mock).mock.calls).toEqual([[]]); + const calls = (props.updateViewRangeTime as jest.Mock).mock.calls; expect(calls).toEqual([[value, value, 'minimap']]); }); it('handles dragged left (anchor is greater)', () => { const value = 0.5; const anchor = 0.6; - const time = { ...props.viewRange.time, reframe: { anchor } }; + const time = { ...props.viewRange.time, reframe: { anchor } } as ViewRangeTime; props = { ...props, viewRange: { time } }; wrapper = shallow() .dive() .dive(); - wrapper.instance()._handleReframeDragEnd({ manager, value }); + wrapper.instance()._handleReframeDragEnd({ manager, value } as DraggingUpdate); - expect(manager.resetBounds.mock.calls).toEqual([[]]); - const calls = props.updateViewRangeTime.mock.calls; + expect((manager.resetBounds as jest.Mock).mock.calls).toEqual([[]]); + const calls = (props.updateViewRangeTime as jest.Mock).mock.calls; expect(calls).toEqual([[value, anchor, 'minimap']]); }); it('handles dragged right (anchor is less)', () => { const value = 0.5; const anchor = 0.4; - const time = { ...props.viewRange.time, reframe: { anchor } }; + const time = { ...props.viewRange.time, reframe: { anchor } } as ViewRangeTime; props = { ...props, viewRange: { time } }; wrapper = shallow() .dive() .dive(); - wrapper.instance()._handleReframeDragEnd({ manager, value }); + wrapper.instance()._handleReframeDragEnd({ manager, value } as DraggingUpdate); - expect(manager.resetBounds.mock.calls).toEqual([[]]); - const calls = props.updateViewRangeTime.mock.calls; + expect((manager.resetBounds as jest.Mock).mock.calls).toEqual([[]]); + const calls = (props.updateViewRangeTime as jest.Mock).mock.calls; expect(calls).toEqual([[anchor, value, 'minimap']]); }); }); @@ -187,12 +190,12 @@ describe('', () => { describe('scrubber', () => { it('prevents the cursor from being drawn on scrubber mouseover', () => { - wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseEnter }); + wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseEnter } as DraggingUpdate); expect(wrapper.state('preventCursorLine')).toBe(true); }); it('prevents the cursor from being drawn on scrubber mouseleave', () => { - wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseLeave }); + wrapper.instance()._handleScrubberEnterLeave({ type: EUpdateTypes.MouseLeave } as DraggingUpdate); expect(wrapper.state('preventCursorLine')).toBe(false); }); @@ -203,7 +206,7 @@ describe('', () => { event: { stopPropagation }, type: EUpdateTypes.DragStart, }; - wrapper.instance()._handleScrubberDragUpdate(update); + wrapper.instance()._handleScrubberDragUpdate(update as unknown as DraggingUpdate); expect(stopPropagation.mock.calls).toEqual([[]]); }); @@ -229,7 +232,7 @@ describe('', () => { }, ]; cases.forEach((_case) => { - instance._handleScrubberDragUpdate(_case.dragUpdate); + instance._handleScrubberDragUpdate(_case.dragUpdate as DraggingUpdate); expect(props.updateNextViewRangeTime).lastCalledWith(_case.viewRangeUpdate); }); }); @@ -261,9 +264,9 @@ describe('', () => { const { manager } = _case.dragUpdate; wrapper.setState({ preventCursorLine: true }); expect(wrapper.state('preventCursorLine')).toBe(true); - instance._handleScrubberDragEnd(_case.dragUpdate); + instance._handleScrubberDragEnd(_case.dragUpdate as unknown as DraggingUpdate); expect(wrapper.state('preventCursorLine')).toBe(false); - expect(manager.resetBounds.mock.calls).toEqual([[]]); + expect((manager.resetBounds as jest.Mock).mock.calls).toEqual([[]]); expect(props.updateViewRangeTime).lastCalledWith(..._case.viewRangeUpdate, 'minimap'); }); }); @@ -315,7 +318,7 @@ describe('', () => { const leftBox = wrapper.find(`.${getStyles(createTheme()).ViewingLayerInactive}`); expect(leftBox.length).toBe(1); - const width = Number(leftBox.prop('width').slice(0, -1)); + const width = Number(leftBox.prop('width')?.toString().slice(0, -1)); const x = leftBox.prop('x'); expect(Math.round(width)).toBe(20); expect(x).toBe(0); @@ -329,17 +332,17 @@ describe('', () => { const rightBox = wrapper.find(`.${getStyles(createTheme()).ViewingLayerInactive}`); expect(rightBox.length).toBe(1); - const width = Number(rightBox.prop('width').slice(0, -1)); - const x = Number(rightBox.prop('x').slice(0, -1)); + const width = Number(rightBox.prop('width')?.toString().slice(0, -1)); + const x = Number(rightBox.prop('x')?.toString().slice(0, -1)); expect(Math.round(width)).toBe(20); expect(x).toBe(80); }); it('renders handles for the timeRangeFilter', () => { const [viewStart, viewEnd] = props.viewRange.time.current; - let scrubber = ; + let scrubber = ; expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); - scrubber = ; + scrubber = ; expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); }); }); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx index e4090032a4c..17025ba4a65 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/ViewingLayer.tsx @@ -89,7 +89,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => { }; }); -type ViewingLayerProps = { +export type ViewingLayerProps = { height: number; numTicks: number; updateViewRangeTime: TUpdateViewRangeTimeFunction; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.js b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.tsx similarity index 84% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.tsx index d76f4aab944..ba8d949e2fc 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.test.tsx @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; import traceGenerator from '../../demo/trace-generators'; @@ -21,9 +21,9 @@ import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnim import CanvasSpanGraph from './CanvasSpanGraph'; import TickLabels from './TickLabels'; -import ViewingLayer from './ViewingLayer'; +import ViewingLayer, { ViewingLayerProps, UnthemedViewingLayer } from './ViewingLayer'; -import SpanGraph from './index'; +import SpanGraph, { SpanGraphProps } from './index'; describe('', () => { polyfillAnimationFrame(window); @@ -39,10 +39,10 @@ describe('', () => { }, }; - let wrapper; + let wrapper: ShallowWrapper; beforeEach(() => { - wrapper = shallow(); + wrapper = shallow(); }); it('renders a ', () => { @@ -54,7 +54,7 @@ describe('', () => { }); it('returns a
if a trace is not provided', () => { - wrapper = shallow(); + wrapper = shallow(); expect(wrapper.matchesElement(
)).toBeTruthy(); }); @@ -68,7 +68,7 @@ describe('', () => { it('passes items to CanvasSpanGraph', () => { const canvasGraph = wrapper.find(CanvasSpanGraph).first(); - const items = trace.spans.map((span) => ({ + const items = trace?.spans.map((span) => ({ valueOffset: span.relativeStartTime, valueWidth: span.duration, serviceName: span.process.serviceName, diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx index 6bab52a4b67..8133aaaa8e9 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/index.tsx @@ -27,7 +27,7 @@ import ViewingLayer from './ViewingLayer'; const DEFAULT_HEIGHT = 60; const TIMELINE_TICK_INTERVAL = 4; -type SpanGraphProps = { +export type SpanGraphProps = { height?: number; trace: Trace; viewRange: ViewRange; diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.js b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.ts similarity index 78% rename from packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.ts index 2b822025a84..a4a5082fa06 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/render-into-canvas.test.ts @@ -15,7 +15,6 @@ import { range as _range } from 'lodash'; import renderIntoCanvas, { - BG_COLOR, ITEM_ALPHA, MIN_ITEM_HEIGHT, MAX_TOTAL_HEIGHT, @@ -24,8 +23,10 @@ import renderIntoCanvas, { MAX_ITEM_HEIGHT, } from './render-into-canvas'; +const BG_COLOR = '#FFFFFF'; + const getCanvasWidth = () => window.innerWidth * 2; -const getBgFillRect = (items) => ({ +const getBgFillRect = (items?: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>) => ({ fillStyle: BG_COLOR, height: !items || items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(MAX_TOTAL_HEIGHT, items.length), width: getCanvasWidth(), @@ -37,12 +38,15 @@ describe('renderIntoCanvas()', () => { const basicItem = { valueWidth: 100, valueOffset: 50, serviceName: 'some-name' }; class CanvasContext { + fillStyle: undefined; + fillRectAccumulator: Array<{ fillStyle: undefined; height: number; width: number; x: number; y: number }> = []; + constructor() { this.fillStyle = undefined; this.fillRectAccumulator = []; } - fillRect(x, y, width, height) { + fillRect(x: number, y: number, width: number, height: number) { const fillStyle = this.fillStyle; this.fillRectAccumulator.push({ fillStyle, @@ -55,6 +59,11 @@ describe('renderIntoCanvas()', () => { } class Canvas { + height: number; + width: number; + contexts: CanvasContext[]; + getContext: jest.Mock; + constructor() { this.contexts = []; this.height = NaN; @@ -71,13 +80,13 @@ describe('renderIntoCanvas()', () => { function getColorFactory() { let i = 0; - const inputOutput = []; - function getFakeColor(str) { - const rv = [i, i, i]; + const inputOutput: Array<{ input: string; output: [number, number, number] }> = []; + function getFakeColor(str: string) { + const rv: [number, number, number] = [i, i, i]; i++; inputOutput.push({ input: str, - output: rv.slice(), + output: rv.slice() as [number, number, number], }); return rv; } @@ -88,7 +97,7 @@ describe('renderIntoCanvas()', () => { it('sets the width', () => { const canvas = new Canvas(); expect(canvas.width !== canvas.width).toBe(true); - renderIntoCanvas(canvas, [basicItem], 150, getColorFactory()); + renderIntoCanvas(canvas as unknown as HTMLCanvasElement, [basicItem], 150, getColorFactory(), BG_COLOR); expect(canvas.width).toBe(getCanvasWidth()); }); @@ -96,18 +105,18 @@ describe('renderIntoCanvas()', () => { it('sets the height', () => { const canvas = new Canvas(); expect(canvas.height !== canvas.height).toBe(true); - renderIntoCanvas(canvas, [basicItem], 150, getColorFactory()); + renderIntoCanvas(canvas as unknown as HTMLCanvasElement, [basicItem], 150, getColorFactory(), BG_COLOR); expect(canvas.height).toBe(MIN_TOTAL_HEIGHT); }); it('draws the background', () => { const expectedDrawing = [getBgFillRect()]; const canvas = new Canvas(); - const items = []; + const items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }> = []; const totalValueWidth = 4000; const getFillColor = getColorFactory(); - renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); - expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]); + renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, totalValueWidth, getFillColor, BG_COLOR); + expect((canvas.getContext as jest.Mock).mock.calls).toEqual([['2d', { alpha: false }]]); expect(canvas.contexts.length).toBe(1); expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawing); }); @@ -141,7 +150,7 @@ describe('renderIntoCanvas()', () => { ]; const canvas = new Canvas(); const getFillColor = getColorFactory(); - renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); + renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, totalValueWidth, getFillColor, BG_COLOR); expect(getFillColor.inputOutput).toEqual(expectedColors); expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]); expect(canvas.contexts.length).toBe(1); @@ -157,7 +166,7 @@ describe('renderIntoCanvas()', () => { items.push(basicItem); } expect(canvas.height !== canvas.height).toBe(true); - renderIntoCanvas(canvas, items, 150, getColorFactory()); + renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, 150, getColorFactory(), BG_COLOR); expect(canvas.height).toBe(items.length); }); @@ -187,9 +196,9 @@ describe('renderIntoCanvas()', () => { ]; const canvas = new Canvas(); const getFillColor = getColorFactory(); - renderIntoCanvas(canvas, items, totalValueWidth, getFillColor); + renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, totalValueWidth, getFillColor, BG_COLOR); expect(getFillColor.inputOutput).toEqual(expectedColors); - expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]); + expect((canvas.getContext as jest.Mock).mock.calls).toEqual([['2d', { alpha: false }]]); expect(canvas.contexts.length).toBe(1); expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings); }); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.tsx similarity index 77% rename from packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.tsx index f0338d287b9..f79f8734233 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.tsx @@ -16,9 +16,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { createTheme } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import TracePageSearchBar, { getStyles } from './TracePageSearchBar'; +import TracePageSearchBar, { getStyles, TracePageSearchBarProps } from './TracePageSearchBar'; const defaultProps = { forwardedRef: React.createRef(), @@ -30,8 +29,8 @@ const defaultProps = { describe('', () => { describe('truthy textFilter', () => { it('renders UiFindInput with correct props', () => { - render(); - expect(screen.getByPlaceholderText('Find...')['value']).toEqual('value'); + render(); + expect((screen.getByPlaceholderText('Find...') as HTMLInputElement)['value']).toEqual('value'); const suffix = screen.getByLabelText('Search bar suffix'); const theme = createTheme(); expect(suffix['className']).toBe(getStyles(theme).TracePageSearchBarSuffix); @@ -39,13 +38,13 @@ describe('', () => { }); it('renders buttons', () => { - render(); + render(); const nextResButton = screen.queryByRole('button', { name: 'Next results button' }); const prevResButton = screen.queryByRole('button', { name: 'Prev results button' }); expect(nextResButton).toBeInTheDocument(); expect(prevResButton).toBeInTheDocument(); - expect(nextResButton['disabled']).toBe(false); - expect(prevResButton['disabled']).toBe(false); + expect((nextResButton as HTMLButtonElement)['disabled']).toBe(false); + expect((prevResButton as HTMLButtonElement)['disabled']).toBe(false); }); it('only shows navigable buttons when navigable is true', () => { @@ -53,7 +52,7 @@ describe('', () => { ...defaultProps, navigable: false, }; - render(); + render(); expect(screen.queryByRole('button', { name: 'Next results button' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Prev results button' })).not.toBeInTheDocument(); }); @@ -65,7 +64,7 @@ describe('', () => { ...defaultProps, searchValue: '', }; - render(); + render(); }); it('does not render suffix', () => { diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx index 7c7f5b7e803..84a4a3c6c82 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.tsx @@ -69,7 +69,7 @@ export const getStyles = (theme: GrafanaTheme2) => { }; }; -type TracePageSearchBarProps = { +export type TracePageSearchBarProps = { navigable: boolean; searchValue: string; setSearch: (value: string) => void; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.ts similarity index 99% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.js rename to packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.ts index e000b7e18c0..82f0ae3b2dc 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/Positions.test.ts @@ -16,8 +16,9 @@ import Positions from './Positions'; describe('Positions', () => { const bufferLen = 1; - const getHeight = (i) => i * 2 + 2; - let ps; + const getHeight = (i: number) => i * 2 + 2; + + let ps: Positions; beforeEach(() => { ps = new Positions(bufferLen); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.tsx.snap similarity index 100% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.js.snap rename to packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/__snapshots__/index.test.tsx.snap diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.tsx similarity index 82% rename from packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js rename to packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.tsx index 55fa6848dfa..3a55a264c83 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.tsx @@ -12,18 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { mount, shallow } from 'enzyme'; +import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; +import { TNil } from '../../types'; import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame'; -import ListView from './index'; +import ListView, { TListViewProps } from './index'; // Util to get list of all callbacks added to an event emitter by event type. // jest adds "error" event listeners to window, this util makes it easier to // ignore those calls. -function getListenersByType(mockFn) { - const rv = {}; +function getListenersByType( + mockFn: jest.MockContext< + void, + [ + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions | undefined + ] + > +) { + const rv: { + [eventType: string]: EventListenerOrEventListenerObject[]; + } = {}; mockFn.calls.forEach(([eventType, callback]) => { if (!rv[eventType]) { rv[eventType] = [callback]; @@ -40,25 +52,24 @@ describe('', () => { const DATA_LENGTH = 40; - function getHeight(index) { + function getHeight(index: number) { return index * 2 + 2; } - function Item(props) { + function Item(props: React.HTMLProps) { const { children, ...rest } = props; return
{children}
; } - function renderItem(itemKey, styles, itemIndex, attrs) { + const renderItem: TListViewProps['itemRenderer'] = (itemKey, styles, itemIndex, attrs) => { return ( {itemIndex} ); - } + }; - let wrapper; - let instance; + let instance: ListView; const props = { dataLength: DATA_LENGTH, @@ -74,6 +85,7 @@ describe('', () => { }; describe('shallow tests', () => { + let wrapper: ShallowWrapper; beforeEach(() => { wrapper = shallow(); }); @@ -92,10 +104,10 @@ describe('', () => { it('sets the height of the items according to the height func', () => { const items = wrapper.find(Item); - const expectedHeights = []; + const expectedHeights: number[] = []; const heights = items.map((node, i) => { expectedHeights.push(getHeight(i)); - return node.prop('style').height; + return node.prop('style')?.height; }); expect(heights.length).toBe(props.initialDraw); expect(heights).toEqual(expectedHeights); @@ -109,12 +121,13 @@ describe('', () => { }); describe('mount tests', () => { + let wrapper: ReactWrapper; describe('accessor functions', () => { const clientHeight = 2; const scrollTop = 3; - let oldRender; - let oldInitWrapper; + let oldRender: () => JSX.Element; + let oldInitWrapper: (elm: HTMLElement | TNil) => void; const initWrapperMock = jest.fn((elm) => { if (elm != null) { // jsDom requires `defineProperties` instead of just setting the props @@ -173,14 +186,28 @@ describe('', () => { }); describe('windowScroller', () => { - let windowAddListenerSpy; - let windowRmListenerSpy; + let windowAddListenerSpy: jest.SpyInstance< + void, + [ + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions | undefined + ] + >; + let windowRmListenerSpy: jest.SpyInstance< + void, + [ + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions | undefined + ] + >; beforeEach(() => { windowAddListenerSpy = jest.spyOn(window, 'addEventListener'); windowRmListenerSpy = jest.spyOn(window, 'removeEventListener'); const wsProps = { ...props, windowScroller: true }; - wrapper = mount(); + wrapper = mount(); instance = wrapper.instance(); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx index e7587f0a563..86b93470428 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx @@ -27,7 +27,7 @@ type TWrapperProps = { /** * @typedef */ -type TListViewProps = { +export type TListViewProps = { /** * Number of elements in the list. */ diff --git a/packaging/wrappers/grafana-server b/packaging/wrappers/grafana-server index d8a4fa930fc..466b0d7c690 100755 --- a/packaging/wrappers/grafana-server +++ b/packaging/wrappers/grafana-server @@ -5,18 +5,8 @@ # the system-wide Grafana configuration that was bundled with the package as we # use the binary. -DEFAULT=/etc/default/grafana - GRAFANA_HOME="${GRAFANA_HOME:-/usr/share/grafana}" -CONF_DIR=/etc/grafana -DATA_DIR=/var/lib/grafana -PLUGINS_DIR=/var/lib/grafana/plugins -LOG_DIR=/var/log/grafana - -CONF_FILE=$CONF_DIR/grafana.ini -PROVISIONING_CFG_DIR=$CONF_DIR/provisioning - EXECUTABLE="$GRAFANA_HOME/bin/grafana" if [ ! -x $EXECUTABLE ]; then @@ -24,18 +14,6 @@ if [ ! -x $EXECUTABLE ]; then exit 5 fi -# overwrite settings from default file -if [ -f "$DEFAULT" ]; then - . "$DEFAULT" -fi - -OPTS="--homepath=${GRAFANA_HOME} \ - --config=${CONF_FILE} \ - --configOverrides='cfg:default.paths.provisioning=$PROVISIONING_CFG_DIR \ - cfg:default.paths.data=${DATA_DIR} \ - cfg:default.paths.logs=${LOG_DIR} \ - cfg:default.paths.plugins=${PLUGINS_DIR}'" - CMD=server -eval $EXECUTABLE "$CMD" "$OPTS" "$@" +eval $EXECUTABLE "$CMD" "$@" diff --git a/pkg/api/api.go b/pkg/api/api.go index 75d9cf3b763..4f57e1815e9 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -114,8 +114,11 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) r.Get("/admin/orgs/edit/:id", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) r.Get("/admin/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) - r.Get("/admin/storage/*", reqGrafanaAdmin, hs.Index) r.Get("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) + if hs.Features.IsEnabled(featuremgmt.FlagStorage) { + r.Get("/admin/storage", reqSignedIn, hs.Index) + r.Get("/admin/storage/*", reqSignedIn, hs.Index) + } r.Get("/styleguide", reqSignedIn, hs.Index) r.Get("/live", reqGrafanaAdmin, hs.Index) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 7a418591664..b86adfc5b29 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" @@ -37,11 +38,17 @@ func setUpGetOrgUsersDB(t *testing.T, sqlStore *sqlstore.SQLStore) { sqlStore.Cfg.AutoAssignOrg = true sqlStore.Cfg.AutoAssignOrgId = int(testOrgID) - _, err := sqlStore.CreateUser(context.Background(), user.CreateUserCommand{Email: "testUser@grafana.com", Login: testUserLogin}) + quotaService := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) require.NoError(t, err) - _, err = sqlStore.CreateUser(context.Background(), user.CreateUserCommand{Email: "user1@grafana.com", Login: "user1"}) + usrSvc, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, quotaService) require.NoError(t, err) - _, err = sqlStore.CreateUser(context.Background(), user.CreateUserCommand{Email: "user2@grafana.com", Login: "user2"}) + + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Email: "testUser@grafana.com", Login: testUserLogin}) + require.NoError(t, err) + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Email: "user1@grafana.com", Login: "user1"}) + require.NoError(t, err) + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Email: "user2@grafana.com", Login: "user2"}) require.NoError(t, err) } @@ -331,13 +338,15 @@ var ( func setupOrgUsersDBForAccessControlTests(t *testing.T, db *sqlstore.SQLStore, orgService org.Service) { t.Helper() - var err error + quotaService := quotaimpl.ProvideService(db, db.Cfg) + usrSvc, err := userimpl.ProvideService(db, orgService, db.Cfg, nil, nil, quotaService) + require.NoError(t, err) - _, err = db.CreateUser(context.Background(), user.CreateUserCommand{Email: testServerAdminViewer.Email, SkipOrgSetup: true, Login: testServerAdminViewer.Login}) + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Email: testServerAdminViewer.Email, SkipOrgSetup: true, Login: testServerAdminViewer.Login}) require.NoError(t, err) - _, err = db.CreateUser(context.Background(), user.CreateUserCommand{Email: testAdminOrg2.Email, SkipOrgSetup: true, Login: testAdminOrg2.Login}) + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Email: testAdminOrg2.Email, SkipOrgSetup: true, Login: testAdminOrg2.Login}) require.NoError(t, err) - _, err = db.CreateUser(context.Background(), user.CreateUserCommand{Email: testEditorOrg1.Email, SkipOrgSetup: true, Login: testEditorOrg1.Login}) + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Email: testEditorOrg1.Email, SkipOrgSetup: true, Login: testEditorOrg1.Login}) require.NoError(t, err) // Create both orgs with server admin diff --git a/pkg/api/team_members_test.go b/pkg/api/team_members_test.go index 5336cdb0768..3fb5aaff768 100644 --- a/pkg/api/team_members_test.go +++ b/pkg/api/team_members_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" @@ -25,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/services/teamguardian/database" "github.com/grafana/grafana/pkg/services/teamguardian/manager" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" ) @@ -46,6 +48,12 @@ func setUpGetTeamMembersHandler(t *testing.T, sqlStore *sqlstore.SQLStore) { teamSvc := teamimpl.ProvideService(sqlStore, setting.NewCfg()) team, err := teamSvc.CreateTeam("group1 name", "test1@test.com", testOrgID) require.NoError(t, err) + quotaService := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, quotaService) + require.NoError(t, err) + for i := 0; i < 3; i++ { userCmd = user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), @@ -53,7 +61,7 @@ func setUpGetTeamMembersHandler(t *testing.T, sqlStore *sqlstore.SQLStore) { Login: fmt.Sprint("loginuser", i), } // user - user, err := sqlStore.CreateUser(context.Background(), userCmd) + user, err := usrSvc.CreateUserForTests(context.Background(), &userCmd) require.NoError(t, err) err = teamSvc.AddTeamMember(user.ID, testOrgID, team.Id, false, 1) require.NoError(t, err) @@ -115,7 +123,13 @@ func TestTeamMembersAPIEndpoint_userLoggedIn(t *testing.T) { } func createUser(db sqlstore.Store, orgId int64, t *testing.T) int64 { - user, err := db.CreateUser(context.Background(), user.CreateUserCommand{ + quotaService := quotaimpl.ProvideService(db, setting.NewCfg()) + orgService, err := orgimpl.ProvideService(db, setting.NewCfg(), quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(db, orgService, setting.NewCfg(), nil, nil, quotaService) + require.NoError(t, err) + + user, err := usrSvc.CreateUserForTests(context.Background(), &user.CreateUserCommand{ Login: fmt.Sprintf("TestUser%d", rand.Int()), OrgID: orgId, Password: "password", @@ -127,7 +141,11 @@ func createUser(db sqlstore.Store, orgId int64, t *testing.T) int64 { func setupTeamTestScenario(userCount int, db *sqlstore.SQLStore, orgService org.Service, t *testing.T) int64 { teamService := teamimpl.ProvideService(db, setting.NewCfg()) // FIXME - user, err := db.CreateUser(context.Background(), user.CreateUserCommand{SkipOrgSetup: true, Login: testUserLogin}) + quotaService := quotaimpl.ProvideService(db, db.Cfg) + usrSvc, err := userimpl.ProvideService(db, orgService, db.Cfg, teamService, nil, quotaService) + require.NoError(t, err) + + user, err := usrSvc.CreateUserForTests(context.Background(), &user.CreateUserCommand{SkipOrgSetup: true, Login: testUserLogin}) require.NoError(t, err) cmd := &org.CreateOrgCommand{Name: "TestOrg", UserID: user.ID} testOrg, err := orgService.CreateWithMember(context.Background(), cmd) diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index e810cecc6c3..7f733b7dc9a 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/login/logintest" + "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" @@ -63,6 +64,11 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { &usagestats.UsageStatsMock{}, ) hs.authInfoService = srv + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil)) + require.NoError(t, err) + hs.userService = userSvc createUserCmd := user.CreateUserCommand{ Email: fmt.Sprint("user", "@test.com"), @@ -70,9 +76,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { Login: "loginuser", IsAdmin: true, } - user, err := sqlStore.CreateUser(context.Background(), createUserCmd) - require.Nil(t, err) - hs.userService, err = userimpl.ProvideService(sqlStore, nil, sc.cfg, nil, nil, quotatest.New(false, nil)) + user, err := userSvc.CreateUserForTests(context.Background(), &createUserCmd) require.NoError(t, err) sc.handlerFunc = hs.GetUserByID @@ -128,7 +132,11 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { Login: "admin", IsAdmin: true, } - _, err := sqlStore.CreateUser(context.Background(), createUserCmd) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil)) + require.NoError(t, err) + _, err = userSvc.Create(context.Background(), &createUserCmd) require.Nil(t, err) sc.handlerFunc = hs.GetUserByLoginOrEmail diff --git a/pkg/build/cmd/genversions.go b/pkg/build/cmd/genversions.go index 0d293499430..629414b7351 100644 --- a/pkg/build/cmd/genversions.go +++ b/pkg/build/cmd/genversions.go @@ -36,7 +36,7 @@ func GenerateMetadata(c *cli.Context) (config.Metadata, error) { releaseMode = config.ReleaseMode{Mode: mode} case config.Custom: if edition, _ := os.LookupEnv("EDITION"); edition == string(config.EditionEnterprise2) { - releaseMode = config.ReleaseMode{Mode: config.TagMode} + releaseMode = config.ReleaseMode{Mode: config.Enterprise2Mode} if tag != "" { version = strings.TrimPrefix(tag, "v") } @@ -48,7 +48,7 @@ func GenerateMetadata(c *cli.Context) (config.Metadata, error) { } // if there is a custom event targeting the main branch, that's an enterprise downstream build if mode == config.MainBranch { - releaseMode = config.ReleaseMode{Mode: config.CustomMode} + releaseMode = config.ReleaseMode{Mode: config.DownstreamMode} } else { releaseMode = config.ReleaseMode{Mode: mode} } diff --git a/pkg/build/cmd/genversions_test.go b/pkg/build/cmd/genversions_test.go index c34b8915da2..1039c04c89e 100644 --- a/pkg/build/cmd/genversions_test.go +++ b/pkg/build/cmd/genversions_test.go @@ -16,6 +16,7 @@ const ( DroneTag = "DRONE_TAG" DroneSemverPrerelease = "DRONE_SEMVER_PRERELEASE" DroneBuildNumber = "DRONE_BUILD_NUMBER" + Edition = "EDITION" ) const ( @@ -33,7 +34,8 @@ func TestGetMetadata(t *testing.T) { {map[string]string{DroneBuildEvent: config.Push, DroneTargetBranch: versionedBranch, DroneTag: "", DroneSemverPrerelease: "", DroneBuildNumber: "12345"}, hashedGrafanaVersion, config.ReleaseMode{Mode: config.ReleaseBranchMode}}, {map[string]string{DroneBuildEvent: config.Push, DroneTargetBranch: config.MainBranch, DroneTag: "", DroneSemverPrerelease: "", DroneBuildNumber: "12345"}, hashedGrafanaVersion, config.ReleaseMode{Mode: config.MainMode}}, {map[string]string{DroneBuildEvent: config.Custom, DroneTargetBranch: versionedBranch, DroneTag: "", DroneSemverPrerelease: "", DroneBuildNumber: "12345"}, hashedGrafanaVersion, config.ReleaseMode{Mode: config.ReleaseBranchMode}}, - {map[string]string{DroneBuildEvent: config.Custom, DroneTargetBranch: config.MainBranch, DroneTag: "", DroneSemverPrerelease: "", DroneBuildNumber: "12345"}, hashedGrafanaVersion, config.ReleaseMode{Mode: config.Custom}}, + {map[string]string{DroneBuildEvent: config.Custom, DroneTargetBranch: config.MainBranch, DroneTag: "", DroneSemverPrerelease: "", DroneBuildNumber: "12345"}, hashedGrafanaVersion, config.ReleaseMode{Mode: config.DownstreamMode}}, + {map[string]string{DroneBuildEvent: config.Custom, DroneTargetBranch: config.MainBranch, DroneTag: "", DroneSemverPrerelease: "", DroneBuildNumber: "12345", Edition: "enterprise2"}, hashedGrafanaVersion, config.ReleaseMode{Mode: config.Enterprise2Mode}}, {map[string]string{DroneBuildEvent: config.Tag, DroneTargetBranch: "", DroneTag: "v9.2.0", DroneSemverPrerelease: "", DroneBuildNumber: "12345"}, "9.2.0", config.ReleaseMode{Mode: config.TagMode, IsBeta: false, IsTest: false}}, {map[string]string{DroneBuildEvent: config.Tag, DroneTargetBranch: "", DroneTag: "v9.2.0-beta", DroneSemverPrerelease: "beta", DroneBuildNumber: "12345"}, "9.2.0-beta", config.ReleaseMode{Mode: config.TagMode, IsBeta: true, IsTest: false}}, {map[string]string{DroneBuildEvent: config.Tag, DroneTargetBranch: "", DroneTag: "v9.2.0-test", DroneSemverPrerelease: "test", DroneBuildNumber: "12345"}, "9.2.0-test", config.ReleaseMode{Mode: config.TagMode, IsBeta: false, IsTest: true}}, diff --git a/pkg/build/cmd/grafanacom.go b/pkg/build/cmd/grafanacom.go index 200f9b40b3c..12cf448e26a 100644 --- a/pkg/build/cmd/grafanacom.go +++ b/pkg/build/cmd/grafanacom.go @@ -146,7 +146,7 @@ func publishPackages(cfg packaging.PublishConfig) error { } switch cfg.ReleaseMode.Mode { - case config.MainMode, config.CustomMode, config.CronjobMode: + case config.MainMode, config.DownstreamMode, config.CronjobMode: pth = path.Join(pth, packaging.MainFolder) default: pth = path.Join(pth, packaging.ReleaseFolder) diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index d8863710085..bd507dee212 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -152,7 +152,7 @@ func main() { }, { Name: "store-storybook", - Usage: "Integrity check for storybook build", + Usage: "Stores storybook to GCS buckets", Action: StoreStorybook, Flags: []cli.Flag{ &cli.StringFlag{ @@ -161,6 +161,11 @@ func main() { }, }, }, + { + Name: "verify-storybook", + Usage: "Integrity check for storybook build", + Action: VerifyStorybook, + }, { Name: "upload-packages", Usage: "Upload Grafana packages", @@ -168,6 +173,10 @@ func main() { Flags: []cli.Flag{ &jobsFlag, &editionFlag, + &cli.BoolFlag{ + Name: "enterprise2", + Usage: "Declare if the edition is enterprise2", + }, }, }, { diff --git a/pkg/build/cmd/uploadpackages.go b/pkg/build/cmd/uploadpackages.go index 01a43a43bdf..a677ec807f5 100644 --- a/pkg/build/cmd/uploadpackages.go +++ b/pkg/build/cmd/uploadpackages.go @@ -69,9 +69,17 @@ func UploadPackages(c *cli.Context) error { return cli.NewExitError(err.Error(), 1) } - edition, ok := os.LookupEnv("EDITION") - if !ok { - return fmt.Errorf("EDITION envvar is missing, exitting") + var edition config.Edition + if e, ok := os.LookupEnv("EDITION"); ok { + edition = config.Edition(e) + } + + if c.Bool("enterprise2") { + edition = config.EditionEnterprise2 + } + + if edition == "" { + return fmt.Errorf("both EDITION envvar and '--enterprise2' flag are missing. At least one of those is required") } // TODO: Verify config values @@ -80,7 +88,7 @@ func UploadPackages(c *cli.Context) error { Version: version, Bucket: releaseModeConfig.Buckets.Artifacts, }, - edition: config.Edition(edition), + edition: edition, versionMode: releaseMode.Mode, gcpKey: gcpKey, distDir: distDir, @@ -88,7 +96,7 @@ func UploadPackages(c *cli.Context) error { if cfg.edition == config.EditionEnterprise2 { if releaseModeConfig.Buckets.ArtifactsEnterprise2 != "" { - cfg.Config.Bucket = releaseModeConfig.Buckets.ArtifactsEnterprise2 + cfg.Bucket = releaseModeConfig.Buckets.ArtifactsEnterprise2 } else { return fmt.Errorf("enterprise2 bucket var doesn't exist") } @@ -142,7 +150,7 @@ func uploadPackages(cfg uploadConfig) error { switch cfg.versionMode { case config.TagMode: versionFolder = releaseFolder - case config.MainMode, config.CustomMode: + case config.MainMode, config.DownstreamMode: versionFolder = mainFolder case config.ReleaseBranchMode: versionFolder = releaseBranchFolder diff --git a/pkg/build/cmd/verifystorybook.go b/pkg/build/cmd/verifystorybook.go new file mode 100644 index 00000000000..052206f1660 --- /dev/null +++ b/pkg/build/cmd/verifystorybook.go @@ -0,0 +1,32 @@ +// Package verifystorybook contains the sub-command "verify-storybook". +package main + +import ( + "fmt" + "log" + "path/filepath" + + "github.com/grafana/grafana/pkg/infra/fs" + "github.com/urfave/cli/v2" +) + +// VerifyStorybook Action implements the sub-command "verify-storybook". +func VerifyStorybook(c *cli.Context) error { + const grafanaDir = "." + + paths := []string{ + "packages/grafana-ui/dist/storybook/index.html", + "packages/grafana-ui/dist/storybook/iframe.html"} + for _, p := range paths { + exists, err := fs.Exists(filepath.Join(grafanaDir, p)) + if err != nil { + return cli.NewExitError(fmt.Sprintf("failed to verify Storybook build: %s", err), 1) + } + if !exists { + return fmt.Errorf("failed to verify Storybook build, missing %q", p) + } + } + + log.Printf("Successfully verified Storybook integrity") + return nil +} diff --git a/pkg/build/config/version_mode.go b/pkg/build/config/version_mode.go index f7aa6578fe4..a74b5b5f49e 100644 --- a/pkg/build/config/version_mode.go +++ b/pkg/build/config/version_mode.go @@ -8,7 +8,8 @@ const ( TagMode VersionMode = "release" ReleaseBranchMode VersionMode = "branch" PullRequestMode VersionMode = "pull_request" - CustomMode VersionMode = "custom" + DownstreamMode VersionMode = "downstream" + Enterprise2Mode VersionMode = "enterprise2" CronjobMode VersionMode = "cron" ) diff --git a/pkg/build/config/versions.go b/pkg/build/config/versions.go index baf8e6b8f46..874cedef5f5 100644 --- a/pkg/build/config/versions.go +++ b/pkg/build/config/versions.go @@ -59,7 +59,7 @@ var Versions = VersionMap{ Storybook: "grafana-storybook", }, }, - CustomMode: { + DownstreamMode: { Variants: []Variant{ VariantArmV6, VariantArmV7, @@ -165,4 +165,42 @@ var Versions = VersionMap{ StorybookSrcDir: "artifacts/storybook", }, }, + Enterprise2Mode: { + Variants: []Variant{ + VariantArmV6, + VariantArmV7, + VariantArmV7Musl, + VariantArm64, + VariantArm64Musl, + VariantDarwinAmd64, + VariantWindowsAmd64, + VariantLinuxAmd64, + VariantLinuxAmd64Musl, + }, + PluginSignature: PluginSignature{ + Sign: true, + AdminSign: true, + }, + Docker: Docker{ + ShouldSave: true, + Architectures: []Architecture{ + ArchAMD64, + ArchARM64, + ArchARMv7, + }, + Distribution: []Distribution{ + Alpine, + Ubuntu, + }, + PrereleaseBucket: "grafana-prerelease/artifacts/docker", + }, + Buckets: Buckets{ + Artifacts: "grafana-prerelease/artifacts/downloads", + ArtifactsEnterprise2: "grafana-prerelease/artifacts/downloads-enterprise2", + CDNAssets: "grafana-prerelease", + CDNAssetsDir: "artifacts/static-assets", + Storybook: "grafana-prerelease", + StorybookSrcDir: "artifacts/storybook", + }, + }, } diff --git a/pkg/build/packaging/grafana.go b/pkg/build/packaging/grafana.go index 7c8331f8a70..4500f72f569 100644 --- a/pkg/build/packaging/grafana.go +++ b/pkg/build/packaging/grafana.go @@ -340,18 +340,6 @@ func createPackage(srcDir string, options linuxPackageOptions) error { return err } - // remove unneeded binaries, these are exposed via wrappers that provide the needed configuration - for _, fileName := range []string{ - cliBinary, - cliBinary + ".md5", - serverBinary, - serverBinary + ".md5", - } { - if err := os.Remove(filepath.Join(packageRoot, options.homeBinDir, fileName)); err != nil { - return fmt.Errorf("failed to remove %q: %w", filepath.Join(options.homeBinDir, fileName), err) - } - } - if err := executeFPM(options, packageRoot, srcDir); err != nil { return err } diff --git a/pkg/build/packaging/grafana_test.go b/pkg/build/packaging/grafana_test.go new file mode 100644 index 00000000000..8f143893085 --- /dev/null +++ b/pkg/build/packaging/grafana_test.go @@ -0,0 +1,22 @@ +package packaging_test + +import ( + "testing" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/packaging" + "github.com/stretchr/testify/assert" +) + +func TestPackageRegexp(t *testing.T) { + t.Run("It should match enterprise2 packages", func(t *testing.T) { + rgx := packaging.PackageRegexp(config.EditionEnterprise2) + matches := []string{ + "grafana-enterprise2-1.2.3-4567pre.linux-amd64.tar.gz", + "grafana-enterprise2-1.2.3-4567pre.linux-amd64.tar.gz.sha256", + } + for _, v := range matches { + assert.Truef(t, rgx.MatchString(v), "'%s' should match regex '%s'", v, rgx.String()) + } + }) +} diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index 194edfeac4b..deb397a0bf2 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -12,9 +12,13 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" ) @@ -102,7 +106,7 @@ func TestBuildConflictBlock(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) - + usrSvc := setupTestUserService(t, sqlStore) if sqlStore.GetDialect().DriverName() != ignoredDatabase { for _, u := range tc.users { cmd := user.CreateUserCommand{ @@ -111,7 +115,7 @@ func TestBuildConflictBlock(t *testing.T) { Login: u.Login, OrgID: int64(testOrgID), } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) } m, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) @@ -207,7 +211,7 @@ conflict: test2 t.Run(tc.desc, func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) - + usrSvc := setupTestUserService(t, sqlStore) if sqlStore.GetDialect().DriverName() != ignoredDatabase { for _, u := range tc.users { cmd := user.CreateUserCommand{ @@ -216,7 +220,7 @@ conflict: test2 Login: u.Login, OrgID: int64(testOrgID), } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) } @@ -385,6 +389,7 @@ func TestGetConflictingUsers(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) + usrSvc := setupTestUserService(t, sqlStore) if sqlStore.GetDialect().DriverName() != ignoredDatabase { for _, u := range tc.users { cmd := user.CreateUserCommand{ @@ -394,7 +399,7 @@ func TestGetConflictingUsers(t *testing.T) { OrgID: int64(testOrgID), IsServiceAccount: u.IsServiceAccount, } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) } m, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) @@ -493,6 +498,7 @@ func TestGenerateConflictingUsersFile(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) + usrSvc := setupTestUserService(t, sqlStore) if sqlStore.GetDialect().DriverName() != ignoredDatabase { for _, u := range tc.users { cmd := user.CreateUserCommand{ @@ -501,7 +507,7 @@ func TestGenerateConflictingUsersFile(t *testing.T) { Login: u.Login, OrgID: int64(testOrgID), } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) } m, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) @@ -543,6 +549,8 @@ func TestRunValidateConflictUserFile(t *testing.T) { t.Run("should validate file thats gets created", func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) + usrSvc := setupTestUserService(t, sqlStore) + const testOrgID int64 = 1 if sqlStore.GetDialect().DriverName() != ignoredDatabase { // add additional user with conflicting login where DOMAIN is upper case @@ -551,14 +559,14 @@ func TestRunValidateConflictUserFile(t *testing.T) { Login: "user_duplicate_test_1_login", OrgID: testOrgID, } - _, err := sqlStore.CreateUser(context.Background(), dupUserLogincmd) + _, err := usrSvc.Create(context.Background(), &dupUserLogincmd) require.NoError(t, err) dupUserEmailcmd := user.CreateUserCommand{ Email: "USERDUPLICATETEST1@TEST.COM", Login: "USER_DUPLICATE_TEST_1_LOGIN", OrgID: testOrgID, } - _, err = sqlStore.CreateUser(context.Background(), dupUserEmailcmd) + _, err = usrSvc.Create(context.Background(), &dupUserEmailcmd) require.NoError(t, err) // get users @@ -589,6 +597,7 @@ func TestIntegrationMergeUser(t *testing.T) { teamSvc := teamimpl.ProvideService(sqlStore, setting.NewCfg()) team1, err := teamSvc.CreateTeam("team1 name", "", 1) require.Nil(t, err) + usrSvc := setupTestUserService(t, sqlStore) const testOrgID int64 = 1 if sqlStore.GetDialect().DriverName() != ignoredDatabase { @@ -601,7 +610,7 @@ func TestIntegrationMergeUser(t *testing.T) { Login: "user_duplicate_test_1_login", OrgID: testOrgID, } - _, err := sqlStore.CreateUser(context.Background(), dupUserLogincmd) + _, err := usrSvc.Create(context.Background(), &dupUserLogincmd) require.NoError(t, err) dupUserEmailcmd := user.CreateUserCommand{ Email: "USERDUPLICATETEST1@TEST.COM", @@ -609,7 +618,7 @@ func TestIntegrationMergeUser(t *testing.T) { Login: "USER_DUPLICATE_TEST_1_LOGIN", OrgID: testOrgID, } - userWithUpperCase, err := sqlStore.CreateUser(context.Background(), dupUserEmailcmd) + userWithUpperCase, err := usrSvc.Create(context.Background(), &dupUserEmailcmd) require.NoError(t, err) // this is the user we want to update to another team err = teamSvc.AddTeamMember(userWithUpperCase.ID, testOrgID, team1.Id, false, 0) @@ -746,6 +755,7 @@ conflict: test2 for _, tc := range testCases { // Restore after destructive operation sqlStore := db.InitTestDB(t) + usrSvc := setupTestUserService(t, sqlStore) if sqlStore.GetDialect().DriverName() != ignoredDatabase { for _, u := range tc.users { cmd := user.CreateUserCommand{ @@ -754,7 +764,7 @@ conflict: test2 Login: u.Login, OrgID: int64(testOrgID), } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) } // add additional user with conflicting login where DOMAIN is upper case @@ -840,3 +850,13 @@ func TestMarshalConflictUser(t *testing.T) { }) } } + +func setupTestUserService(t *testing.T, sqlStore *sqlstore.SQLStore) user.Service { + t.Helper() + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, "atest.FakeQuotaService{}) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, "atest.FakeQuotaService{}) + require.NoError(t, err) + + return usrSvc +} diff --git a/pkg/codegen/generators.go b/pkg/codegen/generators.go index 1a56f71a5b4..1cf3079927f 100644 --- a/pkg/codegen/generators.go +++ b/pkg/codegen/generators.go @@ -67,7 +67,7 @@ func (decl *DeclForGen) Lineage() thema.Lineage { // ForLatestSchema returns a [SchemaForGen] for the latest schema in this // DeclForGen's lineage. func (decl *DeclForGen) ForLatestSchema() SchemaForGen { - comm := decl.Meta.Common() + comm := decl.Properties.Common() return SchemaForGen{ Name: comm.Name, Schema: decl.Lineage().Latest(), diff --git a/pkg/codegen/jenny_corestructkind.go b/pkg/codegen/jenny_corestructkind.go index 0d1fad63e17..98b0d318258 100644 --- a/pkg/codegen/jenny_corestructkind.go +++ b/pkg/codegen/jenny_corestructkind.go @@ -21,7 +21,7 @@ func CoreStructuredKindJenny(gokindsdir string, cfg *CoreStructuredKindGenerator } if cfg.GenDirName == nil { cfg.GenDirName = func(decl *DeclForGen) string { - return decl.Meta.Common().MachineName + return decl.Properties.Common().MachineName } } @@ -54,7 +54,7 @@ func (gen *genCoreStructuredKind) Generate(decl *DeclForGen) (*codejen.File, err return nil, nil } - path := filepath.Join(gen.gokindsdir, gen.cfg.GenDirName(decl), decl.Meta.Common().MachineName+"_kind_gen.go") + path := filepath.Join(gen.gokindsdir, gen.cfg.GenDirName(decl), decl.Properties.Common().MachineName+"_kind_gen.go") buf := new(bytes.Buffer) if err := tmpls.Lookup("kind_corestructured.tmpl").Execute(buf, decl); err != nil { return nil, fmt.Errorf("failed executing kind_corestructured template for %s: %w", path, err) diff --git a/pkg/codegen/jenny_eachmajor.go b/pkg/codegen/jenny_eachmajor.go index 766fcad5bf0..dd95e186ad6 100644 --- a/pkg/codegen/jenny_eachmajor.go +++ b/pkg/codegen/jenny_eachmajor.go @@ -33,7 +33,7 @@ func (j *lmox) Generate(decl *DeclForGen) (codejen.Files, error) { if decl.IsRaw() { return nil, nil } - comm := decl.Meta.Common() + comm := decl.Properties.Common() sfg := SchemaForGen{ Name: comm.Name, IsGroup: comm.LineageIsGroup, @@ -42,7 +42,7 @@ func (j *lmox) Generate(decl *DeclForGen) (codejen.Files, error) { do := func(sfg SchemaForGen, infix string) (codejen.Files, error) { f, err := j.inner.Generate(sfg) if err != nil { - return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), decl.Meta.Common().Name, err) + return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), decl.Properties.Common().Name, err) } if f == nil || !f.Exists() { return nil, nil diff --git a/pkg/codegen/jenny_rawkind.go b/pkg/codegen/jenny_rawkind.go index 45c1675a01b..b8e967c951f 100644 --- a/pkg/codegen/jenny_rawkind.go +++ b/pkg/codegen/jenny_rawkind.go @@ -21,7 +21,7 @@ func RawKindJenny(gokindsdir string, cfg *RawKindGeneratorConfig) OneToOne { } if cfg.GenDirName == nil { cfg.GenDirName = func(decl *DeclForGen) string { - return decl.Meta.Common().MachineName + return decl.Properties.Common().MachineName } } @@ -51,7 +51,7 @@ func (gen *genRawKind) Generate(decl *DeclForGen) (*codejen.File, error) { return nil, nil } - path := filepath.Join(gen.gokindsdir, gen.cfg.GenDirName(decl), decl.Meta.Common().MachineName+"_kind_gen.go") + path := filepath.Join(gen.gokindsdir, gen.cfg.GenDirName(decl), decl.Properties.Common().MachineName+"_kind_gen.go") buf := new(bytes.Buffer) if err := tmpls.Lookup("kind_raw.tmpl").Execute(buf, decl); err != nil { return nil, fmt.Errorf("failed executing kind_raw template for %s: %w", path, err) diff --git a/pkg/codegen/jenny_tsveneerindex.go b/pkg/codegen/jenny_tsveneerindex.go index 1d5b11dea2c..cfe70117ca5 100644 --- a/pkg/codegen/jenny_tsveneerindex.go +++ b/pkg/codegen/jenny_tsveneerindex.go @@ -48,15 +48,15 @@ func (gen *genTSVeneerIndex) Generate(decls ...*DeclForGen) (*codejen.File, erro sch := decl.Lineage().Latest() f, err := typescript.GenerateTypes(sch, &typescript.TypeConfig{ - RootName: decl.Meta.Common().Name, - Group: decl.Meta.Common().LineageIsGroup, + RootName: decl.Properties.Common().Name, + Group: decl.Properties.Common().LineageIsGroup, }) if err != nil { - return nil, fmt.Errorf("%s: %w", decl.Meta.Common().Name, err) + return nil, fmt.Errorf("%s: %w", decl.Properties.Common().Name, err) } elems, err := gen.extractTSIndexVeneerElements(decl, f) if err != nil { - return nil, fmt.Errorf("%s: %w", decl.Meta.Common().Name, err) + return nil, fmt.Errorf("%s: %w", decl.Properties.Common().Name, err) } tsf.Nodes = append(tsf.Nodes, elems...) } @@ -66,7 +66,7 @@ func (gen *genTSVeneerIndex) Generate(decls ...*DeclForGen) (*codejen.File, erro func (gen *genTSVeneerIndex) extractTSIndexVeneerElements(decl *DeclForGen, tf *ast.File) ([]ast.Decl, error) { lin := decl.Lineage() - comm := decl.Meta.Common() + comm := decl.Properties.Common() // Check the root, then walk the tree rootv := lin.Latest().Underlying() @@ -131,7 +131,7 @@ func (gen *genTSVeneerIndex) extractTSIndexVeneerElements(decl *DeclForGen, tf * } vpath := fmt.Sprintf("v%v", thema.LatestVersion(lin)[0]) - if decl.Meta.Common().Maturity.Less(kindsys.MaturityStable) { + if decl.Properties.Common().Maturity.Less(kindsys.MaturityStable) { vpath = "x" } diff --git a/pkg/codegen/latest_jenny.go b/pkg/codegen/latest_jenny.go index 5f24052a46a..327ef398663 100644 --- a/pkg/codegen/latest_jenny.go +++ b/pkg/codegen/latest_jenny.go @@ -35,7 +35,7 @@ func (j *latestj) Generate(decl *DeclForGen) (*codejen.File, error) { if decl.IsRaw() { return nil, nil } - comm := decl.Meta.Common() + comm := decl.Properties.Common() sfg := SchemaForGen{ Name: comm.Name, Schema: decl.Lineage().Latest(), @@ -44,7 +44,7 @@ func (j *latestj) Generate(decl *DeclForGen) (*codejen.File, error) { f, err := j.inner.Generate(sfg) if err != nil { - return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), decl.Meta.Common().Name, err) + return nil, fmt.Errorf("%s jenny failed on %s schema for %s: %w", j.inner.JennyName(), sfg.Schema.Version(), decl.Properties.Common().Name, err) } if f == nil || !f.Exists() { return nil, nil diff --git a/pkg/codegen/tmpl/kind_corestructured.tmpl b/pkg/codegen/tmpl/kind_corestructured.tmpl index a02a686b687..6e6755224ea 100644 --- a/pkg/codegen/tmpl/kind_corestructured.tmpl +++ b/pkg/codegen/tmpl/kind_corestructured.tmpl @@ -1,4 +1,4 @@ -package {{ .Meta.MachineName }} +package {{ .Properties.MachineName }} import ( "github.com/grafana/grafana/pkg/kindsys" @@ -10,14 +10,14 @@ import ( // directory containing the .cue files in which this kind is declared. Necessary // for runtime errors related to the declaration and/or lineage to provide // a real path to the correct .cue file. -const rootrel string = "kinds/structured/{{ .Meta.MachineName }}" +const rootrel string = "kinds/structured/{{ .Properties.MachineName }}" // TODO standard generated docs type Kind struct { - lin thema.ConvergentLineage[*{{ .Meta.Name }}] + lin thema.ConvergentLineage[*{{ .Properties.Name }}] jcodec vmux.Codec - valmux vmux.ValueMux[*{{ .Meta.Name }}] - decl kindsys.Decl[kindsys.CoreStructuredMeta] + valmux vmux.ValueMux[*{{ .Properties.Name }}] + decl kindsys.Decl[kindsys.CoreStructuredProperties] } // type guard @@ -25,7 +25,7 @@ var _ kindsys.Structured = &Kind{} // TODO standard generated docs func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rootrel, rt.Context(), nil) + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredProperties](rootrel, rt.Context(), nil) if err != nil { return nil, err } @@ -40,14 +40,14 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { // Get the thema.Schema that the meta says is in the current version (which // codegen ensures is always the latest) - cursch := thema.SchemaP(lin, k.decl.Meta.CurrentVersion) - tsch, err := thema.BindType[*{{ .Meta.Name }}](cursch, &{{ .Meta.Name }}{}) + cursch := thema.SchemaP(lin, k.decl.Properties.CurrentVersion) + tsch, err := thema.BindType[*{{ .Properties.Name }}](cursch, &{{ .Properties.Name }}{}) if err != nil { // Should be unreachable, modulo bugs in the Thema->Go code generator return nil, err } - k.jcodec = vmux.NewJSONCodec("{{ .Meta.MachineName }}.json") + k.jcodec = vmux.NewJSONCodec("{{ .Properties.MachineName }}.json") k.lin = tsch.ConvergentLineage() k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) return k, nil @@ -55,12 +55,12 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { // TODO standard generated docs func (k *Kind) Name() string { - return "{{ .Meta.MachineName }}" + return "{{ .Properties.MachineName }}" } // TODO standard generated docs func (k *Kind) MachineName() string { - return "{{ .Meta.MachineName }}" + return "{{ .Properties.MachineName }}" } // TODO standard generated docs @@ -69,28 +69,37 @@ func (k *Kind) Lineage() thema.Lineage { } // TODO standard generated docs -func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*{{ .Meta.Name }}] { +func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*{{ .Properties.Name }}] { return k.lin } // JSONValueMux is a version multiplexer that maps a []byte containing JSON data -// at any schematized dashboard version to an instance of {{ .Meta.Name }}. +// at any schematized dashboard version to an instance of {{ .Properties.Name }}. // // Validation and translation errors emitted from this func will identify the // input bytes as "dashboard.json". // // This is a thin wrapper around Thema's [vmux.ValueMux]. -func (k *Kind) JSONValueMux(b []byte) (*{{ .Meta.Name }}, thema.TranslationLacunas, error) { +func (k *Kind) JSONValueMux(b []byte) (*{{ .Properties.Name }}, thema.TranslationLacunas, error) { return k.valmux(b) } // TODO standard generated docs func (k *Kind) Maturity() kindsys.Maturity { - return k.decl.Meta.Maturity + return k.decl.Properties.Maturity } -// TODO standard generated docs -func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredMeta] { +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// {{ .Properties.MachineName }} declaration in .cue files. +func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredProperties] { d := k.decl return &d } + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.CoreStructuredProperties], +// representing the static properties declared in the {{ .Properties.MachineName }} kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/codegen/tmpl/kind_raw.tmpl b/pkg/codegen/tmpl/kind_raw.tmpl index a38070c67e3..52380de4500 100644 --- a/pkg/codegen/tmpl/kind_raw.tmpl +++ b/pkg/codegen/tmpl/kind_raw.tmpl @@ -1,4 +1,4 @@ -package {{ .Meta.MachineName }} +package {{ .Properties.MachineName }} import ( "github.com/grafana/grafana/pkg/kindsys" @@ -8,7 +8,7 @@ import ( // TODO standard generated docs type Kind struct { - decl kindsys.Decl[kindsys.RawMeta] + decl kindsys.Decl[kindsys.RawProperties] } // type guard @@ -16,7 +16,7 @@ var _ kindsys.Raw = &Kind{} // TODO standard generated docs func NewKind() (*Kind, error) { - decl, err := kindsys.LoadCoreKind[kindsys.RawMeta]("kinds/raw/{{ .Meta.MachineName }}", nil, nil) + decl, err := kindsys.LoadCoreKind[kindsys.RawProperties]("kinds/raw/{{ .Properties.MachineName }}", nil, nil) if err != nil { return nil, err } @@ -28,21 +28,30 @@ func NewKind() (*Kind, error) { // TODO standard generated docs func (k *Kind) Name() string { - return "{{ .Meta.Name }}" + return "{{ .Properties.Name }}" } // TODO standard generated docs func (k *Kind) MachineName() string { - return "{{ .Meta.MachineName }}" + return "{{ .Properties.MachineName }}" } // TODO standard generated docs func (k *Kind) Maturity() kindsys.Maturity { - return k.decl.Meta.Maturity + return k.decl.Properties.Maturity } -// TODO standard generated docs -func (k *Kind) Decl() *kindsys.Decl[kindsys.RawMeta] { +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// {{ .Properties.MachineName }} declaration in .cue files. +func (k *Kind) Decl() *kindsys.Decl[kindsys.RawProperties] { d := k.decl return &d } + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.RawProperties], +// representing the static properties declared in the {{ .Properties.MachineName }} kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/codegen/tmpl/kind_registry.tmpl b/pkg/codegen/tmpl/kind_registry.tmpl index e323903a9f9..dc21571c0ea 100644 --- a/pkg/codegen/tmpl/kind_registry.tmpl +++ b/pkg/codegen/tmpl/kind_registry.tmpl @@ -5,7 +5,7 @@ import ( "sync" {{range .Kinds }} - "{{ $.KindPackagePrefix }}/{{ .Meta.MachineName }}"{{end}} + "{{ $.KindPackagePrefix }}/{{ .Properties.MachineName }}"{{end}} "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/kindsys" "github.com/grafana/thema" @@ -25,19 +25,19 @@ type Base struct { all []kindsys.Interface numRaw, numStructured int {{- range .Kinds }} - {{ .Meta.MachineName }} *{{ .Meta.MachineName }}.Kind{{end}} + {{ .Properties.MachineName }} *{{ .Properties.MachineName }}.Kind{{end}} } // type guards var ( {{- range .Kinds }} - _ kindsys.{{ if .IsRaw }}Raw{{ else }}Structured{{ end }} = &{{ .Meta.MachineName }}.Kind{}{{end}} + _ kindsys.{{ if .IsRaw }}Raw{{ else }}Structured{{ end }} = &{{ .Properties.MachineName }}.Kind{}{{end}} ) {{range .Kinds }} -// {{ .Meta.Name }} returns the [kindsys.Interface] implementation for the {{ .Meta.MachineName }} kind. -func (b *Base) {{ .Meta.Name }}() *{{ .Meta.MachineName }}.Kind { - return b.{{ .Meta.MachineName }} +// {{ .Properties.Name }} returns the [kindsys.Interface] implementation for the {{ .Properties.MachineName }} kind. +func (b *Base) {{ .Properties.Name }}() *{{ .Properties.MachineName }}.Kind { + return b.{{ .Properties.MachineName }} } {{end}} @@ -49,11 +49,11 @@ func doNewBase(rt *thema.Runtime) *Base { } {{range .Kinds }} - reg.{{ .Meta.MachineName }}, err = {{ .Meta.MachineName }}.NewKind({{ if .IsCoreStructured }}rt{{ end }}) + reg.{{ .Properties.MachineName }}, err = {{ .Properties.MachineName }}.NewKind({{ if .IsCoreStructured }}rt{{ end }}) if err != nil { - panic(fmt.Sprintf("error while initializing the {{ .Meta.MachineName }} Kind: %s", err)) + panic(fmt.Sprintf("error while initializing the {{ .Properties.MachineName }} Kind: %s", err)) } - reg.all = append(reg.all, reg.{{ .Meta.MachineName }}) + reg.all = append(reg.all, reg.{{ .Properties.MachineName }}) {{end}} return reg diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index d85b7db05e9..14e3f654504 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -31,6 +31,14 @@ var InitTestDBwithCfg = sqlstore.InitTestDBWithCfg var ProvideService = sqlstore.ProvideService var NewSqlBuilder = sqlstore.NewSqlBuilder +func IsTestDbSQLite() bool { + if db, present := os.LookupEnv("GRAFANA_TEST_DB"); !present || db == "sqlite" { + return true + } + + return !IsTestDbMySQL() && !IsTestDbPostgres() +} + func IsTestDbMySQL() bool { if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present { return db == migrator.MySQL diff --git a/pkg/infra/log/requestTiming.go b/pkg/infra/log/requestTiming.go new file mode 100644 index 00000000000..a6b9c0bb5a1 --- /dev/null +++ b/pkg/infra/log/requestTiming.go @@ -0,0 +1,28 @@ +package log + +import ( + "context" + "time" +) + +type requestStartTimeContextKey struct{} + +var requestStartTime = requestStartTimeContextKey{} + +// InitCounter creates a pointer on the context that can be incremented later +func InitstartTime(ctx context.Context, now time.Time) context.Context { + return context.WithValue(ctx, requestStartTime, now) +} + +// TimeSinceStart returns time spend since the request started in grafana +func TimeSinceStart(ctx context.Context, now time.Time) time.Duration { + val := ctx.Value(requestStartTime) + if val != nil { + startTime, ok := val.(time.Time) + if ok { + return now.Sub(startTime) + } + } + + return 0 +} diff --git a/pkg/infra/remotecache/database_storage.go b/pkg/infra/remotecache/database_storage.go index 2b63dd6428a..be5be00011d 100644 --- a/pkg/infra/remotecache/database_storage.go +++ b/pkg/infra/remotecache/database_storage.go @@ -14,12 +14,14 @@ const databaseCacheType = "database" type databaseCache struct { SQLStore db.DB + codec codec log log.Logger } -func newDatabaseCache(sqlstore db.DB) *databaseCache { +func newDatabaseCache(sqlstore db.DB, codec codec) *databaseCache { dc := &databaseCache{ SQLStore: sqlstore, + codec: codec, log: log.New("remotecache.database"), } @@ -78,7 +80,7 @@ func (dc *databaseCache) Get(ctx context.Context, key string) (interface{}, erro } } - if err = decodeGob(cacheHit.Data, item); err != nil { + if err = dc.codec.Decode(ctx, cacheHit.Data, item); err != nil { return err } @@ -90,7 +92,7 @@ func (dc *databaseCache) Get(ctx context.Context, key string) (interface{}, erro func (dc *databaseCache) Set(ctx context.Context, key string, value interface{}, expire time.Duration) error { item := &cachedItem{Val: value} - data, err := encodeGob(item) + data, err := dc.codec.Encode(ctx, item) if err != nil { return err } diff --git a/pkg/infra/remotecache/database_storage_test.go b/pkg/infra/remotecache/database_storage_test.go index 87727683180..6a595c5ff7f 100644 --- a/pkg/infra/remotecache/database_storage_test.go +++ b/pkg/infra/remotecache/database_storage_test.go @@ -16,6 +16,7 @@ func TestDatabaseStorageGarbageCollection(t *testing.T) { db := &databaseCache{ SQLStore: sqlstore, + codec: &gobCodec{}, log: log.New("remotecache.database"), } @@ -64,6 +65,7 @@ func TestSecondSet(t *testing.T) { db := &databaseCache{ SQLStore: sqlstore, + codec: &gobCodec{}, log: log.New("remotecache.database"), } diff --git a/pkg/infra/remotecache/memcached_storage.go b/pkg/infra/remotecache/memcached_storage.go index 56b331cfe99..b8c1b4e4823 100644 --- a/pkg/infra/remotecache/memcached_storage.go +++ b/pkg/infra/remotecache/memcached_storage.go @@ -11,12 +11,14 @@ import ( const memcachedCacheType = "memcached" type memcachedStorage struct { - c *memcache.Client + c *memcache.Client + codec codec } -func newMemcachedStorage(opts *setting.RemoteCacheOptions) *memcachedStorage { +func newMemcachedStorage(opts *setting.RemoteCacheOptions, codec codec) *memcachedStorage { return &memcachedStorage{ - c: memcache.New(opts.ConnStr), + c: memcache.New(opts.ConnStr), + codec: codec, } } @@ -31,7 +33,7 @@ func newItem(sid string, data []byte, expire int32) *memcache.Item { // Set sets value to given key in the cache. func (s *memcachedStorage) Set(ctx context.Context, key string, val interface{}, expires time.Duration) error { item := &cachedItem{Val: val} - bytes, err := encodeGob(item) + bytes, err := s.codec.Encode(ctx, item) if err != nil { return err } @@ -58,7 +60,7 @@ func (s *memcachedStorage) Get(ctx context.Context, key string) (interface{}, er item := &cachedItem{} - err = decodeGob(memcachedItem.Value, item) + err = s.codec.Decode(ctx, memcachedItem.Value, item) if err != nil { return nil, err } diff --git a/pkg/infra/remotecache/redis_storage.go b/pkg/infra/remotecache/redis_storage.go index bc7909b0162..7c76345a5bf 100644 --- a/pkg/infra/remotecache/redis_storage.go +++ b/pkg/infra/remotecache/redis_storage.go @@ -15,7 +15,8 @@ import ( const redisCacheType = "redis" type redisStorage struct { - c *redis.Client + c *redis.Client + codec codec } // parseRedisConnStr parses k=v pairs in csv and builds a redis Options object @@ -76,18 +77,18 @@ func parseRedisConnStr(connStr string) (*redis.Options, error) { return options, nil } -func newRedisStorage(opts *setting.RemoteCacheOptions) (*redisStorage, error) { +func newRedisStorage(opts *setting.RemoteCacheOptions, codec codec) (*redisStorage, error) { opt, err := parseRedisConnStr(opts.ConnStr) if err != nil { return nil, err } - return &redisStorage{c: redis.NewClient(opt)}, nil + return &redisStorage{c: redis.NewClient(opt), codec: codec}, nil } // Set sets value to given key in session. func (s *redisStorage) Set(ctx context.Context, key string, val interface{}, expires time.Duration) error { item := &cachedItem{Val: val} - value, err := encodeGob(item) + value, err := s.codec.Encode(ctx, item) if err != nil { return err } @@ -100,7 +101,7 @@ func (s *redisStorage) Get(ctx context.Context, key string) (interface{}, error) v := s.c.Get(ctx, key) item := &cachedItem{} - err := decodeGob([]byte(v.Val()), item) + err := s.codec.Decode(ctx, []byte(v.Val()), item) if err == nil { return item.Val, nil diff --git a/pkg/infra/remotecache/remotecache.go b/pkg/infra/remotecache/remotecache.go index bce1b75540f..dcbd81f1dd5 100644 --- a/pkg/infra/remotecache/remotecache.go +++ b/pkg/infra/remotecache/remotecache.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" glog "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" ) @@ -29,8 +30,14 @@ const ( ServiceName = "RemoteCache" ) -func ProvideService(cfg *setting.Cfg, sqlStore db.DB) (*RemoteCache, error) { - client, err := createClient(cfg.RemoteCacheOptions, sqlStore) +func ProvideService(cfg *setting.Cfg, sqlStore db.DB, secretsService secrets.Service) (*RemoteCache, error) { + var codec codec + if cfg.RemoteCacheOptions.Encryption { + codec = &encryptionCodec{secretsService} + } else { + codec = &gobCodec{} + } + client, err := createClient(cfg.RemoteCacheOptions, sqlStore, codec) if err != nil { return nil, err } @@ -97,20 +104,24 @@ func (ds *RemoteCache) Run(ctx context.Context) error { return ctx.Err() } -func createClient(opts *setting.RemoteCacheOptions, sqlstore db.DB) (CacheStorage, error) { - if opts.Name == redisCacheType { - return newRedisStorage(opts) +func createClient(opts *setting.RemoteCacheOptions, sqlstore db.DB, codec codec) (cache CacheStorage, err error) { + switch opts.Name { + case redisCacheType: + cache, err = newRedisStorage(opts, codec) + case memcachedCacheType: + cache = newMemcachedStorage(opts, codec) + case databaseCacheType: + cache = newDatabaseCache(sqlstore, codec) + default: + return nil, ErrInvalidCacheType } - - if opts.Name == memcachedCacheType { - return newMemcachedStorage(opts), nil + if err != nil { + return cache, err } - - if opts.Name == databaseCacheType { - return newDatabaseCache(sqlstore), nil + if opts.Prefix != "" { + cache = &prefixCacheStorage{cache: cache, prefix: opts.Prefix} } - - return nil, ErrInvalidCacheType + return cache, nil } // Register records a type, identified by a value for that type, under its @@ -127,13 +138,57 @@ type cachedItem struct { Val interface{} } -func encodeGob(item *cachedItem) ([]byte, error) { +type codec interface { + Encode(context.Context, *cachedItem) ([]byte, error) + Decode(context.Context, []byte, *cachedItem) error +} + +type gobCodec struct{} + +func (c *gobCodec) Encode(_ context.Context, item *cachedItem) ([]byte, error) { buf := bytes.NewBuffer(nil) err := gob.NewEncoder(buf).Encode(item) return buf.Bytes(), err } -func decodeGob(data []byte, out *cachedItem) error { +func (c *gobCodec) Decode(_ context.Context, data []byte, out *cachedItem) error { buf := bytes.NewBuffer(data) return gob.NewDecoder(buf).Decode(&out) } + +type encryptionCodec struct { + secretsService secrets.Service +} + +func (c *encryptionCodec) Encode(ctx context.Context, item *cachedItem) ([]byte, error) { + buf := bytes.NewBuffer(nil) + err := gob.NewEncoder(buf).Encode(item) + if err != nil { + return nil, err + } + return c.secretsService.Encrypt(ctx, buf.Bytes(), secrets.WithoutScope()) +} + +func (c *encryptionCodec) Decode(ctx context.Context, data []byte, out *cachedItem) error { + decrypted, err := c.secretsService.Decrypt(ctx, data) + if err != nil { + return err + } + buf := bytes.NewBuffer(decrypted) + return gob.NewDecoder(buf).Decode(&out) +} + +type prefixCacheStorage struct { + cache CacheStorage + prefix string +} + +func (pcs *prefixCacheStorage) Get(ctx context.Context, key string) (interface{}, error) { + return pcs.cache.Get(ctx, pcs.prefix+key) +} +func (pcs *prefixCacheStorage) Set(ctx context.Context, key string, value interface{}, expire time.Duration) error { + return pcs.cache.Set(ctx, pcs.prefix+key, value, expire) +} +func (pcs *prefixCacheStorage) Delete(ctx context.Context, key string) error { + return pcs.cache.Delete(ctx, pcs.prefix+key) +} diff --git a/pkg/infra/remotecache/remotecache_test.go b/pkg/infra/remotecache/remotecache_test.go index 41b6381d813..7e0947c75d7 100644 --- a/pkg/infra/remotecache/remotecache_test.go +++ b/pkg/infra/remotecache/remotecache_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/setting" ) @@ -27,7 +29,7 @@ func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore d cfg := &setting.Cfg{ RemoteCacheOptions: opts, } - dc, err := ProvideService(cfg, sqlstore) + dc, err := ProvideService(cfg, sqlstore, fakes.NewFakeSecretsService()) require.Nil(t, err, "Failed to init client for test") return dc @@ -45,7 +47,7 @@ func TestCachedBasedOnConfig(t *testing.T) { } func TestInvalidCacheTypeReturnsError(t *testing.T) { - _, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil) + _, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil, &gobCodec{}) assert.Equal(t, err, ErrInvalidCacheType) } @@ -88,3 +90,28 @@ func canNotFetchExpiredItems(t *testing.T, client CacheStorage) { _, err = client.Get(context.Background(), "key1") assert.Equal(t, err, ErrCacheItemNotFound) } + +func TestCachePrefix(t *testing.T) { + db := db.InitTestDB(t) + cache := &databaseCache{ + SQLStore: db, + log: log.New("remotecache.database"), + codec: &gobCodec{}, + } + prefixCache := &prefixCacheStorage{cache: cache, prefix: "test/"} + + // Set a value (with a prefix) + err := prefixCache.Set(context.Background(), "foo", "bar", time.Hour) + require.NoError(t, err) + // Get a value (with a prefix) + v, err := prefixCache.Get(context.Background(), "foo") + require.NoError(t, err) + require.Equal(t, "bar", v) + // Get a value directly from the underlying cache, ensure the prefix is in the key + v, err = cache.Get(context.Background(), "test/foo") + require.NoError(t, err) + require.Equal(t, "bar", v) + // Get a value directly from the underlying cache without a prefix, should not be there + _, err = cache.Get(context.Background(), "foo") + require.Error(t, err) +} diff --git a/pkg/infra/remotecache/testing.go b/pkg/infra/remotecache/testing.go index 008fbaafced..f42252aa7c1 100644 --- a/pkg/infra/remotecache/testing.go +++ b/pkg/infra/remotecache/testing.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/setting" ) @@ -22,7 +23,7 @@ func NewFakeStore(t *testing.T) *RemoteCache { dc, err := ProvideService(&setting.Cfg{ RemoteCacheOptions: opts, - }, sqlStore) + }, sqlStore, fakes.NewFakeSecretsService()) require.NoError(t, err, "Failed to init remote cache for test") return dc diff --git a/pkg/kinds/dashboard/dashboard_kind_gen.go b/pkg/kinds/dashboard/dashboard_kind_gen.go index 71c47986121..f99cd07950c 100644 --- a/pkg/kinds/dashboard/dashboard_kind_gen.go +++ b/pkg/kinds/dashboard/dashboard_kind_gen.go @@ -26,7 +26,7 @@ type Kind struct { lin thema.ConvergentLineage[*Dashboard] jcodec vmux.Codec valmux vmux.ValueMux[*Dashboard] - decl kindsys.Decl[kindsys.CoreStructuredMeta] + decl kindsys.Decl[kindsys.CoreStructuredProperties] } // type guard @@ -34,7 +34,7 @@ var _ kindsys.Structured = &Kind{} // TODO standard generated docs func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rootrel, rt.Context(), nil) + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredProperties](rootrel, rt.Context(), nil) if err != nil { return nil, err } @@ -49,7 +49,7 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { // Get the thema.Schema that the meta says is in the current version (which // codegen ensures is always the latest) - cursch := thema.SchemaP(lin, k.decl.Meta.CurrentVersion) + cursch := thema.SchemaP(lin, k.decl.Properties.CurrentVersion) tsch, err := thema.BindType[*Dashboard](cursch, &Dashboard{}) if err != nil { // Should be unreachable, modulo bugs in the Thema->Go code generator @@ -95,11 +95,20 @@ func (k *Kind) JSONValueMux(b []byte) (*Dashboard, thema.TranslationLacunas, err // TODO standard generated docs func (k *Kind) Maturity() kindsys.Maturity { - return k.decl.Meta.Maturity + return k.decl.Properties.Maturity } -// TODO standard generated docs -func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredMeta] { +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// dashboard declaration in .cue files. +func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredProperties] { d := k.decl return &d } + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.CoreStructuredProperties], +// representing the static properties declared in the dashboard kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/kinds/playlist/playlist_kind_gen.go b/pkg/kinds/playlist/playlist_kind_gen.go index e67a4bc3dc2..ee9026aedcd 100644 --- a/pkg/kinds/playlist/playlist_kind_gen.go +++ b/pkg/kinds/playlist/playlist_kind_gen.go @@ -26,7 +26,7 @@ type Kind struct { lin thema.ConvergentLineage[*Playlist] jcodec vmux.Codec valmux vmux.ValueMux[*Playlist] - decl kindsys.Decl[kindsys.CoreStructuredMeta] + decl kindsys.Decl[kindsys.CoreStructuredProperties] } // type guard @@ -34,7 +34,7 @@ var _ kindsys.Structured = &Kind{} // TODO standard generated docs func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rootrel, rt.Context(), nil) + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredProperties](rootrel, rt.Context(), nil) if err != nil { return nil, err } @@ -49,7 +49,7 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { // Get the thema.Schema that the meta says is in the current version (which // codegen ensures is always the latest) - cursch := thema.SchemaP(lin, k.decl.Meta.CurrentVersion) + cursch := thema.SchemaP(lin, k.decl.Properties.CurrentVersion) tsch, err := thema.BindType[*Playlist](cursch, &Playlist{}) if err != nil { // Should be unreachable, modulo bugs in the Thema->Go code generator @@ -95,11 +95,20 @@ func (k *Kind) JSONValueMux(b []byte) (*Playlist, thema.TranslationLacunas, erro // TODO standard generated docs func (k *Kind) Maturity() kindsys.Maturity { - return k.decl.Meta.Maturity + return k.decl.Properties.Maturity } -// TODO standard generated docs -func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredMeta] { +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// playlist declaration in .cue files. +func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredProperties] { d := k.decl return &d } + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.CoreStructuredProperties], +// representing the static properties declared in the playlist kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/kinds/svg/svg_kind_gen.go b/pkg/kinds/svg/svg_kind_gen.go index b3f6a4f3150..117a525c8a8 100644 --- a/pkg/kinds/svg/svg_kind_gen.go +++ b/pkg/kinds/svg/svg_kind_gen.go @@ -15,7 +15,7 @@ import ( // TODO standard generated docs type Kind struct { - decl kindsys.Decl[kindsys.RawMeta] + decl kindsys.Decl[kindsys.RawProperties] } // type guard @@ -23,7 +23,7 @@ var _ kindsys.Raw = &Kind{} // TODO standard generated docs func NewKind() (*Kind, error) { - decl, err := kindsys.LoadCoreKind[kindsys.RawMeta]("kinds/raw/svg", nil, nil) + decl, err := kindsys.LoadCoreKind[kindsys.RawProperties]("kinds/raw/svg", nil, nil) if err != nil { return nil, err } @@ -45,11 +45,20 @@ func (k *Kind) MachineName() string { // TODO standard generated docs func (k *Kind) Maturity() kindsys.Maturity { - return k.decl.Meta.Maturity + return k.decl.Properties.Maturity } -// TODO standard generated docs -func (k *Kind) Decl() *kindsys.Decl[kindsys.RawMeta] { +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// svg declaration in .cue files. +func (k *Kind) Decl() *kindsys.Decl[kindsys.RawProperties] { d := k.decl return &d } + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.RawProperties], +// representing the static properties declared in the svg kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/kinds/team/team_kind_gen.go b/pkg/kinds/team/team_kind_gen.go index 0401069ee17..2a8fd883210 100644 --- a/pkg/kinds/team/team_kind_gen.go +++ b/pkg/kinds/team/team_kind_gen.go @@ -26,7 +26,7 @@ type Kind struct { lin thema.ConvergentLineage[*Team] jcodec vmux.Codec valmux vmux.ValueMux[*Team] - decl kindsys.Decl[kindsys.CoreStructuredMeta] + decl kindsys.Decl[kindsys.CoreStructuredProperties] } // type guard @@ -34,7 +34,7 @@ var _ kindsys.Structured = &Kind{} // TODO standard generated docs func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { - decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rootrel, rt.Context(), nil) + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredProperties](rootrel, rt.Context(), nil) if err != nil { return nil, err } @@ -49,7 +49,7 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { // Get the thema.Schema that the meta says is in the current version (which // codegen ensures is always the latest) - cursch := thema.SchemaP(lin, k.decl.Meta.CurrentVersion) + cursch := thema.SchemaP(lin, k.decl.Properties.CurrentVersion) tsch, err := thema.BindType[*Team](cursch, &Team{}) if err != nil { // Should be unreachable, modulo bugs in the Thema->Go code generator @@ -95,11 +95,20 @@ func (k *Kind) JSONValueMux(b []byte) (*Team, thema.TranslationLacunas, error) { // TODO standard generated docs func (k *Kind) Maturity() kindsys.Maturity { - return k.decl.Meta.Maturity + return k.decl.Properties.Maturity } -// TODO standard generated docs -func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredMeta] { +// Decl returns the [kindsys.Decl] containing both CUE and Go representations of the +// team declaration in .cue files. +func (k *Kind) Decl() *kindsys.Decl[kindsys.CoreStructuredProperties] { d := k.decl return &d } + +// Props returns a [kindsys.SomeKindProps], with underlying type [kindsys.CoreStructuredProperties], +// representing the static properties declared in the team kind. +// +// This method is identical to calling Decl().Props. It is provided to satisfy [kindsys.Interface]. +func (k *Kind) Props() kindsys.SomeKindProperties { + return k.decl.Properties +} diff --git a/pkg/kindsys/kind.go b/pkg/kindsys/kind.go index 9839954fb69..2add53e98ad 100644 --- a/pkg/kindsys/kind.go +++ b/pkg/kindsys/kind.go @@ -41,15 +41,25 @@ func (m Maturity) Less(om Maturity) bool { return maturityIdx(m) < maturityIdx(om) } -// TODO docs +// Interface describes a Grafana kind object: a Go representation of the definition of +// one of Grafana's categories of kinds. type Interface interface { - // TODO docs + // Props returns a [kindsys.SomeKindProps], representing the properties + // of the kind as declared in the .cue source. The underlying type is + // determined by the category of kind. + // + // This method is largely for convenience, as all actual kind categories are + // expected to implement one of the other interfaces, each of which contain + // a Decl() method through which these same properties are accessible. + Props() SomeKindProperties + + // TODO remove, unnecessary with Props() Name() string - // TODO docs + // TODO remove, unnecessary with Props() MachineName() string - // TODO docs + // TODO remove, unnecessary with Props() Maturity() Maturity // TODO unclear if we want maturity for raw kinds } @@ -58,7 +68,7 @@ type Raw interface { Interface // TODO docs - Decl() *Decl[RawMeta] + Decl() *Decl[RawProperties] } type Structured interface { @@ -68,7 +78,7 @@ type Structured interface { Lineage() thema.Lineage // TODO docs - Decl() *Decl[CoreStructuredMeta] // TODO figure out how to reconcile this interface with CustomStructuredMeta + Decl() *Decl[CoreStructuredProperties] // TODO figure out how to reconcile this interface with CustomStructuredProperties } // type Composable interface { @@ -78,5 +88,5 @@ type Structured interface { // Lineage() thema.Lineage // // // TODO docs -// Meta() CoreStructuredMeta // TODO figure out how to reconcile this interface with CustomStructuredMeta +// Properties() CoreStructuredProperties // TODO figure out how to reconcile this interface with CustomStructuredProperties // } diff --git a/pkg/kindsys/kindmetas.go b/pkg/kindsys/kindmetas.go index 5470d8e26d6..0e366434337 100644 --- a/pkg/kindsys/kindmetas.go +++ b/pkg/kindsys/kindmetas.go @@ -2,8 +2,8 @@ package kindsys import "github.com/grafana/thema" -// CommonMeta contains the metadata common to all categories of kinds. -type CommonMeta struct { +// CommonProperties contains the metadata common to all categories of kinds. +type CommonProperties struct { Name string `json:"name"` PluralName string `json:"pluralName"` MachineName string `json:"machineName"` @@ -12,63 +12,78 @@ type CommonMeta struct { Maturity Maturity `json:"maturity"` } -// TODO generate from type.cue -type RawMeta struct { - CommonMeta +// RawProperties represents the static properties in a #Raw kind declaration that are +// trivially representable with basic Go types. +// +// When a .cue #Raw declaration is loaded through the standard [LoadCoreKind], +// func, it is fully validated and populated according to all rules specified +// in CUE for #Raw kinds. +type RawProperties struct { + CommonProperties Extensions []string `json:"extensions"` } -func (m RawMeta) _private() {} -func (m RawMeta) Common() CommonMeta { - return m.CommonMeta +func (m RawProperties) _private() {} +func (m RawProperties) Common() CommonProperties { + return m.CommonProperties } -// TODO -type CoreStructuredMeta struct { - CommonMeta +// CoreStructuredProperties represents the static properties in the declaration of a +// #CoreStructured kind that are representable with basic Go types. This +// excludes Thema schemas. +// +// When a .cue #CoreStructured declaration is loaded through the standard [LoadCoreKind], +// func, it is fully validated and populated according to all rules specified +// in CUE for #CoreStructured kinds. +type CoreStructuredProperties struct { + CommonProperties CurrentVersion thema.SyntacticVersion `json:"currentVersion"` } -func (m CoreStructuredMeta) _private() {} -func (m CoreStructuredMeta) Common() CommonMeta { - return m.CommonMeta +func (m CoreStructuredProperties) _private() {} +func (m CoreStructuredProperties) Common() CommonProperties { + return m.CommonProperties } -// TODO -type CustomStructuredMeta struct { - CommonMeta +// CustomStructuredProperties represents the static properties in the declaration of a +// #CustomStructured kind that are representable with basic Go types. This +// excludes Thema schemas. +type CustomStructuredProperties struct { + CommonProperties CurrentVersion thema.SyntacticVersion `json:"currentVersion"` } -func (m CustomStructuredMeta) _private() {} -func (m CustomStructuredMeta) Common() CommonMeta { - return m.CommonMeta +func (m CustomStructuredProperties) _private() {} +func (m CustomStructuredProperties) Common() CommonProperties { + return m.CommonProperties } -// TODO -type ComposableMeta struct { - CommonMeta +// ComposableProperties represents the static properties in the declaration of a +// #Composable kind that are representable with basic Go types. This +// excludes Thema schemas. +type ComposableProperties struct { + CommonProperties CurrentVersion thema.SyntacticVersion `json:"currentVersion"` } -func (m ComposableMeta) _private() {} -func (m ComposableMeta) Common() CommonMeta { - return m.CommonMeta +func (m ComposableProperties) _private() {} +func (m ComposableProperties) Common() CommonProperties { + return m.CommonProperties } -// SomeKindMeta is an interface type to abstract over the different kind -// metadata struct types: [RawMeta], [CoreStructuredMeta], -// [CustomStructuredMeta]. +// SomeKindProperties is an interface type to abstract over the different kind +// property struct types: [RawProperties], [CoreStructuredProperties], +// [CustomStructuredProperties], [ComposableProperties]. // // It is the traditional interface counterpart to the generic type constraint -// KindMetas. -type SomeKindMeta interface { +// KindProperties. +type SomeKindProperties interface { _private() - Common() CommonMeta + Common() CommonProperties } -// KindMetas is a type parameter that comprises the base possible set of +// KindProperties is a type parameter that comprises the base possible set of // kind metadata configurations. -type KindMetas interface { - RawMeta | CoreStructuredMeta | CustomStructuredMeta | ComposableMeta +type KindProperties interface { + RawProperties | CoreStructuredProperties | CustomStructuredProperties | ComposableProperties } diff --git a/pkg/kindsys/load.go b/pkg/kindsys/load.go index 81bf9073c9f..710cb425b49 100644 --- a/pkg/kindsys/load.go +++ b/pkg/kindsys/load.go @@ -83,52 +83,52 @@ func CUEFramework(ctx *cue.Context) cue.Value { // ToKindMeta takes a cue.Value expected to represent a kind of the category // specified by the type parameter and populates the Go type from the cue.Value. -func ToKindMeta[T KindMetas](v cue.Value) (T, error) { - meta := new(T) +func ToKindMeta[T KindProperties](v cue.Value) (T, error) { + props := new(T) if !v.Exists() { - return *meta, ErrValueNotExist + return *props, ErrValueNotExist } fw := CUEFramework(v.Context()) var kdef cue.Value - anymeta := any(*meta).(SomeKindMeta) - switch anymeta.(type) { - case RawMeta: + anyprops := any(*props).(SomeKindProperties) + switch anyprops.(type) { + case RawProperties: kdef = fw.LookupPath(cue.MakePath(cue.Def("Raw"))) - case CoreStructuredMeta: + case CoreStructuredProperties: kdef = fw.LookupPath(cue.MakePath(cue.Def("CoreStructured"))) - case CustomStructuredMeta: + case CustomStructuredProperties: kdef = fw.LookupPath(cue.MakePath(cue.Def("CustomStructured"))) - case ComposableMeta: + case ComposableProperties: kdef = fw.LookupPath(cue.MakePath(cue.Def("Composable"))) default: - // unreachable so long as all the possibilities in KindMetas have switch branches + // unreachable so long as all the possibilities in KindProperties have switch branches panic("unreachable") } item := v.Unify(kdef) if err := item.Validate(cue.Concrete(false), cue.All()); err != nil { - return *meta, ewrap(item.Err(), ErrValueNotAKind) + return *props, ewrap(item.Err(), ErrValueNotAKind) } - if err := item.Decode(meta); err != nil { + if err := item.Decode(props); err != nil { // Should only be reachable if CUE and Go framework types have diverged panic(errors.Details(err, nil)) } - return *meta, nil + return *props, nil } // SomeDecl represents a single kind declaration, having been loaded // and validated by a func such as [LoadCoreKind]. // -// The underlying type of the Meta field indicates the category of +// The underlying type of the Properties field indicates the category of // kind. type SomeDecl struct { // V is the cue.Value containing the entire Kind declaration. V cue.Value - // Meta contains the kind's metadata settings. - Meta SomeKindMeta + // Properties contains the kind's declared properties. + Properties SomeKindProperties } // BindKindLineage binds the lineage for the kind declaration. nil, nil is returned @@ -140,10 +140,10 @@ func (decl *SomeDecl) BindKindLineage(rt *thema.Runtime, opts ...thema.BindOptio if rt == nil { rt = cuectx.GrafanaThemaRuntime() } - switch decl.Meta.(type) { - case RawMeta: + switch decl.Properties.(type) { + case RawProperties: return nil, nil - case CoreStructuredMeta, CustomStructuredMeta, ComposableMeta: + case CoreStructuredProperties, CustomStructuredProperties, ComposableProperties: return thema.BindLineage(decl.V.LookupPath(cue.MakePath(cue.Str("lineage"))), rt, opts...) default: panic("unreachable") @@ -152,25 +152,25 @@ func (decl *SomeDecl) BindKindLineage(rt *thema.Runtime, opts ...thema.BindOptio // IsRaw indicates whether the represented kind is a raw kind. func (decl *SomeDecl) IsRaw() bool { - _, is := decl.Meta.(RawMeta) + _, is := decl.Properties.(RawProperties) return is } // IsCoreStructured indicates whether the represented kind is a core structured kind. func (decl *SomeDecl) IsCoreStructured() bool { - _, is := decl.Meta.(CoreStructuredMeta) + _, is := decl.Properties.(CoreStructuredProperties) return is } // IsCustomStructured indicates whether the represented kind is a custom structured kind. func (decl *SomeDecl) IsCustomStructured() bool { - _, is := decl.Meta.(CustomStructuredMeta) + _, is := decl.Properties.(CustomStructuredProperties) return is } // IsComposable indicates whether the represented kind is a composable kind. func (decl *SomeDecl) IsComposable() bool { - _, is := decl.Meta.(ComposableMeta) + _, is := decl.Properties.(ComposableProperties) return is } @@ -178,18 +178,18 @@ func (decl *SomeDecl) IsComposable() bool { // and validated by a func such as [LoadCoreKind]. // // Its type parameter indicates the category of kind. -type Decl[T KindMetas] struct { +type Decl[T KindProperties] struct { // V is the cue.Value containing the entire Kind declaration. V cue.Value - // Meta contains the kind's metadata settings. - Meta T + // Properties contains the kind's declared properties. + Properties T } // Some converts the typed Decl to the equivalent typeless SomeDecl. func (decl *Decl[T]) Some() *SomeDecl { return &SomeDecl{ - V: decl.V, - Meta: any(decl.Meta).(SomeKindMeta), + V: decl.V, + Properties: any(decl.Properties).(SomeKindProperties), } } @@ -210,7 +210,7 @@ func (decl *Decl[T]) Some() *SomeDecl { // This is a low-level function, primarily intended for use in code generation. // For representations of core kinds that are useful in Go programs at runtime, // see ["github.com/grafana/grafana/pkg/registry/corekind"]. -func LoadCoreKind[T RawMeta | CoreStructuredMeta](declpath string, ctx *cue.Context, overlay fs.FS) (*Decl[T], error) { +func LoadCoreKind[T RawProperties | CoreStructuredProperties](declpath string, ctx *cue.Context, overlay fs.FS) (*Decl[T], error) { vk, err := cuectx.BuildGrafanaInstance(ctx, declpath, "kind", overlay) if err != nil { return nil, err @@ -218,7 +218,7 @@ func LoadCoreKind[T RawMeta | CoreStructuredMeta](declpath string, ctx *cue.Cont decl := &Decl[T]{ V: vk, } - decl.Meta, err = ToKindMeta[T](vk) + decl.Properties, err = ToKindMeta[T](vk) if err != nil { return nil, err } diff --git a/pkg/middleware/logger.go b/pkg/middleware/logger.go index 321daa210d0..4366ca45e45 100644 --- a/pkg/middleware/logger.go +++ b/pkg/middleware/logger.go @@ -35,6 +35,8 @@ func Logger(cfg *setting.Cfg) web.Middleware { // we have to init the context with the counter here to update the request r = r.WithContext(log.InitCounter(r.Context())) + // put the start time on context so we can measure it later. + r = r.WithContext(log.InitstartTime(r.Context(), time.Now())) rw := web.Rw(w, r) next.ServeHTTP(rw, r) diff --git a/pkg/plugins/backendplugin/instrumentation/instrumentation.go b/pkg/plugins/backendplugin/instrumentation/instrumentation.go index 5a42ef9a1fc..b9c12016272 100644 --- a/pkg/plugins/backendplugin/instrumentation/instrumentation.go +++ b/pkg/plugins/backendplugin/instrumentation/instrumentation.go @@ -51,6 +51,13 @@ func instrumentPluginRequest(ctx context.Context, cfg *config.Cfg, pluginCtx *ba "duration", elapsed, "pluginId", pluginCtx.PluginID, "endpoint", endpoint, + "eventName", "grafana-data-egress", + "insight_logs", true, + "since_grafana_request_started", log.TimeSinceStart(ctx, time.Now()), + } + + if pluginCtx.User != nil { + logParams = append(logParams, "uname", pluginCtx.User.Login) } traceID := tracing.TraceIDFromContext(ctx, false) diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index d1d918098de..ddeee01766b 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -18,9 +18,12 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -399,10 +402,17 @@ func TestApi_setUserPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { service, sql, _ := setupTestEnvironment(t, tt.permissions, testOptions) - server := setupTestServer(t, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) + server := setupTestServer(t, &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}, + }, service) // seed user - _, err := sql.CreateUser(context.Background(), user.CreateUserCommand{Login: "test", OrgID: 1}) + orgSvc, err := orgimpl.ProvideService(sql, sql.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sql, orgSvc, sql.Cfg, nil, nil, "atest.FakeQuotaService{}) + require.NoError(t, err) + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "test", OrgID: 1}) require.NoError(t, err) recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "users", strconv.Itoa(int(tt.userID))) @@ -505,7 +515,11 @@ func seedPermissions(t *testing.T, resourceID string, sql *sqlstore.SQLStore, se _, err = service.SetTeamPermission(context.Background(), team.OrgId, team.Id, resourceID, "Edit") require.NoError(t, err) // seed user 1 with "View" permission on dashboard 1 - u, err := sql.CreateUser(context.Background(), user.CreateUserCommand{Login: "test", OrgID: 1}) + orgSvc, err := orgimpl.ProvideService(sql, sql.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sql, orgSvc, sql.Cfg, nil, nil, "atest.FakeQuotaService{}) + require.NoError(t, err) + u, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "test", OrgID: 1}) require.NoError(t, err) _, err = service.SetUserPermission(context.Background(), u.OrgID, accesscontrol.User{ID: u.ID}, resourceID, "View") require.NoError(t, err) diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index c1352b89f9b..605db098460 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" + "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/team" @@ -47,7 +48,11 @@ func TestService_SetUserPermission(t *testing.T) { }) // seed user - user, err := sql.CreateUser(context.Background(), user.CreateUserCommand{Login: "test", OrgID: 1}) + orgSvc, err := orgimpl.ProvideService(sql, sql.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sql, orgSvc, sql.Cfg, nil, nil, "atest.FakeQuotaService{}) + require.NoError(t, err) + user, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "test", OrgID: 1}) require.NoError(t, err) var hookCalled bool @@ -204,7 +209,11 @@ func TestService_SetPermissions(t *testing.T) { service, sql, teamSvc := setupTestEnvironment(t, []accesscontrol.Permission{}, tt.options) // seed user - _, err := sql.CreateUser(context.Background(), user.CreateUserCommand{Login: "user", OrgID: 1}) + orgSvc, err := orgimpl.ProvideService(sql, sql.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sql, orgSvc, sql.Cfg, nil, nil, "atest.FakeQuotaService{}) + require.NoError(t, err) + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "user", OrgID: 1}) require.NoError(t, err) _, err = teamSvc.CreateTeam("team", "", 1) require.NoError(t, err) diff --git a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go index e6fff4cacb8..0e782a7cd20 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go @@ -14,9 +14,12 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" datasourcesService "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) const ( @@ -137,7 +140,11 @@ func generateTeamsAndUsers(b *testing.B, db *sqlstore.SQLStore, users int) ([]in teamSvc := teamimpl.ProvideService(db, db.Cfg) numberOfTeams := int(math.Ceil(float64(users) / UsersPerTeam)) globalUserId := 0 - + qs := quotatest.New(false, nil) + orgSvc, err := orgimpl.ProvideService(db, db.Cfg, qs) + require.NoError(b, err) + usrSvc, err := userimpl.ProvideService(db, orgSvc, db.Cfg, nil, nil, qs) + require.NoError(b, err) userIds := make([]int64, 0) teamIds := make([]int64, 0) for i := 0; i < numberOfTeams; i++ { @@ -155,7 +162,7 @@ func generateTeamsAndUsers(b *testing.B, db *sqlstore.SQLStore, users int) ([]in userEmail := fmt.Sprintf("%s@example.org", userName) createUserCmd := user.CreateUserCommand{Email: userEmail, Name: userName, Login: userName, OrgID: 1} - user, err := db.CreateUser(context.Background(), createUserCmd) + user, err := usrSvc.Create(context.Background(), &createUserCmd) require.NoError(b, err) userId := user.ID globalUserId++ diff --git a/pkg/services/accesscontrol/resourcepermissions/store_test.go b/pkg/services/accesscontrol/resourcepermissions/store_test.go index 81affa327ab..0598e8c9190 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) type setUserResourcePermissionTest struct { @@ -488,6 +489,9 @@ func TestIntegrationStore_GetResourcePermissions(t *testing.T) { func seedResourcePermissions(t *testing.T, store *store, sql *sqlstore.SQLStore, orgService org.Service, actions []string, resource, resourceID, resourceAttribute string, numUsers int) { t.Helper() var orgModel *org.Org + usrSvc, err := userimpl.ProvideService(sql, orgService, sql.Cfg, nil, nil, quotatest.New(false, nil)) + require.NoError(t, err) + for i := 0; i < numUsers; i++ { if orgModel == nil { cmd := &org.CreateOrgCommand{Name: "test", UserID: int64(i)} @@ -496,7 +500,7 @@ func seedResourcePermissions(t *testing.T, store *store, sql *sqlstore.SQLStore, orgModel = addedOrg } - u, err := sql.CreateUser(context.Background(), user.CreateUserCommand{ + u, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{ Login: fmt.Sprintf("user:%s%d", resourceID, i), OrgID: orgModel.ID, }) diff --git a/pkg/services/auth/authimpl/auth_token.go b/pkg/services/auth/authimpl/auth_token.go index 7bf1d8bf332..991143f538a 100644 --- a/pkg/services/auth/authimpl/auth_token.go +++ b/pkg/services/auth/authimpl/auth_token.go @@ -4,30 +4,43 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "net" "strings" "time" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -const urgentRotateTime = 1 * time.Minute +const ( + ttl = 15 * time.Second + urgentRotateTime = 1 * time.Minute +) var getTime = time.Now -func ProvideUserAuthTokenService(sqlStore db.DB, cfg *setting.Cfg, serverLockService *serverlock.ServerLockService, quotaService quota.Service) (*UserAuthTokenService, error) { +func ProvideUserAuthTokenService(sqlStore db.DB, + serverLockService *serverlock.ServerLockService, + remoteCache *remotecache.RemoteCache, + features *featuremgmt.FeatureManager, + quotaService quota.Service, + cfg *setting.Cfg) (*UserAuthTokenService, error) { s := &UserAuthTokenService{ sqlStore: sqlStore, serverLockService: serverLockService, cfg: cfg, log: log.New("auth"), + remoteCache: remoteCache, + features: features, } defaultLimits, err := readQuotaConfig(cfg) @@ -43,6 +56,8 @@ func ProvideUserAuthTokenService(sqlStore db.DB, cfg *setting.Cfg, serverLockSer return s, err } + remotecache.Register(auth.UserToken{}) + return s, nil } @@ -51,6 +66,8 @@ type UserAuthTokenService struct { serverLockService *serverlock.ServerLockService cfg *setting.Cfg log log.Logger + remoteCache *remotecache.RemoteCache + features *featuremgmt.FeatureManager } func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { @@ -101,7 +118,52 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, return &userToken, err } +func (s *UserAuthTokenService) lookupTokenWithCache(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + hashedToken := hashToken(unhashedToken) + cacheKey := "auth_token:" + hashedToken + + session, errCache := s.remoteCache.Get(ctx, cacheKey) + if errCache == nil { + token := session.(auth.UserToken) + return &token, nil + } else { + if errors.Is(errCache, remotecache.ErrCacheItemNotFound) { + s.log.Debug("user auth token not found in cache", + "cacheKey", cacheKey) + } else { + s.log.Warn("failed to get user auth token from cache", + "cacheKey", cacheKey, "error", errCache) + } + } + + token, err := s.lookupToken(ctx, unhashedToken) + if err != nil { + return nil, err + } + + // only cache tokens until their near rotation time + // Near rotation time = tokens last rotation plus the rotation interval minus 2 ttl (=30s by default) + nextRotation := time.Unix(token.RotatedAt, 0). + Add(-2 * ttl). // subtract 2 ttl to make sure we don't cache tokens that are about to expire + Add(time.Duration(s.cfg.TokenRotationIntervalMinutes) * time.Minute) + if now := getTime(); now.Before(nextRotation) { + if err := s.remoteCache.Set(ctx, cacheKey, *token, ttl); err != nil { + s.log.Warn("could not cache token", "error", err, "cacheKey", cacheKey, "userId", token.UserId) + } + } + + return token, nil +} + func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + if s.features != nil && s.features.IsEnabled(featuremgmt.FlagSessionRemoteCache) { + return s.lookupTokenWithCache(ctx, unhashedToken) + } + + return s.lookupToken(ctx, unhashedToken) +} + +func (s *UserAuthTokenService) lookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { hashedToken := hashToken(unhashedToken) var model userAuthToken var exists bool diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index 55737a44720..b53d2935273 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" @@ -79,7 +80,7 @@ func getContextHandler(t *testing.T) *ContextHandler { cfg.AuthProxyHeaderName = "X-Killa" cfg.AuthProxyEnabled = true cfg.AuthProxyHeaderProperty = "username" - remoteCacheSvc, err := remotecache.ProvideService(cfg, sqlStore) + remoteCacheSvc, err := remotecache.ProvideService(cfg, sqlStore, fakes.NewFakeSecretsService()) require.NoError(t, err) userAuthTokenSvc := authtest.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index a894eee743b..ed12223d8ab 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -186,6 +186,11 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { } } + // this can be used by proxies to identify certain users + if h.features.IsEnabled(featuremgmt.FlagReturnUnameHeader) { + w.Header().Add("grafana-uname", reqContext.Login) + } + next.ServeHTTP(w, r) }) } diff --git a/pkg/services/dashboards/database/acl_test.go b/pkg/services/dashboards/database/acl_test.go index ae097469e5d..4e67fe4446b 100644 --- a/pkg/services/dashboards/database/acl_test.go +++ b/pkg/services/dashboards/database/acl_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) func TestIntegrationDashboardACLDataAccess(t *testing.T) { @@ -274,11 +275,14 @@ func createUser(t *testing.T, sqlStore *sqlstore.SQLStore, name string, role str sqlStore.Cfg.AutoAssignOrgId = 1 sqlStore.Cfg.AutoAssignOrgRole = role - orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaimpl.ProvideService(sqlStore, sqlStore.Cfg)) + qs := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, qs) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, qs) require.NoError(t, err) currentUserCmd := user.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin} - currentUser, err := sqlStore.CreateUser(context.Background(), currentUserCmd) + currentUser, err := usrSvc.CreateUserForTests(context.Background(), ¤tUserCmd) require.NoError(t, err) orgs, err := orgService.GetUserOrgList(context.Background(), &org.GetUserOrgListQuery{UserID: currentUser.ID}) require.NoError(t, err) diff --git a/pkg/services/export/commit_helper.go b/pkg/services/export/commit_helper.go index 221912c7738..0cff51fcf76 100644 --- a/pkg/services/export/commit_helper.go +++ b/pkg/services/export/commit_helper.go @@ -13,8 +13,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" jsoniter "github.com/json-iterator/go" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/user" ) @@ -77,7 +77,7 @@ func (ch *commitHelper) initOrg(ctx context.Context, sql db.DB, orgID int64) err OrgID: orgID, // gets filled in from each row UserID: 0, } - ch.ctx = store.ContextWithUser(context.Background(), rowUser) + ch.ctx = appcontext.WithUser(context.Background(), rowUser) return err }) } diff --git a/pkg/services/export/entity_store.go b/pkg/services/export/entity_store.go index 630e683cec9..53ed63f9e5d 100644 --- a/pkg/services/export/entity_store.go +++ b/pkg/services/export/entity_store.go @@ -6,13 +6,13 @@ import ( "sync" "time" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/sqlstore/session" - "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/store/kind/snapshot" "github.com/grafana/grafana/pkg/services/user" @@ -101,7 +101,7 @@ func (e *entityStoreJob) start(ctx context.Context) { OrgID: 0, // gets filled in from each row UserID: 0, } - ctx = store.ContextWithUser(ctx, rowUser) + ctx = appcontext.WithUser(ctx, rowUser) what := models.StandardKindDashboard e.status.Count[what] = 0 diff --git a/pkg/services/export/service.go b/pkg/services/export/service.go index a1e7950e784..3e7f03e14d1 100644 --- a/pkg/services/export/service.go +++ b/pkg/services/export/service.go @@ -11,6 +11,7 @@ import ( "time" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" @@ -20,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/playlist" - "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/setting" ) @@ -225,7 +225,7 @@ func (ex *StandardExport) HandleRequestExport(c *models.ReqContext) response.Res return response.Error(http.StatusLocked, "export already running", nil) } - ctx := store.ContextWithUser(context.Background(), c.SignedInUser) + ctx := appcontext.WithUser(context.Background(), c.SignedInUser) var job Job broadcast := func(s ExportStatus) { ex.broadcastStatus(c.OrgID, s) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 10035d8db75..d430d06fb3e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -9,6 +9,11 @@ package featuremgmt var ( // Register each toggle here standardFeatureFlags = []FeatureFlag{ + { + Name: "returnUnameHeader", + Description: "Return user login as header for authenticated requests", + State: FeatureStateAlpha, + }, { Name: "alertingBigTransactions", Description: "Use big transactions for alerting database writes", @@ -368,6 +373,11 @@ var ( Description: "Use Elasticsearch as backend data source", State: FeatureStateAlpha, }, + { + Name: "datasourceOnboarding", + Description: "Enable data source onboarding page", + State: FeatureStateAlpha, + }, { Name: "secureSocksDatasourceProxy", Description: "Enable secure socks tunneling for supported core datasources", @@ -378,5 +388,10 @@ var ( Description: "Use new auth service to perform authentication", State: FeatureStateAlpha, }, + { + Name: "sessionRemoteCache", + Description: "Enable using remote cache for user sessions", + State: FeatureStateAlpha, + }, } ) diff --git a/pkg/services/featuremgmt/settings.go b/pkg/services/featuremgmt/settings.go index 3ff22a61cad..9b813a94a36 100644 --- a/pkg/services/featuremgmt/settings.go +++ b/pkg/services/featuremgmt/settings.go @@ -3,7 +3,7 @@ package featuremgmt import ( "os" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type configBody struct { diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 31c794f62a2..d27beaaca21 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -7,6 +7,10 @@ package featuremgmt const ( + // FlagReturnUnameHeader + // Return user login as header for authenticated requests + FlagReturnUnameHeader = "returnUnameHeader" + // FlagAlertingBigTransactions // Use big transactions for alerting database writes FlagAlertingBigTransactions = "alertingBigTransactions" @@ -267,6 +271,10 @@ const ( // Use Elasticsearch as backend data source FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" + // FlagDatasourceOnboarding + // Enable data source onboarding page + FlagDatasourceOnboarding = "datasourceOnboarding" + // FlagSecureSocksDatasourceProxy // Enable secure socks tunneling for supported core datasources FlagSecureSocksDatasourceProxy = "secureSocksDatasourceProxy" @@ -274,4 +282,8 @@ const ( // FlagAuthnService // Use new auth service to perform authentication FlagAuthnService = "authnService" + + // FlagSessionRemoteCache + // Enable using remote cache for user sessions + FlagSessionRemoteCache = "sessionRemoteCache" ) diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 92a9d0c0501..9fefe7383e7 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -456,9 +456,36 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol return nil, err } + // if the new parent is the same as the current parent, we don't need to do anything + if foldr.ParentUID == cmd.NewParentUID { + return foldr, nil + } + + // here we get the folder, we need to get the height of current folder + // and the depth of the new parent folder, the sum can't bypass 8 + folderHeight, err := s.store.GetHeight(ctx, foldr.UID, cmd.OrgID, &cmd.NewParentUID) + if err != nil { + return nil, err + } + parents, err := s.GetParents(ctx, &folder.GetParentsQuery{UID: cmd.NewParentUID, OrgID: cmd.OrgID}) + if err != nil { + return nil, err + } + + // current folder height + current folder + parent folder + parent folder depth should be less than or equal 8 + if folderHeight+len(parents)+2 > folder.MaxNestedFolderDepth { + return nil, folder.ErrMaximumDepthReached + } + + // if the current folder is already a parent of newparent, we should return error + for _, parent := range parents { + if parent.UID == foldr.UID { + return nil, folder.ErrCircularReference + } + } + return s.store.Update(ctx, folder.UpdateFolderCommand{ Folder: foldr, - // NewParentUID: &cmd.NewParentUID, }) } diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index eb3d3ab0d2f..06e23d061e8 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -347,7 +347,7 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { }) t.Run("get children folder", func(t *testing.T) { - folderStore.ExpectedFolders = []*folder.Folder{ + folderStore.ExpectedChildFolders = []*folder.Folder{ { UID: "test", }, @@ -517,6 +517,56 @@ func TestNestedFolderService(t *testing.T) { require.NotNil(t, f) }) + t.Run("move when parentUID in the current subtree returns error from nested folder service", func(t *testing.T) { + g := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) + t.Cleanup(func() { + guardian.New = g + }) + + store.ExpectedFolder = &folder.Folder{UID: "myFolder", ParentUID: "newFolder"} + store.ExpectedError = folder.ErrCircularReference + f, err := foldersvc.Move(context.Background(), &folder.MoveFolderCommand{UID: "myFolder", NewParentUID: "newFolder", OrgID: orgID, SignedInUser: usr}) + require.Error(t, err, folder.ErrCircularReference) + require.Nil(t, f) + store.ExpectedChildFolders = []*folder.Folder{} + }) + + t.Run("move when new parentUID depth + subTree height bypassed maximum depth returns error", func(t *testing.T) { + g := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) + t.Cleanup(func() { + guardian.New = g + }) + + store.ExpectedError = nil + store.ExpectedFolder = &folder.Folder{UID: "myFolder", ParentUID: "newFolder"} + store.ExpectedParentFolders = []*folder.Folder{ + {UID: "newFolder", ParentUID: "newFolder"}, + {UID: "newFolder2", ParentUID: "newFolder2"}, + } + store.ExpectedFolderHeight = 5 + f, err := foldersvc.Move(context.Background(), &folder.MoveFolderCommand{UID: "myFolder", NewParentUID: "newFolder2", OrgID: orgID, SignedInUser: usr}) + require.Error(t, err, folder.ErrMaximumDepthReached) + require.Nil(t, f) + }) + + t.Run("move when parentUID in the current subtree returns error from nested folder service", func(t *testing.T) { + g := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) + t.Cleanup(func() { + guardian.New = g + }) + + store.ExpectedError = nil + store.ExpectedFolder = &folder.Folder{UID: "myFolder", ParentUID: "newFolder"} + store.ExpectedParentFolders = []*folder.Folder{{UID: "myFolder", ParentUID: "12345"}, {UID: "12345", ParentUID: ""}} + f, err := foldersvc.Move(context.Background(), &folder.MoveFolderCommand{UID: "myFolder", NewParentUID: "newFolder2", OrgID: orgID, SignedInUser: usr}) + require.Error(t, err, folder.ErrCircularReference) + require.Nil(t, f) + store.ExpectedChildFolders = []*folder.Folder{} + }) + t.Run("delete with success", func(t *testing.T) { var actualCmd *models.DeleteDashboardCommand dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { @@ -561,7 +611,7 @@ func TestNestedFolderService(t *testing.T) { for i := 0; i < folder.MaxNestedFolderDepth; i++ { parents = append(parents, &folder.Folder{UID: fmt.Sprintf("folder%d", i)}) } - store.ExpectedFolders = parents + store.ExpectedParentFolders = parents store.ExpectedError = nil _, err := foldersvc.Create(context.Background(), &folder.CreateFolderCommand{ diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 7b5ab9518a6..d66c35894a0 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -264,3 +264,27 @@ func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd folder.GetParentsQu }) return util.Reverse(folders), err } + +func (ss *sqlStore) GetHeight(ctx context.Context, foldrUID string, orgID int64, parentUID *string) (int, error) { + height := -1 + queue := []string{foldrUID} + for len(queue) > 0 { + length := len(queue) + height++ + for i := 0; i < length; i++ { + ele := queue[0] + queue = queue[1:] + if parentUID != nil && *parentUID == ele { + return 0, folder.ErrCircularReference + } + folders, err := ss.GetChildren(ctx, folder.GetTreeQuery{UID: ele, OrgID: orgID}) + if err != nil { + return 0, err + } + for _, f := range folders { + queue = append(queue, f.UID) + } + } + } + return height, nil +} diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index ee1104b078d..f66c4636eb0 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -19,6 +19,9 @@ import ( "github.com/grafana/grafana/pkg/util" ) +var folderTitle string = "folder1" +var folderDsc string = "folder desc" + func TestIntegrationCreate(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -32,8 +35,8 @@ func TestIntegrationCreate(t *testing.T) { t.Run("creating a folder without providing a UID should fail", func(t *testing.T) { _, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: "folder1", - Description: "folder desc", + Title: folderTitle, + Description: folderDsc, OrgID: orgID, }) require.Error(t, err) @@ -41,10 +44,10 @@ func TestIntegrationCreate(t *testing.T) { t.Run("creating a folder with unknown parent should fail", func(t *testing.T) { _, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: "folder1", + Title: folderTitle, OrgID: orgID, ParentUID: "unknown", - Description: "folder desc", + Description: folderDsc, UID: util.GenerateShortUID(), }) require.Error(t, err) @@ -53,8 +56,8 @@ func TestIntegrationCreate(t *testing.T) { t.Run("creating a folder without providing a parent should default to the empty parent folder", func(t *testing.T) { uid := util.GenerateShortUID() f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: "folder1", - Description: "folder desc", + Title: folderTitle, + Description: folderDsc, OrgID: orgID, UID: uid, }) @@ -65,8 +68,8 @@ func TestIntegrationCreate(t *testing.T) { require.NoError(t, err) }) - assert.Equal(t, "folder1", f.Title) - assert.Equal(t, "folder desc", f.Description) + assert.Equal(t, folderTitle, f.Title) + assert.Equal(t, folderDsc, f.Description) assert.NotEmpty(t, f.ID) assert.Equal(t, uid, f.UID) assert.Empty(t, f.ParentUID) @@ -76,8 +79,8 @@ func TestIntegrationCreate(t *testing.T) { OrgID: orgID, }) assert.NoError(t, err) - assert.Equal(t, "folder1", ff.Title) - assert.Equal(t, "folder desc", ff.Description) + assert.Equal(t, folderTitle, ff.Title) + assert.Equal(t, folderDsc, ff.Description) assert.Empty(t, ff.ParentUID) assertAncestorUIDs(t, folderStore, f, []string{folder.GeneralFolderUID}) @@ -103,10 +106,10 @@ func TestIntegrationCreate(t *testing.T) { uid := util.GenerateShortUID() f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: "folder1", + Title: folderTitle, OrgID: orgID, ParentUID: parent.UID, - Description: "folder desc", + Description: folderDsc, UID: uid, }) require.NoError(t, err) @@ -115,8 +118,8 @@ func TestIntegrationCreate(t *testing.T) { require.NoError(t, err) }) - assert.Equal(t, "folder1", f.Title) - assert.Equal(t, "folder desc", f.Description) + assert.Equal(t, folderTitle, f.Title) + assert.Equal(t, folderDsc, f.Description) assert.NotEmpty(t, f.ID) assert.Equal(t, uid, f.UID) assert.Equal(t, parentUID, f.ParentUID) @@ -129,8 +132,8 @@ func TestIntegrationCreate(t *testing.T) { OrgID: f.OrgID, }) assert.NoError(t, err) - assert.Equal(t, "folder1", ff.Title) - assert.Equal(t, "folder desc", ff.Description) + assert.Equal(t, folderTitle, ff.Title) + assert.Equal(t, folderDsc, ff.Description) assert.Equal(t, parentUID, ff.ParentUID) }) } @@ -195,11 +198,9 @@ func TestIntegrationUpdate(t *testing.T) { orgID := CreateOrg(t, db) // create folder - origTitle := "folder1" - origDesc := "folder desc" f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: origTitle, - Description: origDesc, + Title: folderTitle, + Description: folderDsc, OrgID: orgID, UID: util.GenerateShortUID(), }) @@ -300,12 +301,10 @@ func TestIntegrationGet(t *testing.T) { orgID := CreateOrg(t, db) // create folder - title1 := "folder1" - desc1 := "folder desc" uid1 := util.GenerateShortUID() f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: title1, - Description: desc1, + Title: folderTitle, + Description: folderDsc, OrgID: orgID, UID: uid1, }) @@ -381,12 +380,10 @@ func TestIntegrationGetParents(t *testing.T) { orgID := CreateOrg(t, db) // create folder - title1 := "folder1" - desc1 := "folder desc" uid1 := util.GenerateShortUID() f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: title1, - Description: desc1, + Title: folderTitle, + Description: folderDsc, OrgID: orgID, UID: uid1, }) @@ -450,12 +447,10 @@ func TestIntegrationGetChildren(t *testing.T) { orgID := CreateOrg(t, db) // create folder - title1 := "folder1" - desc1 := "folder desc" uid1 := util.GenerateShortUID() parent, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ - Title: title1, - Description: desc1, + Title: folderTitle, + Description: folderDsc, OrgID: orgID, UID: uid1, }) @@ -565,6 +560,40 @@ func TestIntegrationGetChildren(t *testing.T) { }) } +func TestIntegrationGetHeight(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + t.Skip("skipping until folder migration is merged") + + db := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{}) + + orgID := CreateOrg(t, db) + + // create folder + uid1 := util.GenerateShortUID() + parent, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: folderTitle, + Description: folderDsc, + OrgID: orgID, + UID: uid1, + }) + require.NoError(t, err) + subTree := CreateSubTree(t, folderStore, orgID, parent.UID, 4, "sub") + + t.Run("should successfully get height", func(t *testing.T) { + height, err := folderStore.GetHeight(context.Background(), parent.UID, orgID, nil) + require.NoError(t, err) + require.Equal(t, 4, height) + }) + + t.Run("should failed when the parent folder exist in the subtree", func(t *testing.T) { + _, err = folderStore.GetHeight(context.Background(), parent.UID, orgID, &subTree[0]) + require.Error(t, err, folder.ErrCircularReference) + }) +} + func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { t.Helper() @@ -583,18 +612,15 @@ func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { func CreateSubTree(t *testing.T, store *sqlStore, orgID int64, parentUID string, depth int, prefix string) []string { t.Helper() - ancestorUIDs := []string{} + ancestorUIDs := []string{parentUID} for i := 0; i < depth; i++ { title := fmt.Sprintf("%sfolder-%d", prefix, i) cmd := folder.CreateFolderCommand{ Title: title, OrgID: orgID, - ParentUID: parentUID, + ParentUID: ancestorUIDs[len(ancestorUIDs)-1], UID: util.GenerateShortUID(), } - if len(ancestorUIDs) > 0 { - cmd.ParentUID = ancestorUIDs[len(ancestorUIDs)-1] - } f, err := store.Create(context.Background(), cmd) require.NoError(t, err) require.Equal(t, title, f.Title) diff --git a/pkg/services/folder/folderimpl/store.go b/pkg/services/folder/folderimpl/store.go index 9ba0933e4ab..902fddd7cfe 100644 --- a/pkg/services/folder/folderimpl/store.go +++ b/pkg/services/folder/folderimpl/store.go @@ -27,4 +27,8 @@ type store interface { // GetChildren returns the set of immediate children folders (depth=1) of the // given folder. GetChildren(ctx context.Context, cmd folder.GetTreeQuery) ([]*folder.Folder, error) + + // GetHeight returns the height of the folder tree. When parentUID is set, the function would + // verify in the meanwhile that parentUID is not present in the subtree of the folder with the given UID. + GetHeight(ctx context.Context, foldrUID string, orgID int64, parentUID *string) (int, error) } diff --git a/pkg/services/folder/folderimpl/store_fake.go b/pkg/services/folder/folderimpl/store_fake.go index 8fa2a9c1beb..25c39096e20 100644 --- a/pkg/services/folder/folderimpl/store_fake.go +++ b/pkg/services/folder/folderimpl/store_fake.go @@ -7,12 +7,13 @@ import ( ) type FakeStore struct { - ExpectedFolders []*folder.Folder - ExpectedFolder *folder.Folder - ExpectedError error - - CreateCalled bool - DeleteCalled bool + ExpectedChildFolders []*folder.Folder + ExpectedParentFolders []*folder.Folder + ExpectedFolder *folder.Folder + ExpectedError error + ExpectedFolderHeight int + CreateCalled bool + DeleteCalled bool } func NewFakeStore() *FakeStore { @@ -44,9 +45,13 @@ func (f *FakeStore) Get(ctx context.Context, cmd folder.GetFolderQuery) (*folder } func (f *FakeStore) GetParents(ctx context.Context, cmd folder.GetParentsQuery) ([]*folder.Folder, error) { - return f.ExpectedFolders, f.ExpectedError + return f.ExpectedParentFolders, f.ExpectedError } func (f *FakeStore) GetChildren(ctx context.Context, cmd folder.GetTreeQuery) ([]*folder.Folder, error) { - return f.ExpectedFolders, f.ExpectedError + return f.ExpectedChildFolders, f.ExpectedError +} + +func (f *FakeStore) GetHeight(ctx context.Context, folderUID string, orgID int64, parentUID *string) (int, error) { + return f.ExpectedFolderHeight, f.ExpectedError } diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 12143abef99..8dfc80e3b53 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -14,6 +14,7 @@ var ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "folder.bad-reques var ErrDatabaseError = errutil.NewBase(errutil.StatusInternal, "folder.database-error") var ErrInternal = errutil.NewBase(errutil.StatusInternal, "folder.internal") var ErrFolderTooDeep = errutil.NewBase(errutil.StatusInternal, "folder.too-deep") +var ErrCircularReference = errutil.NewBase(errutil.StatusBadRequest, "folder.circular-reference", errutil.WithPublicMessage("Circular reference detected")) const ( GeneralFolderUID = "general" diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 49e02f71790..180188d6454 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -30,10 +30,12 @@ import ( "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -456,8 +458,11 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Name: "User In DB", Login: userInDbName, } - - _, err = sqlStore.CreateUser(context.Background(), cmd) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, quotaService) + require.NoError(t, err) + _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) sc := scenarioContext{ diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 356ea61e045..c326e415c5b 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -28,10 +28,12 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" ) @@ -856,12 +858,13 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Name: "User In DB", Login: userInDbName, } - ctx := appcontext.WithUser(context.Background(), usr) - - _, err = sqlStore.CreateUser(ctx, cmd) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, quotaService) + require.NoError(t, err) + _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) - sc := scenarioContext{ user: usr, ctx: ctx, diff --git a/pkg/services/login/authinfoservice/user_auth_test.go b/pkg/services/login/authinfoservice/user_auth_test.go index 5e8181e9528..f22814020a5 100644 --- a/pkg/services/login/authinfoservice/user_auth_test.go +++ b/pkg/services/login/authinfoservice/user_auth_test.go @@ -15,7 +15,10 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) //nolint:goconst @@ -29,13 +32,19 @@ func TestUserAuth(t *testing.T) { ) t.Run("Given 5 users", func(t *testing.T) { + qs := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, qs) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, qs) + require.NoError(t, err) + for i := 0; i < 5; i++ { cmd := user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), Login: fmt.Sprint("loginuser", i), } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.Create(context.Background(), &cmd) require.Nil(t, err) } @@ -204,6 +213,11 @@ func TestUserAuth(t *testing.T) { t.Run("Always return the most recently used auth_module", func(t *testing.T) { // Restore after destructive operation sqlStore = db.InitTestDB(t) + qs := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, qs) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, qs) + require.NoError(t, err) for i := 0; i < 5; i++ { cmd := user.CreateUserCommand{ @@ -211,8 +225,8 @@ func TestUserAuth(t *testing.T) { Name: fmt.Sprint("user", i), Login: fmt.Sprint("loginuser", i), } - _, err := sqlStore.CreateUser(context.Background(), cmd) - require.Nil(t, err) + _, err = usrSvc.Create(context.Background(), &cmd) + require.NoError(t, err) } // Find a user to set tokens on @@ -272,6 +286,11 @@ func TestUserAuth(t *testing.T) { t.Run("Keeps track of last used auth_module when not using oauth", func(t *testing.T) { // Restore after destructive operation sqlStore = db.InitTestDB(t) + qs := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, qs) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, qs) + require.NoError(t, err) for i := 0; i < 5; i++ { cmd := user.CreateUserCommand{ @@ -279,7 +298,7 @@ func TestUserAuth(t *testing.T) { Name: fmt.Sprint("user", i), Login: fmt.Sprint("loginuser", i), } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.Create(context.Background(), &cmd) require.Nil(t, err) } @@ -406,6 +425,11 @@ func TestUserAuth(t *testing.T) { // Restore after destructive operation sqlStore = db.InitTestDB(t) + qs := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, qs) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, qs) + require.NoError(t, err) for i := 0; i < 5; i++ { cmd := user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), @@ -413,17 +437,22 @@ func TestUserAuth(t *testing.T) { Login: fmt.Sprint("loginuser", i), OrgID: 1, } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.Create(context.Background(), &cmd) require.Nil(t, err) } - _, err := srv.authInfoStore.GetLoginStats(context.Background()) + _, err = srv.authInfoStore.GetLoginStats(context.Background()) require.Nil(t, err) }) t.Run("calculate metrics on duplicate userstats", func(t *testing.T) { // Restore after destructive operation sqlStore = db.InitTestDB(t) + qs := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, qs) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, qs) + require.NoError(t, err) for i := 0; i < 5; i++ { cmd := user.CreateUserCommand{ @@ -432,7 +461,7 @@ func TestUserAuth(t *testing.T) { Login: fmt.Sprint("loginuser", i), OrgID: 1, } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err := usrSvc.Create(context.Background(), &cmd) require.Nil(t, err) } @@ -443,7 +472,7 @@ func TestUserAuth(t *testing.T) { Name: "user name 1", Login: "USER_DUPLICATE_TEST_1_LOGIN", } - _, err := sqlStore.CreateUser(context.Background(), dupUserEmailcmd) + _, err := usrSvc.Create(context.Background(), &dupUserEmailcmd) require.NoError(t, err) // add additional user with duplicate login where DOMAIN is upper case @@ -452,7 +481,7 @@ func TestUserAuth(t *testing.T) { Name: "user name 1", Login: "user_duplicate_test_1_login", } - _, err = sqlStore.CreateUser(context.Background(), dupUserLogincmd) + _, err = usrSvc.Create(context.Background(), &dupUserLogincmd) require.NoError(t, err) authInfoStore.ExpectedUser = &user.User{ Email: "userduplicatetest1@test.com", diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 276318892c2..d1de2fbc52c 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -245,6 +245,8 @@ func ApplyAdminIA(root *NavTreeRoot) { adminNodeLinks = append(adminNodeLinks, accessNode) } + adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("storage")) + if len(adminNodeLinks) > 0 { orgAdminNode.Children = adminNodeLinks } else { diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 5dcbbb030f6..726d47f6224 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/expr/classic" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" @@ -83,16 +84,20 @@ type evaluatorImpl struct { evaluationTimeout time.Duration dataSourceCache datasources.CacheService expressionService *expr.Service + pluginsStore plugins.Store } func NewEvaluatorFactory( cfg setting.UnifiedAlertingSettings, datasourceCache datasources.CacheService, - expressionService *expr.Service) EvaluatorFactory { + expressionService *expr.Service, + pluginsStore plugins.Store, +) EvaluatorFactory { return &evaluatorImpl{ evaluationTimeout: cfg.EvaluationTimeout, dataSourceCache: datasourceCache, expressionService: expressionService, + pluginsStore: pluginsStore, } } @@ -591,7 +596,23 @@ func (evalResults Results) AsDataFrame() data.Frame { } func (e *evaluatorImpl) Validate(ctx EvaluationContext, condition models.Condition) error { - _, err := e.Create(ctx, condition) + req, err := getExprRequest(ctx, condition.Data, e.dataSourceCache) + if err != nil { + return err + } + for _, query := range req.Queries { + if query.DataSource == nil || expr.IsDataSource(query.DataSource.Uid) { + continue + } + p, found := e.pluginsStore.Plugin(ctx.Ctx, query.DataSource.Type) + if !found { // technically this should fail earlier during datasource resolution phase. + return fmt.Errorf("datasource refID %s could not be found: %w", query.RefID, plugins.ErrPluginUnavailable) + } + if !p.Backend { + return fmt.Errorf("datasource refID %s is not a backend datasource", query.RefID) + } + } + _, err = e.create(condition, req) return err } @@ -606,6 +627,10 @@ func (e *evaluatorImpl) Create(ctx EvaluationContext, condition models.Condition if err != nil { return nil, err } + return e.create(condition, req) +} + +func (e *evaluatorImpl) create(condition models.Condition, req *expr.Request) (ConditionEvaluator, error) { pipeline, err := e.expressionService.BuildPipeline(req) if err != nil { return nil, err diff --git a/pkg/services/ngalert/eval/eval_test.go b/pkg/services/ngalert/eval/eval_test.go index 1b77efc04c3..188999b3c69 100644 --- a/pkg/services/ngalert/eval/eval_test.go +++ b/pkg/services/ngalert/eval/eval_test.go @@ -12,11 +12,13 @@ import ( ptr "github.com/xorcare/pointer" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/datasources" fakes "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) func TestEvaluateExecutionResult(t *testing.T) { @@ -351,15 +353,20 @@ func TestEvaluateExecutionResultsNoData(t *testing.T) { } func TestValidate(t *testing.T) { + type services struct { + cache *fakes.FakeCacheService + pluginsStore *plugins.FakePluginStore + } + testCases := []struct { name string - condition func(service *fakes.FakeCacheService) models.Condition + condition func(services services) models.Condition error bool }{ { name: "fail if no expressions", error: true, - condition: func(service *fakes.FakeCacheService) models.Condition { + condition: func(_ services) models.Condition { return models.Condition{ Condition: "A", Data: []models.AlertQuery{}, @@ -369,17 +376,25 @@ func TestValidate(t *testing.T) { { name: "fail if condition RefID does not exist", error: true, - condition: func(service *fakes.FakeCacheService) models.Condition { - ds := models.GenerateAlertQuery() - service.DataSources = append(service.DataSources, &datasources.DataSource{ - Uid: ds.DatasourceUID, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + Uid: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, }) return models.Condition{ Condition: "C", Data: []models.AlertQuery{ - ds, - models.CreateClassicConditionExpression("B", ds.RefID, "last", "gt", rand.Int()), + dsQuery, + models.CreateClassicConditionExpression("B", dsQuery.RefID, "last", "gt", rand.Int()), }, } }, @@ -387,17 +402,24 @@ func TestValidate(t *testing.T) { { name: "fail if condition RefID is empty", error: true, - condition: func(service *fakes.FakeCacheService) models.Condition { - ds := models.GenerateAlertQuery() - service.DataSources = append(service.DataSources, &datasources.DataSource{ - Uid: ds.DatasourceUID, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + Uid: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, }) - return models.Condition{ Condition: "", Data: []models.AlertQuery{ - ds, - models.CreateClassicConditionExpression("B", ds.RefID, "last", "gt", rand.Int()), + dsQuery, + models.CreateClassicConditionExpression("B", dsQuery.RefID, "last", "gt", rand.Int()), }, } }, @@ -405,13 +427,68 @@ func TestValidate(t *testing.T) { { name: "fail if datasource with UID does not exists", error: true, - condition: func(service *fakes.FakeCacheService) models.Condition { - ds := models.GenerateAlertQuery() + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() // do not update the cache service return models.Condition{ - Condition: ds.RefID, + Condition: dsQuery.RefID, Data: []models.AlertQuery{ - ds, + dsQuery, + }, + } + }, + }, + { + name: "fail if datasource cannot be found in plugin store", + error: true, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + Uid: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + // do not update the plugin store + return models.Condition{ + Condition: dsQuery.RefID, + Data: []models.AlertQuery{ + dsQuery, + }, + } + }, + }, + { + name: "fail if datasource is not backend one", + error: true, + condition: func(services services) models.Condition { + dsQuery1 := models.GenerateAlertQuery() + dsQuery2 := models.GenerateAlertQuery() + ds1 := &datasources.DataSource{ + Uid: dsQuery1.DatasourceUID, + Type: util.GenerateShortUID(), + } + ds2 := &datasources.DataSource{ + Uid: dsQuery2.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds1, ds2) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: ds1.Type, + Backend: false, + }, + }, plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: ds2.Type, + Backend: true, + }, + }) + // do not update the plugin store + return models.Condition{ + Condition: dsQuery1.RefID, + Data: []models.AlertQuery{ + dsQuery1, + dsQuery2, }, } }, @@ -419,17 +496,25 @@ func TestValidate(t *testing.T) { { name: "pass if datasource exists and condition is correct", error: false, - condition: func(service *fakes.FakeCacheService) models.Condition { - ds := models.GenerateAlertQuery() - service.DataSources = append(service.DataSources, &datasources.DataSource{ - Uid: ds.DatasourceUID, + condition: func(services services) models.Condition { + dsQuery := models.GenerateAlertQuery() + ds := &datasources.DataSource{ + Uid: dsQuery.DatasourceUID, + Type: util.GenerateShortUID(), + } + services.cache.DataSources = append(services.cache.DataSources, ds) + services.pluginsStore.PluginList = append(services.pluginsStore.PluginList, plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: ds.Type, + Backend: true, + }, }) return models.Condition{ Condition: "B", Data: []models.AlertQuery{ - ds, - models.CreateClassicConditionExpression("B", ds.RefID, "last", "gt", rand.Int()), + dsQuery, + models.CreateClassicConditionExpression("B", dsQuery.RefID, "last", "gt", rand.Int()), }, } }, @@ -441,12 +526,16 @@ func TestValidate(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { cacheService := &fakes.FakeCacheService{} - condition := testCase.condition(cacheService) + store := &plugins.FakePluginStore{} + condition := testCase.condition(services{ + cache: cacheService, + pluginsStore: store, + }) - evaluator := NewEvaluatorFactory(setting.UnifiedAlertingSettings{}, cacheService, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil)) + evaluator := NewEvaluatorFactory(setting.UnifiedAlertingSettings{}, cacheService, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil), store) evalCtx := Context(context.Background(), u) - _, err := evaluator.Create(evalCtx, condition) + err := evaluator.Validate(evalCtx, condition) if testCase.error { require.Error(t, err) } else { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 53b3f19a25e..2e759b818c7 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -116,8 +116,9 @@ const ( ValueStringAnnotation = "__value_string__" ) -var ( +const ( StateReasonMissingSeries = "MissingSeries" + StateReasonError = "Error" ) var ( diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index f1c343a75bc..29d538e8f99 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -158,6 +158,12 @@ func WithTitle(title string) AlertRuleMutator { } } +func WithFor(duration time.Duration) AlertRuleMutator { + return func(rule *AlertRule) { + rule.For = duration + } +} + func GenerateAlertLabels(count int, prefix string) data.Labels { labels := make(data.Labels, count) for i := 0; i < count; i++ { diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index c1ca4c56ec0..12d4cc33f84 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboards" @@ -62,6 +63,7 @@ func ProvideService( bus bus.Bus, accesscontrolService accesscontrol.Service, annotationsRepo annotations.Repository, + pluginsStore plugins.Store, ) (*AlertNG, error) { ng := &AlertNG{ Cfg: cfg, @@ -85,6 +87,7 @@ func ProvideService( bus: bus, accesscontrolService: accesscontrolService, annotationsRepo: annotationsRepo, + pluginsStore: pluginsStore, } if ng.IsDisabled() { @@ -129,7 +132,8 @@ type AlertNG struct { annotationsRepo annotations.Repository store *store.DBstore - bus bus.Bus + bus bus.Bus + pluginsStore plugins.Store } func (ng *AlertNG) init() error { @@ -182,7 +186,7 @@ func (ng *AlertNG) init() error { ng.AlertsRouter = alertsRouter - evalFactory := eval.NewEvaluatorFactory(ng.Cfg.UnifiedAlerting, ng.DataSourceCache, ng.ExpressionService) + evalFactory := eval.NewEvaluatorFactory(ng.Cfg.UnifiedAlerting, ng.DataSourceCache, ng.ExpressionService, ng.pluginsStore) schedCfg := schedule.SchedulerCfg{ MaxAttempts: ng.Cfg.UnifiedAlerting.MaxAttempts, C: clk, diff --git a/pkg/services/ngalert/notifier/channels/opsgenie.go b/pkg/services/ngalert/notifier/channels/opsgenie.go index ba8480a9c67..bb45abce242 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie.go @@ -13,8 +13,8 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + ptr "github.com/xorcare/pointer" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -35,21 +35,14 @@ var ( // OpsgenieNotifier is responsible for sending alert notifications to Opsgenie. type OpsgenieNotifier struct { *Base - APIKey string - APIUrl string - Message string - Description string - AutoClose bool - OverridePriority bool - SendTagsAs string - tmpl *template.Template - log log.Logger - ns notifications.WebhookSender - images ImageStore + tmpl *template.Template + log log.Logger + ns notifications.WebhookSender + images ImageStore + settings *opsgenieSettings } -type OpsgenieConfig struct { - *NotificationChannelConfig +type opsgenieSettings struct { APIKey string APIUrl string Message string @@ -59,62 +52,92 @@ type OpsgenieConfig struct { SendTagsAs string } +func buildOpsgenieSettings(fc FactoryConfig) (*opsgenieSettings, error) { + type rawSettings struct { + APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` + APIUrl string `json:"apiUrl,omitempty" yaml:"apiUrl,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + AutoClose *bool `json:"autoClose,omitempty" yaml:"autoClose,omitempty"` + OverridePriority *bool `json:"overridePriority,omitempty" yaml:"overridePriority,omitempty"` + SendTagsAs string `json:"sendTagsAs,omitempty" yaml:"sendTagsAs,omitempty"` + } + + raw := rawSettings{} + err := fc.Config.unmarshalSettings(&raw) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + raw.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiKey", raw.APIKey) + if raw.APIKey == "" { + return nil, errors.New("could not find api key property in settings") + } + if raw.APIUrl == "" { + raw.APIUrl = OpsgenieAlertURL + } + + if strings.TrimSpace(raw.Message) == "" { + raw.Message = DefaultMessageTitleEmbed + } + + switch raw.SendTagsAs { + case OpsgenieSendTags, OpsgenieSendDetails, OpsgenieSendBoth: + case "": + raw.SendTagsAs = OpsgenieSendTags + default: + return nil, fmt.Errorf("invalid value for sendTagsAs: %q", raw.SendTagsAs) + } + + if raw.AutoClose == nil { + raw.AutoClose = ptr.Bool(true) + } + if raw.OverridePriority == nil { + raw.OverridePriority = ptr.Bool(true) + } + + return &opsgenieSettings{ + APIKey: raw.APIKey, + APIUrl: raw.APIUrl, + Message: raw.Message, + Description: raw.Description, + AutoClose: *raw.AutoClose, + OverridePriority: *raw.OverridePriority, + SendTagsAs: raw.SendTagsAs, + }, nil +} + func OpsgenieFactory(fc FactoryConfig) (NotificationChannel, error) { - cfg, err := NewOpsgenieConfig(fc.Config, fc.DecryptFunc) + notifier, err := NewOpsgenieNotifier(fc) if err != nil { return nil, receiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } } - return NewOpsgenieNotifier(cfg, fc.NotificationService, fc.ImageStore, fc.Template, fc.DecryptFunc), nil -} - -func NewOpsgenieConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedValueFn) (*OpsgenieConfig, error) { - apiKey := decryptFunc(context.Background(), config.SecureSettings, "apiKey", config.Settings.Get("apiKey").MustString()) - if apiKey == "" { - return nil, errors.New("could not find api key property in settings") - } - sendTagsAs := config.Settings.Get("sendTagsAs").MustString(OpsgenieSendTags) - if sendTagsAs != OpsgenieSendTags && - sendTagsAs != OpsgenieSendDetails && - sendTagsAs != OpsgenieSendBoth { - return nil, fmt.Errorf("invalid value for sendTagsAs: %q", sendTagsAs) - } - return &OpsgenieConfig{ - NotificationChannelConfig: config, - APIKey: apiKey, - APIUrl: config.Settings.Get("apiUrl").MustString(OpsgenieAlertURL), - AutoClose: config.Settings.Get("autoClose").MustBool(true), - OverridePriority: config.Settings.Get("overridePriority").MustBool(true), - Message: config.Settings.Get("message").MustString(`{{ template "default.title" . }}`), - Description: config.Settings.Get("description").MustString(""), - SendTagsAs: sendTagsAs, - }, nil + return notifier, nil } // NewOpsgenieNotifier is the constructor for the Opsgenie notifier -func NewOpsgenieNotifier(config *OpsgenieConfig, ns notifications.WebhookSender, images ImageStore, t *template.Template, fn GetDecryptedValueFn) *OpsgenieNotifier { +func NewOpsgenieNotifier(fc FactoryConfig) (*OpsgenieNotifier, error) { + settings, err := buildOpsgenieSettings(fc) + if err != nil { + return nil, err + } return &OpsgenieNotifier{ Base: NewBase(&models.AlertNotification{ - Uid: config.UID, - Name: config.Name, - Type: config.Type, - DisableResolveMessage: config.DisableResolveMessage, - Settings: config.Settings, + Uid: fc.Config.UID, + Name: fc.Config.Name, + Type: fc.Config.Type, + DisableResolveMessage: fc.Config.DisableResolveMessage, + Settings: fc.Config.Settings, }), - APIKey: config.APIKey, - APIUrl: config.APIUrl, - Description: config.Description, - Message: config.Message, - AutoClose: config.AutoClose, - OverridePriority: config.OverridePriority, - SendTagsAs: config.SendTagsAs, - tmpl: t, - log: log.New("alerting.notifier." + config.Name), - ns: ns, - images: images, - } + tmpl: fc.Template, + log: log.New("alerting.notifier.opsgenie"), + ns: fc.NotificationService, + images: fc.ImageStore, + settings: settings, + }, nil } // Notify sends an alert notification to Opsgenie @@ -127,7 +150,7 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return true, nil } - bodyJSON, url, err := on.buildOpsgenieMessage(ctx, alerts, as) + body, url, err := on.buildOpsgenieMessage(ctx, alerts, as) if err != nil { return false, fmt.Errorf("build Opsgenie message: %w", err) } @@ -138,18 +161,13 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return true, nil } - body, err := json.Marshal(bodyJSON) - if err != nil { - return false, fmt.Errorf("marshal json: %w", err) - } - cmd := &models.SendWebhookSync{ Url: url, Body: string(body), HttpMethod: http.MethodPost, HttpHeader: map[string]string{ "Content-Type": "application/json", - "Authorization": fmt.Sprintf("GenieKey %s", on.APIKey), + "Authorization": fmt.Sprintf("GenieKey %s", on.settings.APIKey), }, } @@ -160,28 +178,24 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return true, nil } -func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (payload *simplejson.Json, apiURL string, err error) { +func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (payload []byte, apiURL string, err error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return nil, "", err } - var ( - alias = key.Hash() - bodyJSON = simplejson.New() - details = simplejson.New() - ) - if alerts.Status() == model.AlertResolved { // For resolved notification, we only need the source. // Don't need to run other templates. - if on.AutoClose { - bodyJSON := simplejson.New() - bodyJSON.Set("source", "Grafana") - apiURL = fmt.Sprintf("%s/%s/close?identifierType=alias", on.APIUrl, alias) - return bodyJSON, apiURL, nil + if !on.settings.AutoClose { // TODO This should be handled by DisableResolveMessage? + return nil, "", nil } - return nil, "", nil + msg := opsGenieCloseMessage{ + Source: "Grafana", + } + data, err := json.Marshal(msg) + apiURL = fmt.Sprintf("%s/%s/close?identifierType=alias", on.settings.APIUrl, key.Hash()) + return data, apiURL, err } ruleURL := joinUrlPath(on.tmpl.ExternalURL.String(), "/alerting/list", on.log) @@ -189,17 +203,12 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod var tmplErr error tmpl, data := TmplText(ctx, on.tmpl, as, on.log, &tmplErr) - titleTmpl := on.Message - if strings.TrimSpace(titleTmpl) == "" { - titleTmpl = `{{ template "default.title" . }}` + message, truncated := notify.Truncate(tmpl(on.settings.Message), 130) + if truncated { + on.log.Debug("Truncated message", "originalMessage", message) } - title := tmpl(titleTmpl) - if len(title) > 130 { - title = title[:127] + "..." - } - - description := tmpl(on.Description) + description := tmpl(on.settings.Description) if strings.TrimSpace(description) == "" { description = fmt.Sprintf( "%s\n%s\n\n%s", @@ -215,8 +224,7 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod lbls := make(map[string]string, len(data.CommonLabels)) for k, v := range data.CommonLabels { lbls[k] = tmpl(v) - - if k == "og_priority" { + if k == "og_priority" && on.settings.OverridePriority { if ValidPriorities[v] { priority = v } @@ -229,18 +237,13 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod tmplErr = nil } - bodyJSON.Set("message", title) - bodyJSON.Set("source", "Grafana") - bodyJSON.Set("alias", alias) - bodyJSON.Set("description", description) - details.Set("url", ruleURL) - + details := make(map[string]interface{}) + details["url"] = ruleURL if on.sendDetails() { for k, v := range lbls { - details.Set(k, v) + details[k] = v } - - images := []string{} + var images []string _ = withStoredImages(ctx, on.log, on.images, func(_ int, image ngmodels.Image) error { if len(image.URL) == 0 { @@ -252,7 +255,7 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod as...) if len(images) != 0 { - details.Set("image_urls", images) + details["image_urls"] = images } } @@ -264,19 +267,24 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod } sort.Strings(tags) - if priority != "" && on.OverridePriority { - bodyJSON.Set("priority", priority) + result := opsGenieCreateMessage{ + Alias: key.Hash(), + Description: description, + Tags: tags, + Source: "Grafana", + Message: message, + Details: details, + Priority: priority, } - bodyJSON.Set("tags", tags) - bodyJSON.Set("details", details) - apiURL = tmpl(on.APIUrl) + apiURL = tmpl(on.settings.APIUrl) if tmplErr != nil { - on.log.Warn("failed to template Opsgenie URL", "error", tmplErr.Error(), "fallback", on.APIUrl) - apiURL = on.APIUrl + on.log.Warn("failed to template Opsgenie URL", "error", tmplErr.Error(), "fallback", on.settings.APIUrl) + apiURL = on.settings.APIUrl } - return bodyJSON, apiURL, nil + b, err := json.Marshal(result) + return b, apiURL, err } func (on *OpsgenieNotifier) SendResolved() bool { @@ -284,9 +292,34 @@ func (on *OpsgenieNotifier) SendResolved() bool { } func (on *OpsgenieNotifier) sendDetails() bool { - return on.SendTagsAs == OpsgenieSendDetails || on.SendTagsAs == OpsgenieSendBoth + return on.settings.SendTagsAs == OpsgenieSendDetails || on.settings.SendTagsAs == OpsgenieSendBoth } func (on *OpsgenieNotifier) sendTags() bool { - return on.SendTagsAs == OpsgenieSendTags || on.SendTagsAs == OpsgenieSendBoth + return on.settings.SendTagsAs == OpsgenieSendTags || on.settings.SendTagsAs == OpsgenieSendBoth +} + +type opsGenieCreateMessage struct { + Alias string `json:"alias"` + Message string `json:"message"` + Description string `json:"description,omitempty"` + Details map[string]interface{} `json:"details"` + Source string `json:"source"` + Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"` + Tags []string `json:"tags"` + Note string `json:"note,omitempty"` + Priority string `json:"priority,omitempty"` + Entity string `json:"entity,omitempty"` + Actions []string `json:"actions,omitempty"` +} + +type opsGenieCreateMessageResponder struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Username string `json:"username,omitempty"` + Type string `json:"type"` // team, user, escalation, schedule etc. +} + +type opsGenieCloseMessage struct { + Source string `json:"source"` } diff --git a/pkg/services/ngalert/notifier/channels/opsgenie_test.go b/pkg/services/ngalert/notifier/channels/opsgenie_test.go index 04b507517cb..6236cc81fbf 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie_test.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie_test.go @@ -95,7 +95,7 @@ func TestOpsgenieNotifier(t *testing.T) { "details": { "url": "http://localhost/alerting/list" }, - "message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsq...", + "message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsqT9…", "source": "Grafana", "tags": ["alertname:alert1", "lbl1:val1"] }`, @@ -234,28 +234,33 @@ func TestOpsgenieNotifier(t *testing.T) { require.NoError(t, err) secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - SecureSettings: secureSettings, - } - webhookSender := mockNotificationService() webhookSender.Webhook.Body = "" secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue - cfg, err := NewOpsgenieConfig(m, decryptFn) + + fc := FactoryConfig{ + Config: &NotificationChannelConfig{ + Name: "opsgenie_testing", + Type: "opsgenie", + Settings: settingsJSON, + SecureSettings: secureSettings, + }, + NotificationService: webhookSender, + DecryptFunc: decryptFn, + ImageStore: &UnavailableImageStore{}, + Template: tmpl, + } + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + pn, err := NewOpsgenieNotifier(fc) if c.expInitError != "" { require.Error(t, err) require.Equal(t, c.expInitError, err.Error()) return } require.NoError(t, err) - - ctx := notify.WithGroupKey(context.Background(), "alertname") - ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - pn := NewOpsgenieNotifier(cfg, webhookSender, &UnavailableImageStore{}, tmpl, decryptFn) ok, err := pn.Notify(ctx, c.alerts...) if c.expMsgError != nil { require.False(t, ok) diff --git a/pkg/services/ngalert/notifier/channels/pagerduty.go b/pkg/services/ngalert/notifier/channels/pagerduty.go index 3dd574eb1ce..7d69b35634e 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" @@ -21,9 +22,15 @@ import ( const ( pagerDutyEventTrigger = "trigger" pagerDutyEventResolve = "resolve" + + defaultSeverity = "critical" + defaultClass = "default" + defaultGroup = "default" + defaultClient = "Grafana" ) var ( + knownSeverity = map[string]struct{}{defaultSeverity: {}, "error": {}, "warning": {}, "info": {}} PagerdutyEventAPIURL = "https://events.pagerduty.com/v2/enqueue" ) @@ -35,7 +42,7 @@ type PagerdutyNotifier struct { log log.Logger ns notifications.WebhookSender images ImageStore - settings pagerdutySettings + settings *pagerdutySettings } type pagerdutySettings struct { @@ -46,6 +53,59 @@ type pagerdutySettings struct { Component string `json:"component,omitempty" yaml:"component,omitempty"` Group string `json:"group,omitempty" yaml:"group,omitempty"` Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Source string `json:"source,omitempty" yaml:"source,omitempty"` + Client string `json:"client,omitempty" yaml:"client,omitempty"` + ClientURL string `json:"client_url,omitempty" yaml:"client_url,omitempty"` +} + +func buildPagerdutySettings(fc FactoryConfig) (*pagerdutySettings, error) { + settings := pagerdutySettings{} + err := fc.Config.unmarshalSettings(&settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + settings.Key = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "integrationKey", settings.Key) + if settings.Key == "" { + return nil, errors.New("could not find integration key property in settings") + } + + settings.customDetails = map[string]string{ + "firing": `{{ template "__text_alert_list" .Alerts.Firing }}`, + "resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`, + "num_firing": `{{ .Alerts.Firing | len }}`, + "num_resolved": `{{ .Alerts.Resolved | len }}`, + } + + if settings.Severity == "" { + settings.Severity = defaultSeverity + } + if settings.Class == "" { + settings.Class = defaultClass + } + if settings.Component == "" { + settings.Component = "Grafana" + } + if settings.Group == "" { + settings.Group = defaultGroup + } + if settings.Summary == "" { + settings.Summary = DefaultMessageTitleEmbed + } + if settings.Client == "" { + settings.Client = defaultClient + } + if settings.ClientURL == "" { + settings.ClientURL = "{{ .ExternalURL }}" + } + if settings.Source == "" { + source, err := os.Hostname() + if err != nil { + source = settings.Client + } + settings.Source = source + } + return &settings, nil } func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { @@ -61,9 +121,9 @@ func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { // NewPagerdutyNotifier is the constructor for the PagerDuty notifier func newPagerdutyNotifier(fc FactoryConfig) (*PagerdutyNotifier, error) { - key := fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "integrationKey", fc.Config.Settings.Get("integrationKey").MustString()) - if key == "" { - return nil, errors.New("could not find integration key property in settings") + settings, err := buildPagerdutySettings(fc) + if err != nil { + return nil, err } return &PagerdutyNotifier{ @@ -74,24 +134,11 @@ func newPagerdutyNotifier(fc FactoryConfig) (*PagerdutyNotifier, error) { DisableResolveMessage: fc.Config.DisableResolveMessage, Settings: fc.Config.Settings, }), - tmpl: fc.Template, - log: log.New("alerting.notifier." + fc.Config.Name), - ns: fc.NotificationService, - images: fc.ImageStore, - settings: pagerdutySettings{ - Key: key, - Severity: fc.Config.Settings.Get("severity").MustString("critical"), - customDetails: map[string]string{ - "firing": `{{ template "__text_alert_list" .Alerts.Firing }}`, - "resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`, - "num_firing": `{{ .Alerts.Firing | len }}`, - "num_resolved": `{{ .Alerts.Resolved | len }}`, - }, - Class: fc.Config.Settings.Get("class").MustString("default"), - Component: fc.Config.Settings.Get("component").MustString("Grafana"), - Group: fc.Config.Settings.Get("group").MustString("default"), - Summary: fc.Config.Settings.Get("summary").MustString(DefaultMessageTitleEmbed), - }, + tmpl: fc.Template, + log: log.New("alerting.notifier." + fc.Config.Name), + ns: fc.NotificationService, + images: fc.ImageStore, + settings: settings, }, nil } @@ -152,9 +199,15 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m details[k] = detail } + severity := strings.ToLower(tmpl(pn.settings.Severity)) + if _, ok := knownSeverity[severity]; !ok { + pn.log.Warn("Severity is not in the list of known values - using default severity", "actualSeverity", severity, "defaultSeverity", defaultSeverity) + severity = defaultSeverity + } + msg := &pagerDutyMessage{ - Client: "Grafana", - ClientURL: pn.tmpl.ExternalURL.String(), + Client: tmpl(pn.settings.Client), + ClientURL: tmpl(pn.settings.ClientURL), RoutingKey: pn.settings.Key, EventAction: eventType, DedupKey: key.Hash(), @@ -163,9 +216,10 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m Text: "External URL", }}, Payload: pagerDutyPayload{ + Source: tmpl(pn.settings.Source), Component: tmpl(pn.settings.Component), Summary: tmpl(pn.settings.Summary), - Severity: tmpl(pn.settings.Severity), + Severity: severity, CustomDetails: details, Class: tmpl(pn.settings.Class), Group: tmpl(pn.settings.Group), @@ -182,14 +236,9 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m }, as...) - if len(msg.Payload.Summary) > 1024 { - // This is the Pagerduty limit. - msg.Payload.Summary = msg.Payload.Summary[:1021] + "..." - } - - if hostname, err := os.Hostname(); err == nil { - // TODO: should this be configured like in Prometheus AM? - msg.Payload.Source = hostname + if summary, truncated := notify.Truncate(msg.Payload.Summary, 1024); truncated { + pn.log.Debug("Truncated summary", "original", msg.Payload.Summary) + msg.Payload.Summary = summary } if tmplErr != nil { diff --git a/pkg/services/ngalert/notifier/channels/pagerduty_test.go b/pkg/services/ngalert/notifier/channels/pagerduty_test.go index 7e86b040f5b..7325d81f96a 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty_test.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty_test.go @@ -3,18 +3,21 @@ package channels import ( "context" "encoding/json" + "fmt" + "math/rand" "net/url" "os" + "strings" "testing" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/secrets/fakes" - secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" - "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" ) func TestPagerdutyNotifier(t *testing.T) { @@ -53,7 +56,7 @@ func TestPagerdutyNotifier(t *testing.T) { Payload: pagerDutyPayload{ Summary: "[FIRING:1] (val1)", Source: hostname, - Severity: "critical", + Severity: defaultSeverity, Class: "default", Component: "Grafana", Group: "default", @@ -69,7 +72,87 @@ func TestPagerdutyNotifier(t *testing.T) { Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, }, expMsgError: nil, - }, { + }, + { + name: "should map unknown severity", + settings: `{"integrationKey": "abcdefgh0123456789", "severity": "{{ .CommonLabels.severity }}"}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1", "severity": "invalid-severity"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: &pagerDutyMessage{ + RoutingKey: "abcdefgh0123456789", + DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", + EventAction: "trigger", + Payload: pagerDutyPayload{ + Summary: "[FIRING:1] (val1 invalid-severity)", + Source: hostname, + Severity: defaultSeverity, + Class: "default", + Component: "Grafana", + Group: "default", + CustomDetails: map[string]string{ + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\n - severity = invalid-severity\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1&matcher=severity%3Dinvalid-severity\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "num_firing": "1", + "num_resolved": "0", + "resolved": "", + }, + }, + Client: "Grafana", + ClientURL: "http://localhost", + Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, + }, + expMsgError: nil, + }, + { + name: "Should expand templates in fields", + settings: `{ + "integrationKey": "abcdefgh0123456789", + "severity" : "{{ .CommonLabels.severity }}", + "class": "{{ .CommonLabels.class }}", + "component": "{{ .CommonLabels.component }}", + "group" : "{{ .CommonLabels.group }}", + "source": "{{ .CommonLabels.source }}", + "client": "client-{{ .CommonLabels.source }}", + "client_url": "http://localhost:20200/{{ .CommonLabels.group }}" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1", "severity": "critical", "class": "test-class", "group": "test-group", "component": "test-component", "source": "test-source"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: &pagerDutyMessage{ + RoutingKey: "abcdefgh0123456789", + DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", + EventAction: "trigger", + Payload: pagerDutyPayload{ + Summary: "[FIRING:1] (test-class test-component test-group val1 critical test-source)", + Source: "test-source", + Severity: "critical", + Class: "test-class", + Component: "test-component", + Group: "test-group", + CustomDetails: map[string]string{ + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - class = test-class\n - component = test-component\n - group = test-group\n - lbl1 = val1\n - severity = critical\n - source = test-source\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=class%3Dtest-class&matcher=component%3Dtest-component&matcher=group%3Dtest-group&matcher=lbl1%3Dval1&matcher=severity%3Dcritical&matcher=source%3Dtest-source\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "num_firing": "1", + "num_resolved": "0", + "resolved": "", + }, + }, + Client: "client-test-source", + ClientURL: "http://localhost:20200/test-group", + Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, + }, + expMsgError: nil, + }, + { name: "Default config with one alert and custom summary", settings: `{"integrationKey": "abcdefgh0123456789", "summary": "Alerts firing: {{ len .Alerts.Firing }}"}`, alerts: []*types.Alert{ @@ -87,7 +170,7 @@ func TestPagerdutyNotifier(t *testing.T) { Payload: pagerDutyPayload{ Summary: "Alerts firing: 1", Source: hostname, - Severity: "critical", + Severity: defaultSeverity, Class: "default", Component: "Grafana", Group: "default", @@ -148,7 +231,43 @@ func TestPagerdutyNotifier(t *testing.T) { Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, }, expMsgError: nil, - }, { + }, + { + name: "should truncate long summary", + settings: fmt.Sprintf(`{"integrationKey": "abcdefgh0123456789", "summary": "%s"}`, strings.Repeat("1", rand.Intn(100)+1025)), + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: &pagerDutyMessage{ + RoutingKey: "abcdefgh0123456789", + DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", + EventAction: "trigger", + Payload: pagerDutyPayload{ + Summary: fmt.Sprintf("%s…", strings.Repeat("1", 1023)), + Source: hostname, + Severity: defaultSeverity, + Class: "default", + Component: "Grafana", + Group: "default", + CustomDetails: map[string]string{ + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "num_firing": "1", + "num_resolved": "0", + "resolved": "", + }, + }, + Client: "Grafana", + ClientURL: "http://localhost", + Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, + }, + expMsgError: nil, + }, + { name: "Error in initing", settings: `{}`, expInitError: `could not find integration key property in settings`, diff --git a/pkg/services/ngalert/notifier/channels/util.go b/pkg/services/ngalert/notifier/channels/util.go index 244418df1c9..1149f3b40b7 100644 --- a/pkg/services/ngalert/notifier/channels/util.go +++ b/pkg/services/ngalert/notifier/channels/util.go @@ -20,7 +20,7 @@ import ( "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 49e4c01b93b..400b8f202ff 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -1,11 +1,15 @@ package channels_config import ( + "os" + "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" ) // GetAvailableNotifiers returns the metadata of all the notification channels that can be configured. func GetAvailableNotifiers() []*NotifierPlugin { + hostname, _ := os.Hostname() + pushoverSoundOptions := []SelectOption{ { Value: "default", @@ -239,26 +243,11 @@ func GetAvailableNotifiers() []*NotifierPlugin { Secure: true, }, { - Label: "Severity", - Element: ElementTypeSelect, - SelectOptions: []SelectOption{ - { - Value: "critical", - Label: "Critical", - }, - { - Value: "error", - Label: "Error", - }, - { - Value: "warning", - Label: "Warning", - }, - { - Value: "info", - Label: "Info", - }, - }, + Label: "Severity", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "error", + Description: "Severity of the event. It must be critical, error, warning, info - otherwise, the default is set which is error. You can use templates", PropertyName: "severity", }, { // New in 8.0. @@ -291,6 +280,30 @@ func GetAvailableNotifiers() []*NotifierPlugin { Placeholder: channels.DefaultMessageTitleEmbed, PropertyName: "summary", }, + { // New in 9.4. + Label: "Source", + Description: "The unique location of the affected system, preferably a hostname or FQDN. You can use templates", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: hostname, + PropertyName: "source", + }, + { // New in 9.4. + Label: "Client", + Description: "The name of the monitoring client that is triggering this event. You can use templates", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "Grafana", + PropertyName: "client", + }, + { // New in 9.4. + Label: "Client URL", + Description: "The URL of the monitoring client that is triggering this event. You can use templates", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "{{ .ExternalURL }}", + PropertyName: "client_url", + }, }, }, { diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 429d76526c0..f360deed48f 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -318,7 +318,7 @@ func (service *AlertRuleService) DeleteAlertRule(ctx context.Context, orgID int6 // checkLimitsTransactionCtx checks whether the current transaction (as identified by the ctx) breaches configured alert rule limits. func (service *AlertRuleService) checkLimitsTransactionCtx(ctx context.Context, orgID, userID int64) error { - limitReached, err := service.quotas.CheckQuotaReached(ctx, "alert_rule", "a.ScopeParameters{ + limitReached, err := service.quotas.CheckQuotaReached(ctx, models.QuotaTargetSrv, "a.ScopeParameters{ OrgID: orgID, UserID: userID, }) diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/schedule/compat.go index 5e96e052d69..76eaa061d89 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/schedule/compat.go @@ -130,7 +130,7 @@ func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr } } -func FromAlertStateToPostableAlerts(firingStates []*state.State, stateManager *state.Manager, appURL *url.URL) apimodels.PostableAlerts { +func FromStateTransitionToPostableAlerts(firingStates []state.StateTransition, stateManager *state.Manager, appURL *url.URL) apimodels.PostableAlerts { alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))} var sentAlerts []*state.State ts := time.Now() @@ -139,13 +139,13 @@ func FromAlertStateToPostableAlerts(firingStates []*state.State, stateManager *s if !alertState.NeedsSending(stateManager.ResendDelay) { continue } - alert := stateToPostableAlert(alertState, appURL) + alert := stateToPostableAlert(alertState.State, appURL) alerts.PostableAlerts = append(alerts.PostableAlerts, *alert) if alertState.StateReason == ngModels.StateReasonMissingSeries { // do not put stale state back to state manager continue } alertState.LastSentAt = ts - sentAlerts = append(sentAlerts, alertState) + sentAlerts = append(sentAlerts, alertState.State) } stateManager.Put(sentAlerts) return alerts diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index 12c6ea4bb62..503885fea5d 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -371,7 +371,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR return } processedStates := sch.stateManager.ProcessEvalResults(ctx, e.scheduledAt, e.rule, results, sch.getRuleExtraLabels(e)) - alerts := FromAlertStateToPostableAlerts(processedStates, sch.stateManager, sch.appURL) + alerts := FromStateTransitionToPostableAlerts(processedStates, sch.stateManager, sch.appURL) if len(alerts.PostableAlerts) > 0 { sch.alertsSender.Send(key, alerts) } diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 00cd257ae0c..b595e831f3e 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -21,6 +21,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/plugins" "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" @@ -651,7 +652,7 @@ func setupScheduler(t *testing.T, rs *fakeRulesStore, is *state.FakeInstanceStor var evaluator = evalMock if evalMock == nil { - evaluator = eval.NewEvaluatorFactory(setting.UnifiedAlertingSettings{}, nil, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil)) + evaluator = eval.NewEvaluatorFactory(setting.UnifiedAlertingSettings{}, nil, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil), &plugins.FakePluginStore{}) } if registry == nil { diff --git a/pkg/services/ngalert/state/historian/annotation.go b/pkg/services/ngalert/state/historian/annotation.go index c94805178af..9687b463592 100644 --- a/pkg/services/ngalert/state/historian/annotation.go +++ b/pkg/services/ngalert/state/historian/annotation.go @@ -9,6 +9,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/annotations" @@ -34,7 +35,7 @@ func NewAnnotationHistorian(annotations annotations.Repository, dashboards dashb } // RecordStates writes a number of state transitions for a given rule to state history. -func (h *AnnotationStateHistorian) RecordStates(ctx context.Context, rule *ngmodels.AlertRule, states []state.StateTransition) { +func (h *AnnotationStateHistorian) RecordStatesAsync(ctx context.Context, rule *ngmodels.AlertRule, states []state.StateTransition) { logger := h.log.FromContext(ctx) // Build annotations before starting goroutine, to make sure all data is copied and won't mutate underneath us. annotations := h.buildAnnotations(rule, states, logger) @@ -45,6 +46,9 @@ func (h *AnnotationStateHistorian) RecordStates(ctx context.Context, rule *ngmod func (h *AnnotationStateHistorian) buildAnnotations(rule *ngmodels.AlertRule, states []state.StateTransition, logger log.Logger) []annotations.Item { items := make([]annotations.Item, 0, len(states)) for _, state := range states { + if !shouldAnnotate(state) { + continue + } logger.Debug("Alert state changed creating annotation", "newState", state.Formatted(), "oldState", state.PreviousFormatted()) annotationText, annotationData := buildAnnotationTextAndData(rule, state.State) @@ -154,3 +158,11 @@ func removePrivateLabels(labels data.Labels) data.Labels { } return result } + +func shouldAnnotate(transition state.StateTransition) bool { + // Do not log not transitioned states normal states if it was marked as stale + if !transition.Changed() || transition.StateReason == ngmodels.StateReasonMissingSeries && transition.PreviousState == eval.Normal && transition.State.State == eval.Normal { + return false + } + return true +} diff --git a/pkg/services/ngalert/state/historian/annotation_test.go b/pkg/services/ngalert/state/historian/annotation_test.go new file mode 100644 index 00000000000..eb008035c3c --- /dev/null +++ b/pkg/services/ngalert/state/historian/annotation_test.go @@ -0,0 +1,100 @@ +package historian + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/eval" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" +) + +func TestShouldAnnotate(t *testing.T) { + allStates := []eval.State{ + eval.Normal, + eval.Alerting, + eval.Pending, + eval.NoData, + eval.Error, + } + + type Transition struct { + State eval.State + StateReason string + PreviousState eval.State + PreviousStateReason string + } + + transition := func(from eval.State, fromReason string, to eval.State, toReason string) Transition { + return Transition{ + PreviousState: from, + PreviousStateReason: fromReason, + State: to, + StateReason: toReason, + } + } + + noTransition := func(state eval.State, stateReason string) Transition { + return transition(state, stateReason, state, stateReason) + } + + knownReasons := []string{ + "", + models.StateReasonMissingSeries, + eval.Error.String(), + eval.NoData.String(), + } + + allCombinations := make([]Transition, 0, len(allStates)*len(allStates)*len(knownReasons)*len(knownReasons)) + for _, from := range allStates { + for _, reasonFrom := range knownReasons { + for _, to := range allStates { + for _, reasonTo := range knownReasons { + allCombinations = append(allCombinations, transition(from, reasonFrom, to, reasonTo)) + } + } + } + } + + negativeTransitions := map[Transition]struct{}{ + noTransition(eval.Normal, ""): {}, + noTransition(eval.Normal, eval.Error.String()): {}, + noTransition(eval.Normal, eval.NoData.String()): {}, + noTransition(eval.Normal, models.StateReasonMissingSeries): {}, + noTransition(eval.Alerting, ""): {}, + noTransition(eval.Alerting, eval.Error.String()): {}, + noTransition(eval.Alerting, eval.NoData.String()): {}, + noTransition(eval.Alerting, models.StateReasonMissingSeries): {}, + noTransition(eval.Pending, ""): {}, + noTransition(eval.Pending, eval.Error.String()): {}, + noTransition(eval.Pending, eval.NoData.String()): {}, + noTransition(eval.Pending, models.StateReasonMissingSeries): {}, + noTransition(eval.NoData, ""): {}, + noTransition(eval.NoData, eval.Error.String()): {}, + noTransition(eval.NoData, eval.NoData.String()): {}, + noTransition(eval.NoData, models.StateReasonMissingSeries): {}, + noTransition(eval.Error, ""): {}, + noTransition(eval.Error, eval.Error.String()): {}, + noTransition(eval.Error, eval.NoData.String()): {}, + noTransition(eval.Error, models.StateReasonMissingSeries): {}, + + transition(eval.Normal, "", eval.Normal, models.StateReasonMissingSeries): {}, + transition(eval.Normal, eval.Error.String(), eval.Normal, models.StateReasonMissingSeries): {}, + transition(eval.Normal, eval.NoData.String(), eval.Normal, models.StateReasonMissingSeries): {}, + } + + for _, tc := range allCombinations { + _, ok := negativeTransitions[tc] + trans := state.StateTransition{ + State: &state.State{State: tc.State, StateReason: tc.StateReason}, + PreviousState: tc.PreviousState, + PreviousStateReason: tc.PreviousStateReason, + } + + t.Run(fmt.Sprintf("%s -> %s should be %v", trans.PreviousFormatted(), trans.Formatted(), !ok), func(t *testing.T) { + require.Equal(t, !ok, shouldAnnotate(trans)) + }) + } +} diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 388adb81403..1ffc99b6c1e 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -167,7 +167,7 @@ func (st *Manager) ResetStateByRuleUID(ctx context.Context, ruleKey ngModels.Ale // ProcessEvalResults updates the current states that belong to a rule with the evaluation results. // if extraLabels is not empty, those labels will be added to every state. The extraLabels take precedence over rule labels and result labels -func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time, alertRule *ngModels.AlertRule, results eval.Results, extraLabels data.Labels) []*State { +func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time, alertRule *ngModels.AlertRule, results eval.Results, extraLabels data.Labels) []StateTransition { logger := st.log.FromContext(ctx) logger.Debug("State manager processing evaluation results", "resultCount", len(results)) var states []StateTransition @@ -181,19 +181,11 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time st.saveAlertStates(ctx, logger, states...) - st.logStateTransitions(ctx, alertRule, states, staleStates) - - nextStates := make([]*State, 0, len(states)) - for _, s := range states { - nextStates = append(nextStates, s.State) + allChanges := append(states, staleStates...) + if st.historian != nil { + st.historian.RecordStatesAsync(ctx, alertRule, allChanges) } - // TODO refactor further. Do not filter because it will be filtered downstream - for _, s := range staleStates { - if s.PreviousState == eval.Alerting { - nextStates = append(nextStates, s.State) - } - } - return nextStates + return allChanges } // Set the current state based on evaluation results @@ -213,17 +205,24 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu oldState := currentState.State oldReason := currentState.StateReason - logger.Debug("Setting alert state") + // Add the instance to the log context to help correlate log lines for a state + logger = logger.New("instance", result.Instance) + switch result.State { case eval.Normal: - currentState.resultNormal(alertRule, result) + logger.Debug("Setting next state", "handler", "resultNormal") + resultNormal(currentState, alertRule, result, logger) case eval.Alerting: - currentState.resultAlerting(alertRule, result) + logger.Debug("Setting next state", "handler", "resultAlerting") + resultAlerting(currentState, alertRule, result, logger) case eval.Error: - currentState.resultError(alertRule, result) + logger.Debug("Setting next state", "handler", "resultError") + resultError(currentState, alertRule, result, logger) case eval.NoData: - currentState.resultNoData(alertRule, result) + logger.Debug("Setting next state", "handler", "resultNoData") + resultNoData(currentState, alertRule, result, logger) case eval.Pending: // we do not emit results with this state + logger.Debug("Ignoring set next state as result is pending") } // Set reason iff: result is different than state, reason is not Alerting or Normal @@ -316,26 +315,6 @@ func (st *Manager) saveAlertStates(ctx context.Context, logger log.Logger, state } } -func (st *Manager) logStateTransitions(ctx context.Context, alertRule *ngModels.AlertRule, newStates, staleStates []StateTransition) { - if st.historian == nil { - return - } - changedStates := make([]StateTransition, 0, len(staleStates)) - for _, s := range newStates { - if s.changed() { - changedStates = append(changedStates, s) - } - } - - // TODO refactor further. Let historian decide what to log. Current logic removes states `Normal (reason-X) -> Normal (reason-Y)` - for _, t := range staleStates { - if t.PreviousState == eval.Alerting { - changedStates = append(changedStates, t) - } - } - st.historian.RecordStates(ctx, alertRule, changedStates) -} - func (st *Manager) deleteAlertStates(ctx context.Context, logger log.Logger, states []StateTransition) { if st.instanceStore == nil || len(states) == 0 { return diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 69a8d792093..c4c67b1ddee 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -1868,7 +1868,7 @@ func TestProcessEvalResults(t *testing.T) { Values: make(map[string]*float64), }, }, - StartsAt: evaluationTime.Add(20 * time.Second), + StartsAt: evaluationTime.Add(30 * time.Second), EndsAt: evaluationTime.Add(50 * time.Second).Add(state.ResendDelay * 3), LastEvaluationTime: evaluationTime.Add(50 * time.Second), EvaluationDuration: evaluationDuration, @@ -1937,7 +1937,7 @@ func TestProcessEvalResults(t *testing.T) { "instance_label": "test", }, Values: make(map[string]float64), - State: eval.Alerting, + State: eval.Pending, Results: []state.Evaluation{ { EvaluationTime: evaluationTime.Add(30 * time.Second), @@ -2309,7 +2309,18 @@ func TestStaleResults(t *testing.T) { return key } - checkExpectedStates := func(t *testing.T, actual []*state.State, expected map[string]struct{}) { + checkExpectedStates := func(t *testing.T, actual []*state.State, expected map[string]struct{}) map[string]*state.State { + t.Helper() + result := make(map[string]*state.State) + require.Len(t, actual, len(expected)) + for _, currentState := range actual { + _, ok := expected[currentState.CacheID] + result[currentState.CacheID] = currentState + require.Truef(t, ok, "State %s is not expected. States: %v", currentState.CacheID, expected) + } + return result + } + checkExpectedStateTransitions := func(t *testing.T, actual []state.StateTransition, expected map[string]struct{}) { t.Helper() require.Len(t, actual, len(expected)) for _, currentState := range actual { @@ -2318,75 +2329,83 @@ func TestStaleResults(t *testing.T) { } } + ctx := context.Background() + clk := clock.NewMock() + + store := &state.FakeInstanceStore{} + + st := state.NewManager(testMetrics.GetStateMetrics(), nil, store, &state.NoopImageService{}, clk, &state.FakeHistorian{}) + + rule := models.AlertRuleGen(models.WithFor(0))() + + initResults := eval.Results{ + eval.ResultGen(eval.WithEvaluatedAt(clk.Now()))(), + eval.ResultGen(eval.WithState(eval.Alerting), eval.WithEvaluatedAt(clk.Now()))(), + eval.ResultGen(eval.WithState(eval.Normal), eval.WithEvaluatedAt(clk.Now()))(), + } + + state1 := getCacheID(t, rule, initResults[0]) + state2 := getCacheID(t, rule, initResults[1]) + state3 := getCacheID(t, rule, initResults[2]) + + initStates := map[string]struct{}{ + state1: {}, + state2: {}, + state3: {}, + } + + // Init + processed := st.ProcessEvalResults(ctx, clk.Now(), rule, initResults, nil) + checkExpectedStateTransitions(t, processed, initStates) + + currentStates := st.GetStatesForRuleUID(rule.OrgID, rule.UID) + statesMap := checkExpectedStates(t, currentStates, initStates) + require.Equal(t, eval.Alerting, statesMap[state2].State) // make sure the state is alerting because we need it to be resolved later + + staleDuration := 2 * time.Duration(rule.IntervalSeconds) * time.Second + clk.Add(staleDuration) + result := initResults[0] + result.EvaluatedAt = clk.Now() + results := eval.Results{ + result, + } + + var expectedStaleKeys []models.AlertInstanceKey t.Run("should mark missing states as stale", func(t *testing.T) { - // init - ctx := context.Background() - _, dbstore := tests.SetupTestEnv(t, 1) - clk := clock.NewMock() - clk.Set(time.Now()) - - st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, &state.NoopImageService{}, clk, &state.FakeHistorian{}) - - orgID := rand.Int63() - rule := tests.CreateTestAlertRule(t, ctx, dbstore, 10, orgID) - - initResults := eval.Results{ - eval.Result{ - Instance: data.Labels{"test1": "testValue1"}, - State: eval.Alerting, - EvaluatedAt: clk.Now(), - }, - eval.Result{ - Instance: data.Labels{"test1": "testValue2"}, - State: eval.Alerting, - EvaluatedAt: clk.Now(), - }, - eval.Result{ - Instance: data.Labels{"test1": "testValue3"}, - State: eval.Normal, - EvaluatedAt: clk.Now(), - }, - } - - initStates := map[string]struct{}{ - getCacheID(t, rule, initResults[0]): {}, - getCacheID(t, rule, initResults[1]): {}, - getCacheID(t, rule, initResults[2]): {}, - } - - // Init - processed := st.ProcessEvalResults(ctx, clk.Now(), rule, initResults, nil) - checkExpectedStates(t, processed, initStates) - currentStates := st.GetStatesForRuleUID(orgID, rule.UID) - checkExpectedStates(t, currentStates, initStates) - - staleDuration := 2 * time.Duration(rule.IntervalSeconds) * time.Second - clk.Add(staleDuration) - results := eval.Results{ - eval.Result{ - Instance: data.Labels{"test1": "testValue1"}, - State: eval.Alerting, - EvaluatedAt: clk.Now(), - }, - } - clk.Add(time.Nanosecond) // we use time now when calculate stale states. Evaluation tick and real time are not the same. usually, difference is way greater than nanosecond. - expectedStaleReturned := getCacheID(t, rule, initResults[1]) processed = st.ProcessEvalResults(ctx, clk.Now(), rule, results, nil) - checkExpectedStates(t, processed, map[string]struct{}{ - getCacheID(t, rule, results[0]): {}, - expectedStaleReturned: {}, - }) + checkExpectedStateTransitions(t, processed, initStates) for _, s := range processed { - if s.CacheID == expectedStaleReturned { - assert.Truef(t, s.Resolved, "Returned stale state should have Resolved set to true") - assert.Equal(t, eval.Normal, s.State) - assert.Equal(t, models.StateReasonMissingSeries, s.StateReason) - break + if s.CacheID == state1 { + continue } + assert.Equal(t, eval.Normal, s.State.State) + assert.Equal(t, models.StateReasonMissingSeries, s.StateReason) + assert.Equal(t, clk.Now(), s.EndsAt) + if s.CacheID == state2 { + assert.Truef(t, s.Resolved, "Returned stale state should have Resolved set to true") + } + key, err := s.GetAlertInstanceKey() + require.NoError(t, err) + expectedStaleKeys = append(expectedStaleKeys, key) } - currentStates = st.GetStatesForRuleUID(orgID, rule.UID) + }) + + t.Run("should remove stale states from cache", func(t *testing.T) { + currentStates = st.GetStatesForRuleUID(rule.OrgID, rule.UID) checkExpectedStates(t, currentStates, map[string]struct{}{ getCacheID(t, rule, results[0]): {}, }) }) + + t.Run("should delete stale states from the database", func(t *testing.T) { + for _, op := range store.RecordedOps { + switch q := op.(type) { + case state.FakeInstanceStoreOp: + keys, ok := q.Args[1].([]models.AlertInstanceKey) + require.Truef(t, ok, "Failed to parse fake store operations") + require.Len(t, keys, 2) + require.EqualValues(t, expectedStaleKeys, keys) + } + } + }) } diff --git a/pkg/services/ngalert/state/persist.go b/pkg/services/ngalert/state/persist.go index 78bcd181daf..1dd8a0366ab 100644 --- a/pkg/services/ngalert/state/persist.go +++ b/pkg/services/ngalert/state/persist.go @@ -23,7 +23,7 @@ type RuleReader interface { // Historian maintains an audit log of alert state history. type Historian interface { // RecordStates writes a number of state transitions for a given rule to state history. - RecordStates(ctx context.Context, rule *models.AlertRule, states []StateTransition) + RecordStatesAsync(ctx context.Context, rule *models.AlertRule, states []StateTransition) } // ImageCapturer captures images. diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 839dc014c2c..e804bb5aa61 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/screenshot" @@ -81,11 +82,62 @@ func (a *State) GetAlertInstanceKey() (models.AlertInstanceKey, error) { return models.AlertInstanceKey{RuleOrgID: a.OrgID, RuleUID: a.AlertRuleUID, LabelsHash: labelsHash}, nil } +// SetAlerting sets the state to Alerting. It changes both the start and end time. +func (a *State) SetAlerting(reason string, startsAt, endsAt time.Time) { + a.State = eval.Alerting + a.StateReason = reason + a.StartsAt = startsAt + a.EndsAt = endsAt + a.Error = nil +} + +// SetPending the state to Pending. It changes both the start and end time. +func (a *State) SetPending(reason string, startsAt, endsAt time.Time) { + a.State = eval.Pending + a.StateReason = reason + a.StartsAt = startsAt + a.EndsAt = endsAt + a.Error = nil +} + +// SetNoData sets the state to NoData. It changes both the start and end time. +func (a *State) SetNoData(reason string, startsAt, endsAt time.Time) { + a.State = eval.NoData + a.StateReason = reason + a.StartsAt = startsAt + a.EndsAt = endsAt + a.Error = nil +} + +// SetError sets the state to Error. It changes both the start and end time. +func (a *State) SetError(err error, startsAt, endsAt time.Time) { + a.State = eval.Error + a.StateReason = models.StateReasonError + a.StartsAt = startsAt + a.EndsAt = endsAt + a.Error = err +} + +// SetNormal sets the state to Normal. It changes both the start and end time. +func (a *State) SetNormal(reason string, startsAt, endsAt time.Time) { + a.State = eval.Normal + a.StateReason = reason + a.StartsAt = startsAt + a.EndsAt = endsAt + a.Error = nil +} + +// Resolve sets the State to Normal. It updates the StateReason, the end time, and sets Resolved to true. func (a *State) Resolve(reason string, endsAt time.Time) { a.State = eval.Normal a.StateReason = reason - a.EndsAt = endsAt a.Resolved = true + a.EndsAt = endsAt +} + +// Maintain updates the end time using the most recent evaluation. +func (a *State) Maintain(interval int64, evaluatedAt time.Time) { + a.EndsAt = nextEndsTime(interval, evaluatedAt) } // StateTransition describes the transition from one state to another. @@ -103,7 +155,7 @@ func (c StateTransition) PreviousFormatted() string { return FormatStateAndReason(c.PreviousState, c.PreviousStateReason) } -func (c StateTransition) changed() bool { +func (c StateTransition) Changed() bool { return c.PreviousState != c.State.State || c.PreviousStateReason != c.State.StateReason } @@ -127,113 +179,96 @@ func NewEvaluationValues(m map[string]eval.NumberValueCapture) map[string]*float return result } -func (a *State) resultNormal(_ *models.AlertRule, result eval.Result) { - a.Error = nil // should be nil since state is not error - if a.State != eval.Normal { - a.EndsAt = result.EvaluatedAt - a.StartsAt = result.EvaluatedAt +func resultNormal(state *State, _ *models.AlertRule, result eval.Result, logger log.Logger) { + if state.State == eval.Normal { + logger.Debug("Keeping state", "state", state.State) + } else { + logger.Debug("Changing state", "previous_state", state.State, "next_state", eval.Normal) + // Normal states have the same start and end timestamps + state.SetNormal("", result.EvaluatedAt, result.EvaluatedAt) } - a.State = eval.Normal } -func (a *State) resultAlerting(alertRule *models.AlertRule, result eval.Result) { - a.Error = result.Error // should be nil since the state is not an error - - switch a.State { +func resultAlerting(state *State, rule *models.AlertRule, result eval.Result, logger log.Logger) { + switch state.State { case eval.Alerting: - a.setEndsAt(alertRule, result) + logger.Debug("Keeping state", "state", state.State) + state.Maintain(rule.IntervalSeconds, result.EvaluatedAt) case eval.Pending: - if result.EvaluatedAt.Sub(a.StartsAt) >= alertRule.For { - a.State = eval.Alerting - a.StartsAt = result.EvaluatedAt - a.setEndsAt(alertRule, result) + // If the previous state is Pending then check if the For duration has been observed + if result.EvaluatedAt.Sub(state.StartsAt) >= rule.For { + logger.Debug("Changing state", "previous_state", state.State, "next_state", eval.Alerting) + state.SetAlerting("", result.EvaluatedAt, nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt)) } default: - a.StartsAt = result.EvaluatedAt - a.setEndsAt(alertRule, result) - if !(alertRule.For > 0) { - // If For is 0, immediately set Alerting - a.State = eval.Alerting + if rule.For > 0 { + // If the alert rule has a For duration that should be observed then the state should be set to Pending + logger.Debug("Changing state", "previous_state", state.State, "next_state", eval.Pending) + state.SetPending("", result.EvaluatedAt, nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt)) } else { - a.State = eval.Pending + logger.Debug("Changing state", "previous_state", state.State, "next_state", eval.Alerting) + state.SetAlerting("", result.EvaluatedAt, nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt)) } } } - -func (a *State) resultError(alertRule *models.AlertRule, result eval.Result) { - a.Error = result.Error - - execErrState := eval.Error - switch alertRule.ExecErrState { +func resultError(state *State, rule *models.AlertRule, result eval.Result, logger log.Logger) { + switch rule.ExecErrState { case models.AlertingErrState: - execErrState = eval.Alerting + logger.Debug("Execution error state is Alerting", "handler", "resultAlerting", "previous_handler", "resultError") + resultAlerting(state, rule, result, logger) + // This is a special case where Alerting and Pending should also have an error and reason + state.Error = result.Error + state.StateReason = "error" case models.ErrorErrState: - // If the evaluation failed because a query returned an error then - // update the state with the Datasource UID as a label and the error - // message as an annotation so other code can use this metadata to - // add context to alerts - var queryError expr.QueryError - if errors.As(a.Error, &queryError) { - for _, next := range alertRule.Data { - if next.RefID == queryError.RefID { - a.Labels["ref_id"] = next.RefID - a.Labels["datasource_uid"] = next.DatasourceUID - break + if state.State == eval.Error { + logger.Debug("Keeping state", "state", state.State) + state.Maintain(rule.IntervalSeconds, result.EvaluatedAt) + } else { + // This is the first occurrence of an error + logger.Debug("Changing state", "previous_state", state.State, "next_state", eval.Error) + state.SetError(result.Error, result.EvaluatedAt, nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt)) + + if result.Error != nil { + // If the evaluation failed because a query returned an error then add the Ref ID and + // Datasource UID as labels + var queryError expr.QueryError + if errors.As(state.Error, &queryError) { + for _, next := range rule.Data { + if next.RefID == queryError.RefID { + state.Labels["ref_id"] = next.RefID + state.Labels["datasource_uid"] = next.DatasourceUID + break + } + } + state.Annotations["Error"] = queryError.Error() } } - a.Annotations["Error"] = queryError.Error() } - execErrState = eval.Error case models.OkErrState: - a.resultNormal(alertRule, result) - return + logger.Debug("Execution error state is Normal", "handler", "resultNormal", "previous_handler", "resultError") + resultNormal(state, rule, result, logger) default: - a.Error = fmt.Errorf("cannot map error to a state because option [%s] is not supported. evaluation error: %w", alertRule.ExecErrState, a.Error) - } - - switch a.State { - case eval.Alerting, eval.Error: - // We must set the state here as the state can change both from Alerting - // to Error and from Error to Alerting. This can happen when the datasource - // is unavailable or queries against the datasource returns errors, and is - // then resolved as soon as the datasource is available and queries return - // without error - a.State = execErrState - a.setEndsAt(alertRule, result) - case eval.Pending: - if result.EvaluatedAt.Sub(a.StartsAt) >= alertRule.For { - a.State = execErrState - a.StartsAt = result.EvaluatedAt - a.setEndsAt(alertRule, result) - } - default: - // For is observed when Alerting is chosen for the alert state - // if execution error or timeout. - if execErrState == eval.Alerting && alertRule.For > 0 { - a.State = eval.Pending - } else { - a.State = execErrState - } - a.StartsAt = result.EvaluatedAt - a.setEndsAt(alertRule, result) + err := fmt.Errorf("unsupported execution error state: %s", rule.ExecErrState) + state.SetError(err, state.StartsAt, nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt)) + state.Annotations["Error"] = err.Error() } } -func (a *State) resultNoData(alertRule *models.AlertRule, result eval.Result) { - a.Error = result.Error +func resultNoData(state *State, rule *models.AlertRule, result eval.Result, _ log.Logger) { + state.Error = result.Error - if a.StartsAt.IsZero() { - a.StartsAt = result.EvaluatedAt + if state.StartsAt.IsZero() { + state.StartsAt = result.EvaluatedAt } - a.setEndsAt(alertRule, result) + state.EndsAt = nextEndsTime(rule.IntervalSeconds, result.EvaluatedAt) - switch alertRule.NoDataState { + switch rule.NoDataState { case models.Alerting: - a.State = eval.Alerting + state.State = eval.Alerting case models.NoData: - a.State = eval.NoData + state.State = eval.NoData case models.OK: - a.State = eval.Normal + state.State = eval.Normal } } @@ -278,17 +313,13 @@ func (a *State) TrimResults(alertRule *models.AlertRule) { a.Results = newResults } -// setEndsAt sets the ending timestamp of the alert. -// The internal Alertmanager will use this time to know when it should automatically resolve the alert -// in case it hasn't received additional alerts. Under regular operations the scheduler will continue to send the -// alert with an updated EndsAt, if the alert is resolved then a last alert is sent with EndsAt = last evaluation time. -func (a *State) setEndsAt(alertRule *models.AlertRule, result eval.Result) { +func nextEndsTime(interval int64, evaluatedAt time.Time) time.Time { ends := ResendDelay - if alertRule.IntervalSeconds > int64(ResendDelay.Seconds()) { - ends = time.Second * time.Duration(alertRule.IntervalSeconds) + intv := time.Second * time.Duration(interval) + if intv > ResendDelay { + ends = intv } - - a.EndsAt = result.EvaluatedAt.Add(ends * 3) + return evaluatedAt.Add(3 * ends) } func (a *State) GetLabels(opts ...models.LabelOption) map[string]string { diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index b99fd3e32ab..45680d8c2a1 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -8,15 +8,341 @@ import ( "testing" "time" + "github.com/benbjohnson/clock" "github.com/golang/mock/gomock" - "github.com/grafana/grafana/pkg/services/ngalert/eval" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/screenshot" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ptr "github.com/xorcare/pointer" + + "github.com/grafana/grafana/pkg/services/ngalert/eval" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/screenshot" ) +func TestSetAlerting(t *testing.T) { + mock := clock.NewMock() + tests := []struct { + name string + state State + reason string + startsAt time.Time + endsAt time.Time + expected State + }{{ + name: "state is set to Alerting", + reason: "this is a reason", + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.Alerting, + StateReason: "this is a reason", + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }, { + name: "previous state is removed", + state: State{ + State: eval.Normal, + StateReason: "this is a reason", + Error: errors.New("this is an error"), + }, + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.Alerting, + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := test.state + actual.SetAlerting(test.reason, test.startsAt, test.endsAt) + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestSetPending(t *testing.T) { + mock := clock.NewMock() + tests := []struct { + name string + state State + reason string + startsAt time.Time + endsAt time.Time + expected State + }{{ + name: "state is set to Pending", + reason: "this is a reason", + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.Pending, + StateReason: "this is a reason", + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }, { + name: "previous state is removed", + state: State{ + State: eval.Pending, + StateReason: "this is a reason", + Error: errors.New("this is an error"), + }, + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.Pending, + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := test.state + actual.SetPending(test.reason, test.startsAt, test.endsAt) + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestNormal(t *testing.T) { + mock := clock.NewMock() + tests := []struct { + name string + state State + reason string + startsAt time.Time + endsAt time.Time + expected State + }{{ + name: "state is set to Normal", + reason: "this is a reason", + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.Normal, + StateReason: "this is a reason", + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }, { + name: "previous state is removed", + state: State{ + State: eval.Normal, + StateReason: "this is a reason", + Error: errors.New("this is an error"), + }, + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.Normal, + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := test.state + actual.SetNormal(test.reason, test.startsAt, test.endsAt) + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestNoData(t *testing.T) { + mock := clock.NewMock() + tests := []struct { + name string + state State + reason string + startsAt time.Time + endsAt time.Time + expected State + }{{ + name: "state is set to No Data", + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.NoData, + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }, { + name: "previous state is removed", + state: State{ + State: eval.NoData, + StateReason: "this is a reason", + Error: errors.New("this is an error"), + }, + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + expected: State{ + State: eval.NoData, + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := test.state + actual.SetNoData(test.reason, test.startsAt, test.endsAt) + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestSetError(t *testing.T) { + mock := clock.NewMock() + tests := []struct { + name string + state State + startsAt time.Time + endsAt time.Time + error error + expected State + }{{ + name: "state is set to Error", + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + error: errors.New("this is an error"), + expected: State{ + State: eval.Error, + StateReason: ngmodels.StateReasonError, + Error: errors.New("this is an error"), + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }, { + name: "previous state is removed", + state: State{ + State: eval.Error, + StateReason: "this is a reason", + Error: errors.New("this is an error"), + }, + startsAt: mock.Now(), + endsAt: mock.Now().Add(time.Minute), + error: errors.New("this is another error"), + expected: State{ + State: eval.Error, + StateReason: ngmodels.StateReasonError, + Error: errors.New("this is another error"), + StartsAt: mock.Now(), + EndsAt: mock.Now().Add(time.Minute), + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := test.state + actual.SetError(test.error, test.startsAt, test.endsAt) + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestMaintain(t *testing.T) { + mock := clock.NewMock() + now := mock.Now() + + // the interval is less than the resend interval of 30 seconds + s := State{State: eval.Alerting, StartsAt: now, EndsAt: now.Add(time.Second)} + s.Maintain(10, now.Add(10*time.Second)) + // 10 seconds + 3 x 30 seconds is 100 seconds + assert.Equal(t, now.Add(100*time.Second), s.EndsAt) + + // the interval is above the resend interval of 30 seconds + s = State{State: eval.Alerting, StartsAt: now, EndsAt: now.Add(time.Second)} + s.Maintain(60, now.Add(10*time.Second)) + // 10 seconds + 3 x 60 seconds is 190 seconds + assert.Equal(t, now.Add(190*time.Second), s.EndsAt) +} + +func TestEnd(t *testing.T) { + evaluationTime, _ := time.Parse("2006-01-02", "2021-03-25") + testCases := []struct { + name string + expected time.Time + testRule *ngmodels.AlertRule + testResult eval.Result + }{ + { + name: "less than resend delay: for=unset,interval=10s - endsAt = resendDelay * 3", + expected: evaluationTime.Add(ResendDelay * 3), + testRule: &ngmodels.AlertRule{ + IntervalSeconds: 10, + }, + }, + { + name: "less than resend delay: for=0s,interval=10s - endsAt = resendDelay * 3", + expected: evaluationTime.Add(ResendDelay * 3), + testRule: &ngmodels.AlertRule{ + For: 0 * time.Second, + IntervalSeconds: 10, + }, + }, + { + name: "less than resend delay: for=10s,interval=10s - endsAt = resendDelay * 3", + expected: evaluationTime.Add(ResendDelay * 3), + testRule: &ngmodels.AlertRule{ + For: 10 * time.Second, + IntervalSeconds: 10, + }, + }, + { + name: "less than resend delay: for=10s,interval=20s - endsAt = resendDelay * 3", + expected: evaluationTime.Add(ResendDelay * 3), + testRule: &ngmodels.AlertRule{ + For: 10 * time.Second, + IntervalSeconds: 20, + }, + }, + { + name: "more than resend delay: for=unset,interval=1m - endsAt = interval * 3", + expected: evaluationTime.Add(time.Second * 60 * 3), + testRule: &ngmodels.AlertRule{ + IntervalSeconds: 60, + }, + }, + { + name: "more than resend delay: for=0s,interval=1m - endsAt = resendDelay * 3", + expected: evaluationTime.Add(time.Second * 60 * 3), + testRule: &ngmodels.AlertRule{ + For: 0 * time.Second, + IntervalSeconds: 60, + }, + }, + { + name: "more than resend delay: for=1m,interval=5m - endsAt = interval * 3", + expected: evaluationTime.Add(time.Second * 300 * 3), + testRule: &ngmodels.AlertRule{ + For: time.Minute, + IntervalSeconds: 300, + }, + }, + { + name: "more than resend delay: for=5m,interval=1m - endsAt = interval * 3", + expected: evaluationTime.Add(time.Second * 60 * 3), + testRule: &ngmodels.AlertRule{ + For: 300 * time.Second, + IntervalSeconds: 60, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := eval.Result{EvaluatedAt: evaluationTime} + assert.Equal(t, tc.expected, nextEndsTime(tc.testRule.IntervalSeconds, r.EvaluatedAt)) + }) + } +} + func TestNeedsSending(t *testing.T) { evaluationTime, _ := time.Parse("2006-01-02", "2021-03-25") testCases := []struct { @@ -144,88 +470,6 @@ func TestNeedsSending(t *testing.T) { } } -func TestSetEndsAt(t *testing.T) { - evaluationTime, _ := time.Parse("2006-01-02", "2021-03-25") - testCases := []struct { - name string - expected time.Time - testRule *ngmodels.AlertRule - testResult eval.Result - }{ - { - name: "less than resend delay: for=unset,interval=10s - endsAt = resendDelay * 3", - expected: evaluationTime.Add(ResendDelay * 3), - testRule: &ngmodels.AlertRule{ - IntervalSeconds: 10, - }, - }, - { - name: "less than resend delay: for=0s,interval=10s - endsAt = resendDelay * 3", - expected: evaluationTime.Add(ResendDelay * 3), - testRule: &ngmodels.AlertRule{ - For: 0 * time.Second, - IntervalSeconds: 10, - }, - }, - { - name: "less than resend delay: for=10s,interval=10s - endsAt = resendDelay * 3", - expected: evaluationTime.Add(ResendDelay * 3), - testRule: &ngmodels.AlertRule{ - For: 10 * time.Second, - IntervalSeconds: 10, - }, - }, - { - name: "less than resend delay: for=10s,interval=20s - endsAt = resendDelay * 3", - expected: evaluationTime.Add(ResendDelay * 3), - testRule: &ngmodels.AlertRule{ - For: 10 * time.Second, - IntervalSeconds: 20, - }, - }, - { - name: "more than resend delay: for=unset,interval=1m - endsAt = interval * 3", - expected: evaluationTime.Add(time.Second * 60 * 3), - testRule: &ngmodels.AlertRule{ - IntervalSeconds: 60, - }, - }, - { - name: "more than resend delay: for=0s,interval=1m - endsAt = resendDelay * 3", - expected: evaluationTime.Add(time.Second * 60 * 3), - testRule: &ngmodels.AlertRule{ - For: 0 * time.Second, - IntervalSeconds: 60, - }, - }, - { - name: "more than resend delay: for=1m,interval=5m - endsAt = interval * 3", - expected: evaluationTime.Add(time.Second * 300 * 3), - testRule: &ngmodels.AlertRule{ - For: time.Minute, - IntervalSeconds: 300, - }, - }, - { - name: "more than resend delay: for=5m,interval=1m - endsAt = interval * 3", - expected: evaluationTime.Add(time.Second * 60 * 3), - testRule: &ngmodels.AlertRule{ - For: 300 * time.Second, - IntervalSeconds: 60, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - s := &State{} - r := eval.Result{EvaluatedAt: evaluationTime} - s.setEndsAt(tc.testRule, r) - assert.Equal(t, tc.expected, s.EndsAt) - }) - } -} - func TestGetLastEvaluationValuesForCondition(t *testing.T) { genState := func(results []Evaluation) *State { return &State{ diff --git a/pkg/services/ngalert/state/testing.go b/pkg/services/ngalert/state/testing.go index 80c2a565e45..917c785eed4 100644 --- a/pkg/services/ngalert/state/testing.go +++ b/pkg/services/ngalert/state/testing.go @@ -15,6 +15,11 @@ type FakeInstanceStore struct { RecordedOps []interface{} } +type FakeInstanceStoreOp struct { + Name string + Args []interface{} +} + func (f *FakeInstanceStore) ListAlertInstances(_ context.Context, q *models.ListAlertInstancesQuery) error { f.mtx.Lock() defer f.mtx.Unlock() @@ -33,7 +38,15 @@ func (f *FakeInstanceStore) SaveAlertInstances(_ context.Context, q ...models.Al func (f *FakeInstanceStore) FetchOrgIds(_ context.Context) ([]int64, error) { return []int64{}, nil } -func (f *FakeInstanceStore) DeleteAlertInstances(_ context.Context, _ ...models.AlertInstanceKey) error { +func (f *FakeInstanceStore) DeleteAlertInstances(ctx context.Context, q ...models.AlertInstanceKey) error { + f.mtx.Lock() + defer f.mtx.Unlock() + f.RecordedOps = append(f.RecordedOps, FakeInstanceStoreOp{ + Name: "DeleteAlertInstances", Args: []interface{}{ + ctx, + q, + }, + }) return nil } @@ -49,7 +62,7 @@ func (f *FakeRuleReader) ListAlertRules(_ context.Context, q *models.ListAlertRu type FakeHistorian struct{} -func (f *FakeHistorian) RecordStates(ctx context.Context, rule *models.AlertRule, states []StateTransition) { +func (f *FakeHistorian) RecordStatesAsync(ctx context.Context, rule *models.AlertRule, states []StateTransition) { } // NotAvailableImageService is a service that returns ErrScreenshotsUnavailable. diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 0adc53b6e95..b03c54a751f 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" @@ -99,7 +100,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, ng, err := ngalert.ProvideService( cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil), - secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac, annotationstest.NewFakeAnnotationsRepo(), + secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac, annotationstest.NewFakeAnnotationsRepo(), &plugins.FakePluginStore{}, ) require.NoError(tb, err) return ng, &store.DBstore{ diff --git a/pkg/services/org/orgimpl/org.go b/pkg/services/org/orgimpl/org.go index ed6c3bc506a..030a3476d70 100644 --- a/pkg/services/org/orgimpl/org.go +++ b/pkg/services/org/orgimpl/org.go @@ -2,6 +2,7 @@ package orgimpl import ( "context" + "errors" "fmt" "time" @@ -65,10 +66,12 @@ func (s *Service) GetIDForNewUser(ctx context.Context, cmd org.GetOrgIDForNewUse return cmd.OrgID, nil } - orgName := cmd.OrgName + var orgName string + orgName = cmd.OrgName if len(orgName) == 0 { orgName = util.StringsFallback2(cmd.Email, cmd.Login) } + orga.Name = orgName if setting.AutoAssignOrg { orga, err := s.store.Get(ctx, int64(s.cfg.AutoAssignOrgId)) @@ -142,12 +145,14 @@ func (s *Service) Delete(ctx context.Context, cmd *org.DeleteOrgCommand) error { } func (s *Service) GetOrCreate(ctx context.Context, orgName string) (int64, error) { - var orga *org.Org + var orga = &org.Org{} var err error if s.cfg.AutoAssignOrg { - orga, err = s.store.Get(ctx, int64(s.cfg.AutoAssignOrgId)) - if err != nil { + got, err := s.store.Get(ctx, int64(s.cfg.AutoAssignOrgId)) + if err != nil && !errors.Is(err, org.ErrOrgNotFound) { return 0, err + } else if err == nil { + return got.ID, nil } if s.cfg.AutoAssignOrgId != 1 { @@ -156,11 +161,9 @@ func (s *Service) GetOrCreate(ctx context.Context, orgName string) (int64, error return 0, fmt.Errorf("could not create user: organization ID %d does not exist", s.cfg.AutoAssignOrgId) } - orga.Name = MainOrgName orga.ID = int64(s.cfg.AutoAssignOrgId) } else { - orga = &org.Org{} orga.Name = orgName } diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index 60461b8698c..06a75d217ef 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -14,8 +14,10 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" ) @@ -304,11 +306,12 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) { }) t.Run("GetOrgUsers and UpdateOrgUsers", func(t *testing.T) { ss := db.InitTestDB(t) - ac1cmd := user.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"} - ac2cmd := user.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name", IsAdmin: true} - ac1, err := ss.CreateUser(context.Background(), ac1cmd) + _, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg) + ac1cmd := &user.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"} + ac2cmd := &user.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name", IsAdmin: true} + ac1, err := usrSvc.CreateUserForTests(context.Background(), ac1cmd) require.NoError(t, err) - ac2, err := ss.CreateUser(context.Background(), ac2cmd) + ac2, err := usrSvc.CreateUserForTests(context.Background(), ac2cmd) require.NoError(t, err) cmd := org.AddOrgUserCommand{ OrgID: ac1.OrgID, @@ -412,6 +415,8 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) { t.Run("Given single org and 2 users inserted", func(t *testing.T) { ss = db.InitTestDB(t) + _, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg) + testUser := &user.SignedInUser{ Permissions: map[int64]map[string][]string{ 1: {accesscontrol.ActionOrgUsersRead: []string{accesscontrol.ScopeUsersAll}}, @@ -421,13 +426,13 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) { ss.Cfg.AutoAssignOrgId = 1 ss.Cfg.AutoAssignOrgRole = "Viewer" - ac1cmd := user.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"} - ac2cmd := user.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name"} + ac1cmd := &user.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"} + ac2cmd := &user.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name"} - ac1, err := ss.CreateUser(context.Background(), ac1cmd) + ac1, err := usrSvc.CreateUserForTests(context.Background(), ac1cmd) testUser.OrgID = ac1.OrgID require.NoError(t, err) - _, err = ss.CreateUser(context.Background(), ac2cmd) + _, err = usrSvc.Create(context.Background(), ac2cmd) require.NoError(t, err) t.Run("Can get organization users paginated with query", func(t *testing.T) { @@ -470,20 +475,20 @@ func TestIntegrationSQLStore_AddOrgUser(t *testing.T) { dialect: store.GetDialect(), cfg: setting.NewCfg(), } + _, usrSvc := createOrgAndUserSvc(t, store, store.Cfg) // create org and admin - u, err := store.CreateUser(context.Background(), user.CreateUserCommand{ + u, err := usrSvc.CreateUserForTests(context.Background(), &user.CreateUserCommand{ Login: "admin", }) require.NoError(t, err) // create a service account with no org - sa, err := store.CreateUser(context.Background(), user.CreateUserCommand{ + sa, err := usrSvc.CreateUserForTests(context.Background(), &user.CreateUserCommand{ Login: "sa-no-org", IsServiceAccount: true, SkipOrgSetup: true, }) - require.NoError(t, err) require.Equal(t, int64(-1), sa.OrgID) @@ -599,9 +604,11 @@ func TestIntegration_SQLStore_GetOrgUsers(t *testing.T) { func seedOrgUsers(t *testing.T, orgUserStore store, store *sqlstore.SQLStore, numUsers int) { t.Helper() + _, usrSvc := createOrgAndUserSvc(t, store, store.Cfg) + // Seed users for i := 1; i <= numUsers; i++ { - user, err := store.CreateUser(context.Background(), user.CreateUserCommand{ + user, err := usrSvc.CreateUserForTests(context.Background(), &user.CreateUserCommand{ Login: fmt.Sprintf("user-%d", i), OrgID: 1, }) @@ -633,8 +640,8 @@ func TestIntegration_SQLStore_GetOrgUsers_PopulatesCorrectly(t *testing.T) { } // The millisecond part is not stored in the DB constNow := time.Date(2022, 8, 17, 20, 34, 58, 0, time.UTC) - sqlstore.MockTimeNow(constNow) - defer sqlstore.ResetTimeNow() + userimpl.MockTimeNow(constNow) + defer userimpl.ResetTimeNow() store := db.InitTestDB(t, sqlstore.InitTestDBOpt{}) orgUserStore := sqlStore{ @@ -642,6 +649,7 @@ func TestIntegration_SQLStore_GetOrgUsers_PopulatesCorrectly(t *testing.T) { dialect: store.GetDialect(), cfg: setting.NewCfg(), } + _, usrSvc := createOrgAndUserSvc(t, store, store.Cfg) id, err := orgUserStore.Insert(context.Background(), &org.Org{ @@ -651,7 +659,7 @@ func TestIntegration_SQLStore_GetOrgUsers_PopulatesCorrectly(t *testing.T) { }) require.NoError(t, err) - newUser, err := store.CreateUser(context.Background(), user.CreateUserCommand{ + newUser, err := usrSvc.CreateUserForTests(context.Background(), &user.CreateUserCommand{ Login: "Viewer", Email: "viewer@localhost", OrgID: id, @@ -752,8 +760,6 @@ func TestIntegration_SQLStore_SearchOrgUsers(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { result, err := orgUserStore.SearchOrgUsers(context.Background(), tt.query) - fmt.Println("users:", result) - require.NoError(t, err) assert.Len(t, result.OrgUsers, tt.expectedNumUsers) @@ -776,15 +782,16 @@ func TestIntegration_SQLStore_RemoveOrgUser(t *testing.T) { dialect: store.GetDialect(), cfg: setting.NewCfg(), } + _, usrSvc := createOrgAndUserSvc(t, store, store.Cfg) // create org and admin - _, err := store.CreateUser(context.Background(), user.CreateUserCommand{ + _, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{ Login: "admin", OrgID: 1, }) require.NoError(t, err) // create a user with no org - _, err = store.CreateUser(context.Background(), user.CreateUserCommand{ + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{ Login: "user", OrgID: 1, SkipOrgSetup: true, @@ -807,3 +814,15 @@ func TestIntegration_SQLStore_RemoveOrgUser(t *testing.T) { }) require.NoError(t, err) } + +func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { + t.Helper() + + quotaService := quotaimpl.ProvideService(store, cfg) + orgService, err := ProvideService(store, cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(store, orgService, cfg, nil, nil, quotaService) + require.NoError(t, err) + + return orgService, usrSvc +} diff --git a/pkg/services/org/orgtest/fake.go b/pkg/services/org/orgtest/fake.go index d359d03fb34..3ac6ac57eab 100644 --- a/pkg/services/org/orgtest/fake.go +++ b/pkg/services/org/orgtest/fake.go @@ -75,7 +75,7 @@ func (f *FakeOrgService) Delete(ctx context.Context, cmd *org.DeleteOrgCommand) } func (f *FakeOrgService) GetOrCreate(ctx context.Context, orgName string) (int64, error) { - return 0, f.ExpectedError + return f.ExpectedOrg.ID, f.ExpectedError } func (f *FakeOrgService) AddOrgUser(ctx context.Context, cmd *org.AddOrgUserCommand) error { diff --git a/pkg/services/playlist/playlistimpl/entity_store.go b/pkg/services/playlist/playlistimpl/entity_store.go index 4d44ad85e2a..068302975dc 100644 --- a/pkg/services/playlist/playlistimpl/entity_store.go +++ b/pkg/services/playlist/playlistimpl/entity_store.go @@ -5,10 +5,10 @@ import ( "encoding/json" "fmt" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/sqlstore/session" - objectstore "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/user" ) @@ -44,7 +44,7 @@ func (s *entityStoreImpl) sync() { UserID: 0, // Admin user IsGrafanaAdmin: true, } - ctx := objectstore.ContextWithUser(context.Background(), rowUser) + ctx := appcontext.WithUser(context.Background(), rowUser) for _, info := range results { dto, err := s.sqlimpl.Get(ctx, &playlist.GetPlaylistByUidQuery{ OrgId: info.OrgID, diff --git a/pkg/services/provisioning/alerting/config_reader.go b/pkg/services/provisioning/alerting/config_reader.go index 66eda559df0..489e5b5776b 100644 --- a/pkg/services/provisioning/alerting/config_reader.go +++ b/pkg/services/provisioning/alerting/config_reader.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/infra/log" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type rulesConfigReader struct { diff --git a/pkg/services/provisioning/alerting/contact_point_types_test.go b/pkg/services/provisioning/alerting/contact_point_types_test.go index 3a64bead6ac..34141f0949e 100644 --- a/pkg/services/provisioning/alerting/contact_point_types_test.go +++ b/pkg/services/provisioning/alerting/contact_point_types_test.go @@ -5,7 +5,7 @@ import ( "github.com/grafana/grafana/pkg/services/provisioning/values" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) func TestReceivers(t *testing.T) { diff --git a/pkg/services/provisioning/alerting/notification_policy_types_test.go b/pkg/services/provisioning/alerting/notification_policy_types_test.go index 3538ad23657..d281427e5a6 100644 --- a/pkg/services/provisioning/alerting/notification_policy_types_test.go +++ b/pkg/services/provisioning/alerting/notification_policy_types_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/stretchr/testify/require" ) diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index 47547a6fa70..a8aa1a06b3b 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/provisioning/utils" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type configReader struct { diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 29345dcdd7a..8b8d73a4496 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/datasources" diff --git a/pkg/services/provisioning/notifiers/config_reader.go b/pkg/services/provisioning/notifiers/config_reader.go index e9f1f69354c..944527bfacc 100644 --- a/pkg/services/provisioning/notifiers/config_reader.go +++ b/pkg/services/provisioning/notifiers/config_reader.go @@ -16,7 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/provisioning/utils" "github.com/grafana/grafana/pkg/setting" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type configReader struct { diff --git a/pkg/services/provisioning/plugins/config_reader.go b/pkg/services/provisioning/plugins/config_reader.go index f7e9551a775..13ea247da78 100644 --- a/pkg/services/provisioning/plugins/config_reader.go +++ b/pkg/services/provisioning/plugins/config_reader.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type configReader interface { diff --git a/pkg/services/provisioning/values/values.go b/pkg/services/provisioning/values/values.go index 8d487dbb398..20c0de27b9a 100644 --- a/pkg/services/provisioning/values/values.go +++ b/pkg/services/provisioning/values/values.go @@ -210,8 +210,8 @@ func (val *JSONSliceValue) UnmarshalYAML(unmarshal func(interface{}) error) erro for _, v := range unmarshaled { i := make(map[string]interface{}) r := make(map[string]interface{}) - for key, val := range v.(map[interface{}]interface{}) { - i[key.(string)], r[key.(string)], err = transformInterface(val) + for key, val := range v.(map[string]interface{}) { + i[key], r[key], err = transformInterface(val) if err != nil { return err } @@ -245,7 +245,7 @@ func transformInterface(i interface{}) (interface{}, interface{}, error) { case reflect.Slice: return transformSlice(i.([]interface{})) case reflect.Map: - return transformMap(i.(map[interface{}]interface{})) + return transformMap(i.(map[string]interface{})) case reflect.String: return interpolateValue(i.(string)) default: @@ -268,17 +268,14 @@ func transformSlice(i []interface{}) (interface{}, interface{}, error) { return transformedSlice, rawSlice, nil } -func transformMap(i map[interface{}]interface{}) (interface{}, interface{}, error) { +func transformMap(i map[string]interface{}) (interface{}, interface{}, error) { transformed := make(map[string]interface{}) raw := make(map[string]interface{}) for key, val := range i { - stringKey, ok := key.(string) - if ok { - var err error - transformed[stringKey], raw[stringKey], err = transformInterface(val) - if err != nil { - return nil, nil, err - } + var err error + transformed[key], raw[key], err = transformInterface(val) + if err != nil { + return nil, nil, err } } return transformed, raw, nil diff --git a/pkg/services/provisioning/values/values_test.go b/pkg/services/provisioning/values/values_test.go index 8a3615f21c6..15954efd401 100644 --- a/pkg/services/provisioning/values/values_test.go +++ b/pkg/services/provisioning/values/values_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/ini.v1" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) func TestValues(t *testing.T) { @@ -39,21 +39,23 @@ func TestValues(t *testing.T) { type Data struct { Val IntValue `yaml:"val"` } - d := &Data{} - t.Run("Should unmarshal simple number", func(t *testing.T) { + d := &Data{} + unmarshalingTest(t, `val: 1`, d) require.Equal(t, d.Val.Value(), 1) require.Equal(t, d.Val.Raw, "1") }) t.Run("Should unmarshal env var", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: $INT`, d) require.Equal(t, d.Val.Value(), 1) require.Equal(t, d.Val.Raw, "$INT") }) t.Run("Should ignore empty value", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: `, d) require.Equal(t, d.Val.Value(), 0) require.Equal(t, d.Val.Raw, "") @@ -64,39 +66,43 @@ func TestValues(t *testing.T) { type Data struct { Val StringValue `yaml:"val"` } - d := &Data{} - t.Run("Should unmarshal simple string", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: test`, d) require.Equal(t, d.Val.Value(), "test") require.Equal(t, d.Val.Raw, "test") }) t.Run("Should unmarshal env var", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: $STRING`, d) require.Equal(t, d.Val.Value(), "test") require.Equal(t, d.Val.Raw, "$STRING") }) t.Run("Should ignore empty value", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: `, d) require.Equal(t, d.Val.Value(), "") require.Equal(t, d.Val.Raw, "") }) t.Run("empty var should have empty value", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: $EMPTYSTRING`, d) require.Equal(t, d.Val.Value(), "") require.Equal(t, d.Val.Raw, "$EMPTYSTRING") }) t.Run("$$ should be a literal $", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: $$`, d) require.Equal(t, d.Val.Value(), "$") require.Equal(t, d.Val.Raw, "$$") }) t.Run("$$ should be a literal $ and not expanded within a string", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: mY,Passwo$$rd`, d) require.Equal(t, d.Val.Value(), "mY,Passwo$rd") require.Equal(t, d.Val.Raw, "mY,Passwo$$rd") @@ -107,27 +113,29 @@ func TestValues(t *testing.T) { type Data struct { Val BoolValue `yaml:"val"` } - d := &Data{} - t.Run("Should unmarshal bool value", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: true`, d) require.True(t, d.Val.Value()) require.Equal(t, d.Val.Raw, "true") }) t.Run("Should unmarshal explicit string", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: "true"`, d) require.True(t, d.Val.Value()) require.Equal(t, d.Val.Raw, "true") }) t.Run("Should unmarshal env var", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: $BOOL`, d) require.True(t, d.Val.Value()) require.Equal(t, d.Val.Raw, "$BOOL") }) t.Run("Should ignore empty value", func(t *testing.T) { + d := &Data{} unmarshalingTest(t, `val: `, d) require.False(t, d.Val.Value()) require.Equal(t, d.Val.Raw, "") diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go index 56f71f218a1..2976281b6d8 100644 --- a/pkg/services/queryhistory/queryhistory_test.go +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -17,7 +17,10 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -51,8 +54,12 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Cfg: setting.NewCfg(), store: sqlStore, } - service.Cfg.QueryHistoryEnabled = true + quotaService := quotatest.New(false, nil) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, nil, nil, quotaService) + require.NoError(t, err) usr := user.SignedInUser{ UserID: testUserID, @@ -64,7 +71,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo LastSeenAt: time.Now(), } - _, err := sqlStore.CreateUser(context.Background(), user.CreateUserCommand{ + _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{ Email: "signed.in.user@test.com", Name: "Signed In User", Login: "signed_in_user", diff --git a/pkg/services/quota/model.go b/pkg/services/quota/model.go index 091c60c4f36..c9915bebb0c 100644 --- a/pkg/services/quota/model.go +++ b/pkg/services/quota/model.go @@ -11,7 +11,9 @@ import ( var ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "quota.bad-request") var ErrInvalidTargetSrv = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-target") var ErrInvalidScope = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-scope") +var ErrFailedToGetScope = errutil.NewBase(errutil.StatusInternal, "quota.failed-get-scope") var ErrInvalidTarget = errutil.NewBase(errutil.StatusInternal, "quota.invalid-target-table") +var ErrUsageFoundForTarget = errutil.NewBase(errutil.StatusNotFound, "quota.missing-target-usage") var ErrTargetSrvConflict = errutil.NewBase(errutil.StatusBadRequest, "quota.target-srv-conflict") var ErrDisabled = errutil.NewBase(errutil.StatusForbidden, "quota.disabled", errutil.WithPublicMessage("Quotas not enabled")) var ErrInvalidTagFormat = errutil.NewBase(errutil.StatusInternal, "quota.invalid-invalid-tag-format") diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index f26c066b193..65763f20463 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -2,7 +2,6 @@ package quotaimpl import ( "context" - "fmt" "sync" "github.com/grafana/grafana/pkg/infra/db" @@ -33,7 +32,7 @@ func (s *serviceDisabled) CheckQuotaReached(ctx context.Context, targetSrv quota } func (s *serviceDisabled) DeleteQuotaForUser(ctx context.Context, userID int64) error { - return quota.ErrDisabled + return nil } func (s *serviceDisabled) RegisterQuotaReporter(e *quota.NewUsageReporter) error { @@ -205,9 +204,24 @@ func (s *service) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetS case limit == 0: return true, nil default: + scope, err := t.GetScope() + if err != nil { + return false, quota.ErrFailedToGetScope.Errorf("failed to get the scope for target: %s", t) + } + + // do not check user quota if the user information is not available (eg no user is signed in) + if scope == quota.UserScope && (scopeParams == nil || scopeParams.UserID == 0) { + continue + } + + // do not check user quota if the org information is not available (eg no user is signed in) + if scope == quota.OrgScope && (scopeParams == nil || scopeParams.OrgID == 0) { + continue + } + u, ok := targetUsage.Get(t) if !ok { - return false, fmt.Errorf("no usage for target:%s", t) + return false, quota.ErrUsageFoundForTarget.Errorf("no usage for target:%s", t) } if u >= limit { return true, nil diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 54d5c127cd1..f1943c51432 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -5,10 +5,15 @@ import ( "testing" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "github.com/xorcare/pointer" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/plugins" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" "github.com/grafana/grafana/pkg/services/apikey" @@ -38,9 +43,6 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/require" - "github.com/xorcare/pointer" ) func TestQuotaService(t *testing.T) { @@ -465,7 +467,7 @@ func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quot func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService quota.Service) { _, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) require.NoError(t, err) - _, err = authimpl.ProvideUserAuthTokenService(sqlStore, sqlStore.Cfg, nil, quotaService) + _, err = authimpl.ProvideUserAuthTokenService(sqlStore, nil, nil, featuremgmt.WithFeatures(), quotaService, sqlStore.Cfg) require.NoError(t, err) _, err = dashboardStore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) require.NoError(t, err) @@ -476,7 +478,7 @@ func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService m := metrics.NewNGAlert(prometheus.NewRegistry()) _, err = ngalert.ProvideService( sqlStore.Cfg, &ngalerttests.FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotaService, - secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, annotationstest.NewFakeAnnotationsRepo(), + secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, annotationstest.NewFakeAnnotationsRepo(), &plugins.FakePluginStore{}, ) require.NoError(t, err) _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), sqlStore.Cfg, quotaService, storesrv.ProvideSystemUsersService()) diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 92130e02611..54f8ac7a0ef 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/actest" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" + "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" "github.com/grafana/grafana/pkg/services/licensing" @@ -32,6 +33,7 @@ import ( "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" @@ -46,14 +48,7 @@ var ( func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) - kvStore := kvstore.ProvideService(store) - orgService, err := orgimpl.ProvideService(store, setting.NewCfg(), quotaService) - require.NoError(t, err) - saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, orgService) - svcmock := tests.ServiceAccountMock{} + services := setupTestServices(t, store) autoAssignOrg := store.Cfg.AutoAssignOrg store.Cfg.AutoAssignOrg = true @@ -62,7 +57,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { }() orgCmd := &org.CreateOrgCommand{Name: "Some Test Org"} - _, err = orgService.CreateWithMember(context.Background(), orgCmd) + _, err := services.OrgService.CreateWithMember(context.Background(), orgCmd) require.Nil(t, err) type testCreateSATestCase struct { @@ -167,7 +162,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { serviceAccountRequestScenario(t, http.MethodPost, serviceAccountPath, testUser, func(httpmethod string, endpoint string, usr *tests.TestUser) { - server, api := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore) + server, api := setupTestServer(t, &services.SAService, routing.NewRouteRegister(), tc.acmock, store, services.SAStore) marshalled, err := json.Marshal(tc.body) require.NoError(t, err) @@ -216,12 +211,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { // with permissions and without permissions func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { store := db.InitTestDB(t) - kvStore := kvstore.ProvideService(store) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) - saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) - svcmock := tests.ServiceAccountMock{} + services := setupTestServices(t, store) var requestResponse = func(server *web.Mux, httpMethod, requestpath string) *httptest.ResponseRecorder { req, err := http.NewRequest(httpMethod, requestpath, nil) @@ -249,7 +239,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { } serviceAccountRequestScenario(t, http.MethodDelete, serviceAccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, saStore) + server, _ := setupTestServer(t, &services.SAService, routing.NewRouteRegister(), testcase.acmock, store, services.SAStore) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.ID))).Code require.Equal(t, testcase.expectedCode, actual) }) @@ -273,7 +263,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { } serviceAccountRequestScenario(t, http.MethodDelete, serviceAccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, saStore) + server, _ := setupTestServer(t, &services.SAService, routing.NewRouteRegister(), testcase.acmock, store, services.SAStore) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, createduser.ID)).Code require.Equal(t, testcase.expectedCode, actual) }) @@ -326,12 +316,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) - kvStore := kvstore.ProvideService(store) - saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) - svcmock := tests.ServiceAccountMock{} + services := setupTestServices(t, store) type testRetrieveSATestCase struct { desc string user *tests.TestUser @@ -395,7 +380,7 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) scopeID = int(createdUser.ID) } - server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore) + server, _ := setupTestServer(t, &services.SAService, routing.NewRouteRegister(), tc.acmock, store, services.SAStore) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID)) @@ -420,12 +405,7 @@ func newString(s string) *string { func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) - kvStore := kvstore.ProvideService(store) - saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) - svcmock := tests.ServiceAccountMock{} + services := setupTestServices(t, store) type testUpdateSATestCase struct { desc string user *tests.TestUser @@ -518,7 +498,7 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - server, saAPI := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore) + server, saAPI := setupTestServer(t, &services.SAService, routing.NewRouteRegister(), tc.acmock, store, services.SAStore) scopeID := tc.Id if tc.user != nil { createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) @@ -556,3 +536,33 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { }) } } + +type services struct { + OrgService org.Service + UserService user.Service + SAStore serviceaccounts.Store + SAService tests.ServiceAccountMock + APIKeyService apikey.Service +} + +func setupTestServices(t *testing.T, db *sqlstore.SQLStore) services { + kvStore := kvstore.ProvideService(db) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(db, db.Cfg, quotaService) + require.NoError(t, err) + + orgService, err := orgimpl.ProvideService(db, setting.NewCfg(), quotaService) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(db, orgService, db.Cfg, nil, nil, quotaService) + require.NoError(t, err) + saStore := database.ProvideServiceAccountsStore(db, apiKeyService, kvStore, userSvc, orgService) + svcmock := tests.ServiceAccountMock{} + + return services{ + OrgService: orgService, + UserService: userSvc, + SAStore: saStore, + SAService: svcmock, + APIKeyService: apiKeyService, + } +} diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 90b234d24d2..ca5fda55914 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -18,14 +18,10 @@ import ( "github.com/grafana/grafana/pkg/components/apikeygen" apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apikey" - "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" - "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web" @@ -55,12 +51,7 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string, func TestServiceAccountsAPI_CreateToken(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) - kvStore := kvstore.ProvideService(store) - saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) - svcmock := tests.ServiceAccountMock{} + services := setupTestServices(t, store) sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true}) type testCreateSAToken struct { @@ -140,7 +131,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { bodyString = string(b) } - server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore) + server, _ := setupTestServer(t, &services.SAService, routing.NewRouteRegister(), tc.acmock, store, services.SAStore) actual := requestResponse(server, http.MethodPost, endpoint, strings.NewReader(bodyString)) actualCode := actual.Code @@ -154,7 +145,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { assert.Equal(t, tc.body["name"], actualBody["name"]) query := apikey.GetByNameQuery{KeyName: tc.body["name"].(string), OrgId: sa.OrgID} - err = apiKeyService.GetApiKeyByName(context.Background(), &query) + err = services.APIKeyService.GetApiKeyByName(context.Background(), &query) require.NoError(t, err) assert.Equal(t, sa.ID, *query.Result.ServiceAccountId) @@ -174,12 +165,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { func TestServiceAccountsAPI_DeleteToken(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) - kvStore := kvstore.ProvideService(store) - svcMock := &tests.ServiceAccountMock{} - saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) + services := setupTestServices(t, store) sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true}) type testCreateSAToken struct { @@ -239,11 +225,11 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - token := createTokenforSA(t, saStore, tc.keyName, sa.OrgID, sa.ID, 1) + token := createTokenforSA(t, services.SAStore, tc.keyName, sa.OrgID, sa.ID, 1) endpoint := fmt.Sprintf(serviceaccountIDTokensDetailPath, sa.ID, token.Id) bodyString := "" - server, _ := setupTestServer(t, svcMock, routing.NewRouteRegister(), tc.acmock, store, saStore) + server, _ := setupTestServer(t, &services.SAService, routing.NewRouteRegister(), tc.acmock, store, services.SAStore) actual := requestResponse(server, http.MethodDelete, endpoint, strings.NewReader(bodyString)) actualCode := actual.Code @@ -253,7 +239,7 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { require.Equal(t, tc.expectedCode, actualCode, endpoint, actualBody) query := apikey.GetByNameQuery{KeyName: tc.keyName, OrgId: sa.OrgID} - err := apiKeyService.GetApiKeyByName(context.Background(), &query) + err := services.APIKeyService.GetApiKeyByName(context.Background(), &query) if actualCode == http.StatusOK { require.Error(t, err) } else { diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index 19265645f15..20982a29f42 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -30,13 +30,14 @@ type ServiceAccountsStoreImpl struct { } func ProvideServiceAccountsStore(store *sqlstore.SQLStore, apiKeyService apikey.Service, - kvStore kvstore.KVStore, orgService org.Service) *ServiceAccountsStoreImpl { + kvStore kvstore.KVStore, userService user.Service, orgService org.Service) *ServiceAccountsStoreImpl { return &ServiceAccountsStoreImpl{ sqlStore: store, apiKeyService: apiKeyService, kvStore: kvStore, log: log.New("serviceaccounts.store"), orgService: orgService, + userService: userService, } } @@ -55,7 +56,7 @@ func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, org var newSA *user.User createErr := s.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) (err error) { var errUser error - newSA, errUser = s.sqlStore.CreateUser(ctx, user.CreateUserCommand{ + newSA, errUser = s.userService.CreateServiceAccount(ctx, &user.CreateUserCommand{ Login: generatedLogin, OrgID: orgId, Name: saForm.Name, @@ -461,7 +462,7 @@ func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Co } return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - newSA, errCreateSA := s.sqlStore.CreateUser(ctx, cmd) + newSA, errCreateSA := s.userService.CreateServiceAccount(ctx, &cmd) if errCreateSA != nil { return fmt.Errorf("failed to create service account: %w", errCreateSA) } diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index 268f6a678c0..41084066d4c 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" ) @@ -118,7 +119,9 @@ func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreI kvStore := kvstore.ProvideService(db) orgService, err := orgimpl.ProvideService(db, setting.NewCfg(), quotaService) require.NoError(t, err) - return db, ProvideServiceAccountsStore(db, apiKeyService, kvStore, orgService) + userSvc, err := userimpl.ProvideService(db, orgService, db.Cfg, nil, nil, quotaService) + require.NoError(t, err) + return db, ProvideServiceAccountsStore(db, apiKeyService, kvStore, userSvc, orgService) } func TestStore_RetrieveServiceAccount(t *testing.T) { diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 4365fcd52af..635e467df8d 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -55,10 +55,9 @@ func ProvideServiceAccountsService( serviceaccountsAPI.RegisterAPIEndpoints() s.secretScanEnabled = cfg.SectionWithEnvOverrides("secretscan").Key("enabled").MustBool(false) + s.secretScanInterval = cfg.SectionWithEnvOverrides("secretscan"). + Key("interval").MustDuration(defaultSecretScanInterval) if s.secretScanEnabled { - s.secretScanInterval = cfg.SectionWithEnvOverrides("secretscan"). - Key("interval").MustDuration(defaultSecretScanInterval) - s.secretScanService = secretscan.NewService(s.store, cfg) } @@ -76,7 +75,7 @@ func (sa *ServiceAccountsService) Run(ctx context.Context) error { defer updateStatsTicker.Stop() // Enforce a minimum interval of 1 minute. - if sa.secretScanInterval < time.Minute { + if sa.secretScanEnabled && sa.secretScanInterval < time.Minute { sa.backgroundLog.Warn("secret scan interval is too low, increasing to " + defaultSecretScanInterval.String()) diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index d8b5dea247b..232cfc8da8b 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -12,10 +12,13 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) type TestUser struct { @@ -41,7 +44,13 @@ func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser role = testUser.Role } - u1, err := sqlStore.CreateUser(context.Background(), user.CreateUserCommand{ + quotaService := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, quotaService) + require.NoError(t, err) + + u1, err := usrSvc.CreateUserForTests(context.Background(), &user.CreateUserCommand{ Login: testUser.Login, IsServiceAccount: testUser.IsServiceAccount, DefaultOrgRole: role, diff --git a/pkg/services/sqlstore/health_test.go b/pkg/services/sqlstore/health_test.go index 4873ec37fbf..2865a4302b8 100644 --- a/pkg/services/sqlstore/health_test.go +++ b/pkg/services/sqlstore/health_test.go @@ -4,8 +4,9 @@ import ( "context" "testing" - "github.com/grafana/grafana/pkg/models" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" ) func TestIntegrationGetDBHealthQuery(t *testing.T) { diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go deleted file mode 100644 index 52b970f9128..00000000000 --- a/pkg/services/sqlstore/org.go +++ /dev/null @@ -1,70 +0,0 @@ -// DO NOT ADD METHODS TO THIS FILES. SQLSTORE IS DEPRECATED AND WILL BE REMOVED. -package sqlstore - -import ( - "fmt" - "time" - - "github.com/grafana/grafana/pkg/events" - "github.com/grafana/grafana/pkg/models" -) - -const mainOrgName = "Main Org." - -func verifyExistingOrg(sess *DBSession, orgId int64) error { - var org models.Org - has, err := sess.Where("id=?", orgId).Get(&org) - if err != nil { - return err - } - if !has { - return models.ErrOrgNotFound - } - return nil -} - -func (ss *SQLStore) getOrCreateOrg(sess *DBSession, orgName string) (int64, error) { - var org models.Org - if ss.Cfg.AutoAssignOrg { - has, err := sess.Where("id=?", ss.Cfg.AutoAssignOrgId).Get(&org) - if err != nil { - return 0, err - } - if has { - return org.Id, nil - } - - if ss.Cfg.AutoAssignOrgId != 1 { - ss.log.Error("Could not create user: organization ID does not exist", "orgID", - ss.Cfg.AutoAssignOrgId) - return 0, fmt.Errorf("could not create user: organization ID %d does not exist", - ss.Cfg.AutoAssignOrgId) - } - - org.Name = mainOrgName - org.Id = int64(ss.Cfg.AutoAssignOrgId) - } else { - org.Name = orgName - } - - org.Created = time.Now() - org.Updated = time.Now() - - if org.Id != 0 { - if _, err := sess.InsertId(&org); err != nil { - return 0, err - } - } else { - if _, err := sess.InsertOne(&org); err != nil { - return 0, err - } - } - - sess.publishAfterCommit(&events.OrgCreated{ - Timestamp: org.Created, - Id: org.Id, - Name: org.Name, - }) - - return org.Id, nil -} diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go deleted file mode 100644 index c348e404fb2..00000000000 --- a/pkg/services/sqlstore/org_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package sqlstore - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" - dashver "github.com/grafana/grafana/pkg/services/dashboardversion" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/util" -) - -func TestIntegrationAccountDataAccess(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - t.Run("Testing Account DB Access", func(t *testing.T) { - sqlStore := InitTestDB(t) - - t.Run("Given single org mode", func(t *testing.T) { - sqlStore.Cfg.AutoAssignOrg = true - sqlStore.Cfg.AutoAssignOrgId = 1 - sqlStore.Cfg.AutoAssignOrgRole = "Viewer" - - t.Run("Users should be added to default organization", func(t *testing.T) { - ac1cmd := user.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"} - ac2cmd := user.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name"} - - ac1, err := sqlStore.CreateUser(context.Background(), ac1cmd) - require.NoError(t, err) - ac2, err := sqlStore.CreateUser(context.Background(), ac2cmd) - require.NoError(t, err) - - q1 := models.GetUserOrgListQuery{UserId: ac1.ID} - q2 := models.GetUserOrgListQuery{UserId: ac2.ID} - err = sqlStore.getUserOrgList(context.Background(), &q1) - require.NoError(t, err) - err = sqlStore.getUserOrgList(context.Background(), &q2) - require.NoError(t, err) - - require.Equal(t, q1.Result[0].OrgId, q2.Result[0].OrgId) - require.Equal(t, string(q1.Result[0].Role), "Viewer") - }) - }) - - t.Run("Given two saved users", func(t *testing.T) { - sqlStore = InitTestDB(t) - sqlStore.Cfg.AutoAssignOrg = false - - ac1cmd := user.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"} - ac2cmd := user.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name", IsAdmin: true} - serviceaccountcmd := user.CreateUserCommand{Login: "serviceaccount", Email: "service@test.com", Name: "serviceaccount name", IsAdmin: true, IsServiceAccount: true} - - ac1, err := sqlStore.CreateUser(context.Background(), ac1cmd) - require.NoError(t, err) - ac2, err := sqlStore.CreateUser(context.Background(), ac2cmd) - require.NoError(t, err) - // user only used for making sure we filter out the service accounts - _, err = sqlStore.CreateUser(context.Background(), serviceaccountcmd) - require.NoError(t, err) - - t.Run("Given an added org user", func(t *testing.T) { - cmd := models.AddOrgUserCommand{ - OrgId: ac1.OrgID, - UserId: ac2.ID, - Role: org.RoleViewer, - } - - err := sqlStore.addOrgUser(context.Background(), &cmd) - t.Run("Should have been saved without error", func(t *testing.T) { - require.NoError(t, err) - }) - - t.Run("Can get user organizations", func(t *testing.T) { - query := models.GetUserOrgListQuery{UserId: ac2.ID} - err := sqlStore.getUserOrgList(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, len(query.Result), 2) - }) - - t.Run("Given an org user with dashboard permissions", func(t *testing.T) { - ac3cmd := user.CreateUserCommand{Login: "ac3", Email: "ac3@test.com", Name: "ac3 name", IsAdmin: false} - ac3, err := sqlStore.CreateUser(context.Background(), ac3cmd) - require.NoError(t, err) - - orgUserCmd := models.AddOrgUserCommand{ - OrgId: ac1.OrgID, - UserId: ac3.ID, - Role: org.RoleViewer, - } - - err = sqlStore.addOrgUser(context.Background(), &orgUserCmd) - require.NoError(t, err) - - dash1 := insertTestDashboard(t, sqlStore, "1 test dash", ac1.OrgID, 0, false, "prod", "webapp") - dash2 := insertTestDashboard(t, sqlStore, "2 test dash", ac3.OrgID, 0, false, "prod", "webapp") - - err = updateDashboardACL(t, sqlStore, dash1.Id, &models.DashboardACL{ - DashboardID: dash1.Id, OrgID: ac1.OrgID, UserID: ac3.ID, Permission: models.PERMISSION_EDIT, - }) - require.NoError(t, err) - - err = updateDashboardACL(t, sqlStore, dash2.Id, &models.DashboardACL{ - DashboardID: dash2.Id, OrgID: ac3.OrgID, UserID: ac3.ID, Permission: models.PERMISSION_EDIT, - }) - require.NoError(t, err) - }) - }) - }) - }) -} - -// TODO: Use FakeDashboardStore when org has its own service -func insertTestDashboard(t *testing.T, sqlStore *SQLStore, title string, orgId int64, - folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { - t.Helper() - cmd := models.SaveDashboardCommand{ - OrgId: orgId, - FolderId: folderId, - IsFolder: isFolder, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": title, - "tags": tags, - }), - } - - var dash *models.Dashboard - err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { - dash = cmd.GetDashboardModel() - dash.SetVersion(1) - dash.Created = time.Now() - dash.Updated = time.Now() - dash.Uid = util.GenerateShortUID() - _, err := sess.Insert(dash) - return err - }) - - require.NoError(t, err) - require.NotNil(t, dash) - dash.Data.Set("id", dash.Id) - dash.Data.Set("uid", dash.Uid) - - err = sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { - dashVersion := &dashver.DashboardVersion{ - DashboardID: dash.Id, - ParentVersion: dash.Version, - RestoredFrom: cmd.RestoredFrom, - Version: dash.Version, - Created: time.Now(), - CreatedBy: dash.UpdatedBy, - Message: cmd.Message, - Data: dash.Data, - } - require.NoError(t, err) - - if affectedRows, err := sess.Insert(dashVersion); err != nil { - return err - } else if affectedRows == 0 { - return dashboards.ErrDashboardNotFound - } - - return nil - }) - require.NoError(t, err) - - return dash -} - -// TODO: Use FakeDashboardStore when org has its own service -func updateDashboardACL(t *testing.T, sqlStore *SQLStore, dashboardID int64, items ...*models.DashboardACL) error { - t.Helper() - - err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { - _, err := sess.Exec("DELETE FROM dashboard_acl WHERE dashboard_id=?", dashboardID) - if err != nil { - return fmt.Errorf("deleting from dashboard_acl failed: %w", err) - } - - for _, item := range items { - item.Created = time.Now() - item.Updated = time.Now() - if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return models.ErrDashboardACLInfoMissing - } - - if item.DashboardID == 0 { - return models.ErrDashboardPermissionDashboardEmpty - } - - sess.Nullable("user_id", "team_id") - if _, err := sess.Insert(item); err != nil { - return err - } - } - - // Update dashboard HasACL flag - dashboard := models.Dashboard{HasACL: true} - _, err = sess.Cols("has_acl").Where("id=?", dashboardID).Update(&dashboard) - return err - }) - return err -} diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go deleted file mode 100644 index d15870fefb8..00000000000 --- a/pkg/services/sqlstore/org_users.go +++ /dev/null @@ -1,68 +0,0 @@ -package sqlstore - -import ( - "context" - "time" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/user" -) - -func (ss *SQLStore) addOrgUser(ctx context.Context, cmd *models.AddOrgUserCommand) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - // check if user exists - var usr user.User - session := sess.ID(cmd.UserId) - if !cmd.AllowAddingServiceAccount { - session = session.Where(notServiceAccountFilter(ss)) - } - - if exists, err := session.Get(&usr); err != nil { - return err - } else if !exists { - return user.ErrUserNotFound - } - - if res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and user_id=?", cmd.OrgId, usr.ID); err != nil { - return err - } else if len(res) == 1 { - return models.ErrOrgUserAlreadyAdded - } - - if res, err := sess.Query("SELECT 1 from org WHERE id=?", cmd.OrgId); err != nil { - return err - } else if len(res) != 1 { - return models.ErrOrgNotFound - } - - entity := models.OrgUser{ - OrgId: cmd.OrgId, - UserId: cmd.UserId, - Role: cmd.Role, - Created: time.Now(), - Updated: time.Now(), - } - - _, err := sess.Insert(&entity) - if err != nil { - return err - } - - var userOrgs []*models.UserOrgDTO - sess.Table("org_user") - sess.Join("INNER", "org", "org_user.org_id=org.id") - sess.Where("org_user.user_id=? AND org_user.org_id=?", usr.ID, usr.OrgID) - sess.Cols("org.name", "org_user.role", "org_user.org_id") - err = sess.Find(&userOrgs) - - if err != nil { - return err - } - - if len(userOrgs) == 0 { - return setUsingOrgInTransaction(sess, usr.ID, cmd.OrgId) - } - - return nil - }) -} diff --git a/pkg/services/sqlstore/org_users_test.go b/pkg/services/sqlstore/org_users_test.go deleted file mode 100644 index 7528e679805..00000000000 --- a/pkg/services/sqlstore/org_users_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package sqlstore - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/user" -) - -func TestSQLStore_AddOrgUser(t *testing.T) { - var orgID int64 = 1 - store := InitTestDB(t) - - // create org and admin - _, err := store.CreateUser(context.Background(), user.CreateUserCommand{ - Login: "admin", - OrgID: orgID, - }) - require.NoError(t, err) - - // create a service account with no org - sa, err := store.CreateUser(context.Background(), user.CreateUserCommand{ - Login: "sa-no-org", - IsServiceAccount: true, - SkipOrgSetup: true, - }) - - require.NoError(t, err) - require.Equal(t, int64(-1), sa.OrgID) - - // assign the sa to the org but without the override. should fail - err = store.addOrgUser(context.Background(), &models.AddOrgUserCommand{ - Role: "Viewer", - OrgId: orgID, - UserId: sa.ID, - }) - require.Error(t, err) - - // assign the sa to the org with the override. should succeed - err = store.addOrgUser(context.Background(), &models.AddOrgUserCommand{ - Role: "Viewer", - OrgId: orgID, - UserId: sa.ID, - AllowAddingServiceAccount: true, - }) - - require.NoError(t, err) - - // assert the org has been correctly set - saFound := new(user.User) - err = store.WithDbSession(context.Background(), func(sess *DBSession) error { - has, err := sess.ID(sa.ID).Get(saFound) - if err != nil { - return err - } else if !has { - return user.ErrUserNotFound - } - return nil - }) - - require.NoError(t, err) - require.Equal(t, saFound.OrgID, orgID) -} diff --git a/pkg/services/sqlstore/sqlbuilder_test.go b/pkg/services/sqlstore/sqlbuilder_test.go index d9a56aab581..8d75c369cf7 100644 --- a/pkg/services/sqlstore/sqlbuilder_test.go +++ b/pkg/services/sqlstore/sqlbuilder_test.go @@ -2,17 +2,22 @@ package sqlstore import ( "context" + "fmt" "math/rand" "strconv" "testing" "time" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" ) func TestIntegrationSQLBuilder(t *testing.T) { @@ -196,22 +201,28 @@ func createDummyUser(t *testing.T, sqlStore *SQLStore) *user.User { t.Helper() uid := strconv.Itoa(rand.Intn(9999999)) - createUserCmd := user.CreateUserCommand{ - Email: uid + "@example.com", - Login: uid, - Name: uid, - Company: "", - OrgName: "", - Password: uid, - EmailVerified: true, - IsAdmin: false, - SkipOrgSetup: false, - DefaultOrgRole: string(org.RoleViewer), + usr := &user.User{ + Email: uid + "@example.com", + Login: uid, + Name: uid, + Company: "", + Password: uid, + EmailVerified: true, + IsAdmin: false, + Created: time.Now(), + Updated: time.Now(), } - user, err := sqlStore.CreateUser(context.Background(), createUserCmd) - require.NoError(t, err) - return user + var id int64 + err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + sess.UseBool("is_admin") + var err error + id, err = sess.Insert(usr) + return err + }) + require.NoError(t, err) + usr.ID = id + return usr } func createDummyDashboard(t *testing.T, sqlStore *SQLStore, dashboardProps DashboardProps) *models.Dashboard { @@ -317,3 +328,95 @@ func getDashboards(t *testing.T, sqlStore *SQLStore, search Search, aclUserID in require.NoError(t, err) return res } + +// TODO: Use FakeDashboardStore when org has its own service +func insertTestDashboard(t *testing.T, sqlStore *SQLStore, title string, orgId int64, + folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { + t.Helper() + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + + var dash *models.Dashboard + err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + dash = cmd.GetDashboardModel() + dash.SetVersion(1) + dash.Created = time.Now() + dash.Updated = time.Now() + dash.Uid = util.GenerateShortUID() + _, err := sess.Insert(dash) + return err + }) + + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.Id) + dash.Data.Set("uid", dash.Uid) + + err = sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + dashVersion := &dashver.DashboardVersion{ + DashboardID: dash.Id, + ParentVersion: dash.Version, + RestoredFrom: cmd.RestoredFrom, + Version: dash.Version, + Created: time.Now(), + CreatedBy: dash.UpdatedBy, + Message: cmd.Message, + Data: dash.Data, + } + require.NoError(t, err) + + if affectedRows, err := sess.Insert(dashVersion); err != nil { + return err + } else if affectedRows == 0 { + return dashboards.ErrDashboardNotFound + } + + return nil + }) + require.NoError(t, err) + + return dash +} + +// TODO: Use FakeDashboardStore when org has its own service +func updateDashboardACL(t *testing.T, sqlStore *SQLStore, dashboardID int64, items ...*models.DashboardACL) error { + t.Helper() + + err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + _, err := sess.Exec("DELETE FROM dashboard_acl WHERE dashboard_id=?", dashboardID) + if err != nil { + return fmt.Errorf("deleting from dashboard_acl failed: %w", err) + } + + for _, item := range items { + item.Created = time.Now() + item.Updated = time.Now() + if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { + return models.ErrDashboardACLInfoMissing + } + + if item.DashboardID == 0 { + return models.ErrDashboardPermissionDashboardEmpty + } + + sess.Nullable("user_id", "team_id") + if _, err := sess.Insert(item); err != nil { + return err + } + } + + // Update dashboard HasACL flag + dashboard := models.Dashboard{HasACL: true} + _, err = sess.Cols("has_acl").Where("id=?", dashboardID).Update(&dashboard) + return err + }) + return err +} diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 4dc3291164f..bbe0a39a8c7 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -198,7 +198,6 @@ func (ss *SQLStore) ensureMainOrgAndAdminUser() error { if _, err := sess.SQL(rawSQL).Get(&stats); err != nil { return fmt.Errorf("could not determine if admin user exists: %w", err) } - if stats.Count > 0 { return nil } @@ -206,6 +205,7 @@ func (ss *SQLStore) ensureMainOrgAndAdminUser() error { // ensure admin user if !ss.Cfg.DisableInitAdminCreation { ss.log.Debug("Creating default admin user") + if _, err := ss.createUser(ctx, sess, user.CreateUserCommand{ Login: ss.Cfg.AdminUser, Email: ss.Cfg.AdminEmail, @@ -216,9 +216,6 @@ func (ss *SQLStore) ensureMainOrgAndAdminUser() error { } ss.log.Info("Created default admin", "user", ss.Cfg.AdminUser) - // Why should we return and not create the default org in this case? - // Returning here breaks tests using anonymous access - // return nil } ss.log.Debug("Creating default org", "name", mainOrgName) diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 77dfe576391..747af3a8aa7 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -8,13 +8,11 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/session" - "github.com/grafana/grafana/pkg/services/user" ) type Store interface { GetDialect() migrator.Dialect GetDBType() core.DbType - CreateUser(ctx context.Context, cmd user.CreateUserCommand) (*user.User, error) WithDbSession(ctx context.Context, callback DBTransactionFunc) error WithNewDbSession(ctx context.Context, callback DBTransactionFunc) error WithTransactionalDbSession(ctx context.Context, callback DBTransactionFunc) error diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index b30766fd6c8..b11cdb37558 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -4,8 +4,8 @@ package sqlstore import ( "context" "fmt" - "sort" "strings" + "time" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" @@ -14,6 +14,8 @@ import ( "github.com/grafana/grafana/pkg/util" ) +const mainOrgName = "Main Org." + func (ss *SQLStore) getOrgIDForNewUser(sess *DBSession, args user.CreateUserCommand) (int64, error) { if ss.Cfg.AutoAssignOrg && args.OrgID != 0 { if err := verifyExistingOrg(sess, args.OrgID); err != nil { @@ -30,21 +32,19 @@ func (ss *SQLStore) getOrgIDForNewUser(sess *DBSession, args user.CreateUserComm return ss.getOrCreateOrg(sess, orgName) } -// createUser creates a user in the database -// if autoAssignOrg is enabled then args.OrgID will be used -// to add to an existing Org with id=args.OrgID -// if autoAssignOrg is disabled then args.OrgName will be used -// to create a new Org with name=args.OrgName. -// If a org already exists with that name, it will error +// createUser creates a user in the database. It will also create a default +// organization, if none exists. This should only be used by the sqlstore +// Reset() function. +// +// If AutoAssignOrg is enabled then args.OrgID will be used to add to an +// existing Org with id=args.OrgID. If AutoAssignOrg is disabled then +// args.OrgName will be used to create a new Org with name=args.OrgName. If an +// org already exists with that name, it will error. func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args user.CreateUserCommand) (user.User, error) { var usr user.User - var orgID int64 = -1 - if !args.SkipOrgSetup { - var err error - orgID, err = ss.getOrgIDForNewUser(sess, args) - if err != nil { - return usr, err - } + orgID, err := ss.getOrgIDForNewUser(sess, args) + if err != nil { + return usr, err } if args.Email == "" { @@ -68,18 +68,13 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args user.C // create user usr = user.User{ - Email: args.Email, - Name: args.Name, - Login: args.Login, - Company: args.Company, - IsAdmin: args.IsAdmin, - IsDisabled: args.IsDisabled, - OrgID: orgID, - EmailVerified: args.EmailVerified, - Created: TimeNow(), - Updated: TimeNow(), - LastSeenAt: TimeNow().AddDate(-10, 0, 0), - IsServiceAccount: args.IsServiceAccount, + Email: args.Email, + Login: args.Login, + IsAdmin: args.IsAdmin, + OrgID: orgID, + Created: TimeNow(), + Updated: TimeNow(), + LastSeenAt: TimeNow().AddDate(-10, 0, 0), } salt, err := util.GetRandomString(10) @@ -115,95 +110,29 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args user.C Email: usr.Email, }) - // create org user link - if !args.SkipOrgSetup { - orgUser := models.OrgUser{ - OrgId: orgID, - UserId: usr.ID, - Role: org.RoleAdmin, - Created: TimeNow(), - Updated: TimeNow(), - } + orgUser := models.OrgUser{ + OrgId: orgID, + UserId: usr.ID, + Role: org.RoleAdmin, + Created: TimeNow(), + Updated: TimeNow(), + } - if ss.Cfg.AutoAssignOrg && !usr.IsAdmin { - if len(args.DefaultOrgRole) > 0 { - orgUser.Role = org.RoleType(args.DefaultOrgRole) - } else { - orgUser.Role = org.RoleType(ss.Cfg.AutoAssignOrgRole) - } + if ss.Cfg.AutoAssignOrg && !usr.IsAdmin { + if len(args.DefaultOrgRole) > 0 { + orgUser.Role = org.RoleType(args.DefaultOrgRole) + } else { + orgUser.Role = org.RoleType(ss.Cfg.AutoAssignOrgRole) } + } - if _, err = sess.Insert(&orgUser); err != nil { - return usr, err - } + if _, err = sess.Insert(&orgUser); err != nil { + return usr, err } return usr, nil } -// deprecated method, use only for tests -func (ss *SQLStore) CreateUser(ctx context.Context, cmd user.CreateUserCommand) (*user.User, error) { - var user user.User - createErr := ss.WithTransactionalDbSession(ctx, func(sess *DBSession) (err error) { - user, err = ss.createUser(ctx, sess, cmd) - return - }) - return &user, createErr -} - -func notServiceAccountFilter(ss *SQLStore) string { - return fmt.Sprintf("%s.is_service_account = %s", - ss.Dialect.Quote("user"), - ss.Dialect.BooleanStr(false)) -} - -func setUsingOrgInTransaction(sess *DBSession, userID int64, orgID int64) error { - user := user.User{ - ID: userID, - OrgID: orgID, - } - - _, err := sess.ID(userID).Update(&user) - return err -} - -type byOrgName []*models.UserOrgDTO - -// Len returns the length of an array of organisations. -func (o byOrgName) Len() int { - return len(o) -} - -// Swap swaps two indices of an array of organizations. -func (o byOrgName) Swap(i, j int) { - o[i], o[j] = o[j], o[i] -} - -// Less returns whether element i of an array of organizations is less than element j. -func (o byOrgName) Less(i, j int) bool { - if strings.ToLower(o[i].Name) < strings.ToLower(o[j].Name) { - return true - } - - return o[i].Name < o[j].Name -} - -func (ss *SQLStore) getUserOrgList(ctx context.Context, query *models.GetUserOrgListQuery) error { - return ss.WithDbSession(ctx, func(dbSess *DBSession) error { - query.Result = make([]*models.UserOrgDTO, 0) - sess := dbSess.Table("org_user") - sess.Join("INNER", "org", "org_user.org_id=org.id") - sess.Join("INNER", ss.Dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.Dialect.Quote("user"))) - sess.Where("org_user.user_id=?", query.UserId) - sess.Where(notServiceAccountFilter(ss)) - sess.Cols("org.name", "org_user.role", "org_user.org_id") - sess.OrderBy("org.name") - err := sess.Find(&query.Result) - sort.Sort(byOrgName(query.Result)) - return err - }) -} - func UserDeletions() []string { deletes := []string{ "DELETE FROM star WHERE user_id = ?", @@ -218,3 +147,61 @@ func UserDeletions() []string { } return deletes } + +func verifyExistingOrg(sess *DBSession, orgId int64) error { + var org models.Org + has, err := sess.Where("id=?", orgId).Get(&org) + if err != nil { + return err + } + if !has { + return models.ErrOrgNotFound + } + return nil +} + +func (ss *SQLStore) getOrCreateOrg(sess *DBSession, orgName string) (int64, error) { + var org models.Org + if ss.Cfg.AutoAssignOrg { + has, err := sess.Where("id=?", ss.Cfg.AutoAssignOrgId).Get(&org) + if err != nil { + return 0, err + } + if has { + return org.Id, nil + } + + if ss.Cfg.AutoAssignOrgId != 1 { + ss.log.Error("Could not create user: organization ID does not exist", "orgID", + ss.Cfg.AutoAssignOrgId) + return 0, fmt.Errorf("could not create user: organization ID %d does not exist", + ss.Cfg.AutoAssignOrgId) + } + + org.Name = mainOrgName + org.Id = int64(ss.Cfg.AutoAssignOrgId) + } else { + org.Name = orgName + } + + org.Created = time.Now() + org.Updated = time.Now() + + if org.Id != 0 { + if _, err := sess.InsertId(&org); err != nil { + return 0, err + } + } else { + if _, err := sess.InsertOne(&org); err != nil { + return 0, err + } + } + + sess.publishAfterCommit(&events.OrgCreated{ + Timestamp: org.Created, + Id: org.Id, + Name: org.Name, + }) + + return org.Id, nil +} diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index 1017f870b35..938bf16992d 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -5,14 +5,16 @@ import ( "fmt" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) func TestIntegrationStatsDataAccess(t *testing.T) { @@ -71,6 +73,7 @@ func populateDB(t *testing.T, sqlStore *sqlstore.SQLStore) { t.Helper() orgService, _ := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) + userSvc, _ := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, "atest.FakeQuotaService{}) users := make([]user.User, 3) for i := range users { @@ -80,7 +83,7 @@ func populateDB(t *testing.T, sqlStore *sqlstore.SQLStore) { Login: fmt.Sprintf("user_test_%v_login", i), OrgName: fmt.Sprintf("Org #%v", i), } - user, err := sqlStore.CreateUser(context.Background(), cmd) + user, err := userSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) users[i] = *user } diff --git a/pkg/services/store/auth.go b/pkg/services/store/auth.go index b9d7cb8bf9b..6ef47fe94f9 100644 --- a/pkg/services/store/auth.go +++ b/pkg/services/store/auth.go @@ -1,44 +1,11 @@ package store import ( - "context" "fmt" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" - grpccontext "github.com/grafana/grafana/pkg/services/grpcserver/context" "github.com/grafana/grafana/pkg/services/user" ) -type testUserKey struct{} - -func ContextWithUser(ctx context.Context, data *user.SignedInUser) context.Context { - return context.WithValue(ctx, testUserKey{}, data) -} - -// UserFromContext ** Experimental ** -// TODO: move to global infra package / new auth service -func UserFromContext(ctx context.Context) *user.SignedInUser { - grpcCtx := grpccontext.FromContext(ctx) - if grpcCtx != nil { - return grpcCtx.SignedInUser - } - - // Explicitly set in context - u, ok := ctx.Value(testUserKey{}).(*user.SignedInUser) - if ok && u != nil { - return u - } - - // From the HTTP request - c, ok := ctxkey.Get(ctx).(*models.ReqContext) - if !ok || c == nil || c.SignedInUser == nil { - return nil - } - - return c.SignedInUser -} - // Really just spitballing here :) this should hook into a system that can give better display info func GetUserIDString(user *user.SignedInUser) string { if user == nil { diff --git a/pkg/services/store/entity/sqlstash/querybuilder.go b/pkg/services/store/entity/sqlstash/querybuilder.go index ac13956f2af..4f4ed45f4eb 100644 --- a/pkg/services/store/entity/sqlstash/querybuilder.go +++ b/pkg/services/store/entity/sqlstash/querybuilder.go @@ -17,6 +17,11 @@ func (q *selectQuery) addWhere(f string, val interface{}) { q.where = append(q.where, f+"=?") } +func (q *selectQuery) addWhereInSubquery(f string, subquery string, subqueryArgs []interface{}) { + q.args = append(q.args, subqueryArgs...) + q.where = append(q.where, f+" IN ("+subquery+")") +} + func (q *selectQuery) addWhereIn(f string, vals []string) { count := len(vals) if count > 1 { diff --git a/pkg/services/store/entity/sqlstash/sql_storage_server.go b/pkg/services/store/entity/sqlstash/sql_storage_server.go index 022dd9eb938..132f47c3428 100644 --- a/pkg/services/store/entity/sqlstash/sql_storage_server.go +++ b/pkg/services/store/entity/sqlstash/sql_storage_server.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/slugify" @@ -109,7 +110,10 @@ func (s *sqlEntityServer) validateGRN(ctx context.Context, grn *entity.GRN) (*en if grn == nil { return nil, fmt.Errorf("missing GRN") } - user := store.UserFromContext(ctx) + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } if grn.TenantId == 0 { grn.TenantId = user.OrgID } else if grn.TenantId != user.OrgID { @@ -281,7 +285,10 @@ func (s *sqlEntityServer) AdminWrite(ctx context.Context, r *entity.AdminWriteEn updatedAt := r.UpdatedAt updatedBy := r.UpdatedBy if updatedBy == "" { - modifier := store.UserFromContext(ctx) + modifier, err := appcontext.User(ctx) + if err != nil { + return nil, err + } if modifier == nil { return nil, fmt.Errorf("can not find user in context") } @@ -618,12 +625,15 @@ func (s *sqlEntityServer) History(ctx context.Context, r *entity.EntityHistoryRe } func (s *sqlEntityServer) Search(ctx context.Context, r *entity.EntitySearchRequest) (*entity.EntitySearchResponse, error) { - user := store.UserFromContext(ctx) + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } if user == nil { return nil, fmt.Errorf("missing user in context") } - if r.NextPageToken != "" || len(r.Sort) > 0 || len(r.Labels) > 0 { + if r.NextPageToken != "" || len(r.Sort) > 0 { return nil, fmt.Errorf("not yet supported") } @@ -637,6 +647,7 @@ func (s *sqlEntityServer) Search(ctx context.Context, r *entity.EntitySearchRequ if r.WithBody { fields = append(fields, "body") } + if r.WithLabels { fields = append(fields, "labels") } @@ -644,25 +655,40 @@ func (s *sqlEntityServer) Search(ctx context.Context, r *entity.EntitySearchRequ fields = append(fields, "fields") } - selectQuery := selectQuery{ + entityQuery := selectQuery{ fields: fields, from: "entity", // the table args: []interface{}{}, limit: int(r.Limit), oneExtra: true, // request one more than the limit (and show next token if it exists) } - selectQuery.addWhere("tenant_id", user.OrgID) + entityQuery.addWhere("tenant_id", user.OrgID) if len(r.Kind) > 0 { - selectQuery.addWhereIn("kind", r.Kind) + entityQuery.addWhereIn("kind", r.Kind) } // Folder UID or OID? if r.Folder != "" { - selectQuery.addWhere("folder", r.Folder) + entityQuery.addWhere("folder", r.Folder) } - query, args := selectQuery.toQuery() + if len(r.Labels) > 0 { + var args []interface{} + var conditions []string + for labelKey, labelValue := range r.Labels { + args = append(args, labelKey) + args = append(args, labelValue) + conditions = append(conditions, "(label = ? AND value = ?)") + } + joinedConditions := strings.Join(conditions, " OR ") + query := "SELECT grn FROM entity_labels WHERE " + joinedConditions + " GROUP BY grn HAVING COUNT(label) = ?" + args = append(args, len(r.Labels)) + + entityQuery.addWhereInSubquery("grn", query, args) + } + + query, args := entityQuery.toQuery() fmt.Printf("\n\n-------------\n") fmt.Printf("%s\n", query) @@ -704,7 +730,7 @@ func (s *sqlEntityServer) Search(ctx context.Context, r *entity.EntitySearchRequ } // found one more than requested - if len(rsp.Results) >= selectQuery.limit { + if len(rsp.Results) >= entityQuery.limit { // TODO? should this encode start+offset? rsp.NextPageToken = oid break @@ -732,5 +758,6 @@ func (s *sqlEntityServer) Search(ctx context.Context, r *entity.EntitySearchRequ rsp.Results = append(rsp.Results, result) } + return rsp, err } diff --git a/pkg/services/store/entity/tests/common.go b/pkg/services/store/entity/tests/common.go index 78628eccbaf..b40122f9cef 100644 --- a/pkg/services/store/entity/tests/common.go +++ b/pkg/services/store/entity/tests/common.go @@ -5,12 +5,12 @@ import ( "testing" apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" saAPI "github.com/grafana/grafana/pkg/services/serviceaccounts/api" saTests "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" - "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -86,6 +86,6 @@ func createTestContext(t *testing.T) testContext { authToken: authToken, client: client, user: serviceAccountUser, - ctx: store.ContextWithUser(context.Background(), serviceAccountUser), + ctx: appcontext.WithUser(context.Background(), serviceAccountUser), } } diff --git a/pkg/services/store/entity/tests/server_integration_test.go b/pkg/services/store/entity/tests/server_integration_test.go index 17f45487399..29d7762e1d3 100644 --- a/pkg/services/store/entity/tests/server_integration_test.go +++ b/pkg/services/store/entity/tests/server_integration_test.go @@ -1,6 +1,7 @@ package entity_server_tests import ( + _ "embed" "encoding/json" "fmt" "reflect" @@ -15,6 +16,13 @@ import ( "google.golang.org/grpc/metadata" ) +var ( + //go:embed testdata/dashboard-with-tags-b-g.json + dashboardWithTagsBlueGreen string + //go:embed testdata/dashboard-with-tags-r-g.json + dashboardWithTagsRedGreen string +) + type rawEntityMatcher struct { grn *entity.GRN createdRange []time.Time @@ -111,6 +119,11 @@ func requireVersionMatch(t *testing.T, obj *entity.EntityVersionInfo, m objectVe } func TestIntegrationEntityServer(t *testing.T) { + if true { + // FIXME + t.Skip() + } + if testing.Short() { t.Skip("skipping integration test") } @@ -379,4 +392,88 @@ func TestIntegrationEntityServer(t *testing.T) { w2.Entity.Version, }, version) }) + + t.Run("should be able to filter objects based on their labels", func(t *testing.T) { + kind := models.StandardKindDashboard + _, err := testCtx.client.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + Kind: kind, + UID: "blue-green", + }, + Body: []byte(dashboardWithTagsBlueGreen), + }) + require.NoError(t, err) + + _, err = testCtx.client.Write(ctx, &entity.WriteEntityRequest{ + GRN: &entity.GRN{ + Kind: kind, + UID: "red-green", + }, + Body: []byte(dashboardWithTagsRedGreen), + }) + require.NoError(t, err) + + search, err := testCtx.client.Search(ctx, &entity.EntitySearchRequest{ + Kind: []string{kind}, + WithBody: false, + WithLabels: true, + Labels: map[string]string{ + "red": "", + }, + }) + require.NoError(t, err) + require.NotNil(t, search) + require.Len(t, search.Results, 1) + require.Equal(t, search.Results[0].GRN.UID, "red-green") + + search, err = testCtx.client.Search(ctx, &entity.EntitySearchRequest{ + Kind: []string{kind}, + WithBody: false, + WithLabels: true, + Labels: map[string]string{ + "red": "", + "green": "", + }, + }) + require.NoError(t, err) + require.NotNil(t, search) + require.Len(t, search.Results, 1) + require.Equal(t, search.Results[0].GRN.UID, "red-green") + + search, err = testCtx.client.Search(ctx, &entity.EntitySearchRequest{ + Kind: []string{kind}, + WithBody: false, + WithLabels: true, + Labels: map[string]string{ + "red": "invalid", + }, + }) + require.NoError(t, err) + require.NotNil(t, search) + require.Len(t, search.Results, 0) + + search, err = testCtx.client.Search(ctx, &entity.EntitySearchRequest{ + Kind: []string{kind}, + WithBody: false, + WithLabels: true, + Labels: map[string]string{ + "green": "", + }, + }) + require.NoError(t, err) + require.NotNil(t, search) + require.Len(t, search.Results, 2) + + search, err = testCtx.client.Search(ctx, &entity.EntitySearchRequest{ + Kind: []string{kind}, + WithBody: false, + WithLabels: true, + Labels: map[string]string{ + "yellow": "", + }, + }) + require.NoError(t, err) + require.NotNil(t, search) + require.Len(t, search.Results, 0) + }) } diff --git a/pkg/services/store/entity/tests/testdata/dashboard-with-tags-b-g.json b/pkg/services/store/entity/tests/testdata/dashboard-with-tags-b-g.json new file mode 100644 index 00000000000..444842ed2fd --- /dev/null +++ b/pkg/services/store/entity/tests/testdata/dashboard-with-tags-b-g.json @@ -0,0 +1,40 @@ +{ + "tags": [ + "blue", + "green" + ], + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 221, + "links": [], + "liveNow": false, + "panels": [ + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 8, + "title": "Row title", + "type": "row" + } + ], + "schemaVersion": 36, + "style": "dark", + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "special ds", + "uid": "mocpwtR4k", + "version": 1, + "weekStart": "" +} diff --git a/pkg/services/store/entity/tests/testdata/dashboard-with-tags-r-g.json b/pkg/services/store/entity/tests/testdata/dashboard-with-tags-r-g.json new file mode 100644 index 00000000000..2239008252d --- /dev/null +++ b/pkg/services/store/entity/tests/testdata/dashboard-with-tags-r-g.json @@ -0,0 +1,40 @@ +{ + "tags": [ + "red", + "green" + ], + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 221, + "links": [], + "liveNow": false, + "panels": [ + { + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 8, + "title": "Row title", + "type": "row" + } + ], + "schemaVersion": 36, + "style": "dark", + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "special ds", + "uid": "mocpwtR4k", + "version": 1, + "weekStart": "" +} diff --git a/pkg/services/store/resolver/ds_cache.go b/pkg/services/store/resolver/ds_cache.go index 64081d057a7..93c648ebc8f 100644 --- a/pkg/services/store/resolver/ds_cache.go +++ b/pkg/services/store/resolver/ds_cache.go @@ -6,9 +6,9 @@ import ( "sync" "time" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/tsdb/grafanads" ) @@ -122,9 +122,12 @@ func (c *dsCache) getDS(ctx context.Context, uid string) (*dsVal, error) { } } - orgID := store.UserFromContext(ctx).OrgID + usr, err := appcontext.User(ctx) + if err != nil { + return nil, nil // no user + } - v, ok := c.cache[orgID] + v, ok := c.cache[usr.OrgID] if !ok { return nil, nil // org not found } diff --git a/pkg/services/store/resolver/service_test.go b/pkg/services/store/resolver/service_test.go index 92d305761cc..b420b27a508 100644 --- a/pkg/services/store/resolver/service_test.go +++ b/pkg/services/store/resolver/service_test.go @@ -4,17 +4,17 @@ import ( "context" "testing" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/datasources" fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" - "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/user" "github.com/stretchr/testify/require" ) func TestResolver(t *testing.T) { - ctxOrg1 := store.ContextWithUser(context.Background(), &user.SignedInUser{OrgID: 1}) + ctxOrg1 := appcontext.WithUser(context.Background(), &user.SignedInUser{OrgID: 1}) ds := &fakeDatasources.FakeDataSourceService{ DataSources: []*datasources.DataSource{ diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index fc17c6b85b1..bc77c693ea4 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -592,8 +592,7 @@ func (s *standardStorageService) getWorkflowOptions(ctx context.Context, user *u Workflows: make([]workflowInfo, 0), } - scope, _ := splitFirstSegment(path) - root, _ := s.tree.getRoot(user.OrgID, scope) + root, _ := s.tree.getRoot(user.OrgID, path) if root == nil { return options, fmt.Errorf("can not read") } diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index 17ec4f6d08b..047beacd9c9 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -13,16 +13,19 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) func TestIntegrationTeamCommandsAndQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - t.Run("Testing Team commands & queries", func(t *testing.T) { + t.Run("Testing Team commands and queries", func(t *testing.T) { sqlStore := db.InitTestDB(t) teamSvc := ProvideService(sqlStore, sqlStore.Cfg) testUser := &user.SignedInUser{ @@ -35,6 +38,11 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }, }, } + quotaService := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, teamSvc, nil, quotaService) + require.NoError(t, err) t.Run("Given saved users and two teams", func(t *testing.T) { var userIds []int64 @@ -51,7 +59,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { Name: fmt.Sprint("user", i), Login: fmt.Sprint("loginuser", i), } - usr, err = sqlStore.CreateUser(context.Background(), userCmd) + usr, err = userSvc.Create(context.Background(), &userCmd) require.NoError(t, err) userIds = append(userIds, usr.ID) } @@ -82,7 +90,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { q1 := &models.GetTeamMembersQuery{OrgId: testOrgID, TeamId: team1.Id, SignedInUser: testUser} err = teamSvc.GetTeamMembers(context.Background(), q1) require.NoError(t, err) - require.Equal(t, len(q1.Result), 2) + require.Equal(t, 2, len(q1.Result)) require.Equal(t, q1.Result[0].TeamId, team1.Id) require.Equal(t, q1.Result[0].Login, "loginuser0") require.Equal(t, q1.Result[0].OrgId, testOrgID) @@ -373,6 +381,11 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { t.Run("Should be able to exclude service accounts from teamembers", func(t *testing.T) { sqlStore = db.InitTestDB(t) + quotaService := quotaimpl.ProvideService(sqlStore, sqlStore.Cfg) + orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sqlStore.Cfg, teamSvc, nil, quotaService) + require.NoError(t, err) setup() userCmd = user.CreateUserCommand{ Email: fmt.Sprint("sa", 1, "@test.com"), @@ -380,7 +393,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { Login: fmt.Sprint("login-sa", 1), IsServiceAccount: true, } - serviceAccount, err := sqlStore.CreateUser(context.Background(), userCmd) + serviceAccount, err := userSvc.CreateUserForTests(context.Background(), &userCmd) require.NoError(t, err) groupId := team2.Id @@ -497,6 +510,11 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { require.NoError(t, errCreateTeam) team2, errCreateTeam := teamSvc.CreateTeam("group2 name", "test2@example.org", testOrgID) require.NoError(t, errCreateTeam) + quotaService := quotaimpl.ProvideService(store, store.Cfg) + orgSvc, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(store, orgSvc, store.Cfg, teamSvc, nil, quotaService) + require.NoError(t, err) for i := 0; i < 4; i++ { userCmd := user.CreateUserCommand{ @@ -504,7 +522,7 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { Name: fmt.Sprint("user", i), Login: fmt.Sprint("loginuser", i), } - user, errCreateUser := store.CreateUser(context.Background(), userCmd) + user, errCreateUser := userSvc.Create(context.Background(), &userCmd) require.NoError(t, errCreateUser) userIds[i] = user.ID } diff --git a/pkg/services/user/user.go b/pkg/services/user/user.go index b66962c48f9..2852ef4afd4 100644 --- a/pkg/services/user/user.go +++ b/pkg/services/user/user.go @@ -6,6 +6,7 @@ import ( type Service interface { Create(context.Context, *CreateUserCommand) (*User, error) + CreateServiceAccount(context.Context, *CreateUserCommand) (*User, error) Delete(context.Context, *DeleteUserCommand) error GetByID(context.Context, *GetUserByIDQuery) (*User, error) GetByLogin(context.Context, *GetUserByLoginQuery) (*User, error) @@ -23,4 +24,7 @@ type Service interface { UpdatePermissions(context.Context, int64, bool) error SetUserHelpFlag(context.Context, *SetUserHelpFlagCommand) error GetProfile(context.Context, *GetUserProfileQuery) (*UserProfileDTO, error) + + // TEST ONLY METHOD + CreateUserForTests(context.Context, *CreateUserCommand) (*User, error) } diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index 53deaf17c41..09d6b5b48d7 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -24,6 +23,7 @@ type store interface { GetByID(context.Context, int64) (*user.User, error) GetNotServiceAccount(context.Context, int64) (*user.User, error) Delete(context.Context, int64) error + LoginConflict(ctx context.Context, login, email string, caseInsensitive bool) error CaseInsensitiveLoginConflict(context.Context, string, string) error GetByLogin(context.Context, *user.GetUserByLoginQuery) (*user.User, error) GetByEmail(context.Context, *user.GetUserByEmailQuery) (*user.User, error) @@ -59,12 +59,11 @@ func ProvideStore(db db.DB, cfg *setting.Cfg) sqlStore { } func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) { - var userID int64 var err error err = ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { sess.UseBool("is_admin") - if userID, err = sess.Insert(cmd); err != nil { + if _, err = sess.Insert(cmd); err != nil { return err } sess.PublishAfterCommit(&events.UserCreated{ @@ -79,7 +78,13 @@ func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) { if err != nil { return 0, err } - return userID, nil + + // verify that user was created and cmd.ID was updated with the actual new userID + _, err = ss.getAnyUserType(ctx, cmd.ID) + if err != nil { + return 0, err + } + return cmd.ID, nil } func (ss *sqlStore) Get(ctx context.Context, usr *user.User) (*user.User, error) { @@ -185,7 +190,6 @@ func (ss *sqlStore) GetByLogin(ctx context.Context, query *user.GetUserByLoginQu where = "LOWER(email)=LOWER(?)" } has, err = sess.Where(ss.notServiceAccountFilter()).Where(where, query.LoginOrEmail).Get(usr) - if err != nil { return err } @@ -264,6 +268,43 @@ func (ss *sqlStore) userCaseInsensitiveLoginConflict(ctx context.Context, sess * return nil } +// LoginConflict returns an error if the provided email or login are already +// associated with a user. If caseInsensitive is true the search is not case +// sensitive. +func (ss *sqlStore) LoginConflict(ctx context.Context, login, email string, caseInsensitive bool) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { + return ss.loginConflict(ctx, sess, login, email, caseInsensitive) + }) + return err +} + +func (ss *sqlStore) loginConflict(ctx context.Context, sess *db.Session, login, email string, caseInsensitive bool) error { + users := make([]user.User, 0) + where := "email=? OR login=?" + if caseInsensitive { + where = "LOWER(email)=LOWER(?) OR LOWER(login)=LOWER(?)" + login = strings.ToLower(login) + email = strings.ToLower(email) + } + + exists, err := sess.Where(where, email, login).Get(&user.User{}) + if err != nil { + return err + } + if exists { + return user.ErrUserAlreadyExists + } + if err := sess.Where("LOWER(email)=LOWER(?) OR LOWER(login)=LOWER(?)", + email, login).Find(&users); err != nil { + return err + } + + if len(users) > 1 { + return &user.ErrCaseInsensitiveLoginConflict{Users: users} + } + return nil +} + func (ss *sqlStore) Update(ctx context.Context, cmd *user.UpdateUserCommand) error { if ss.cfg.CaseInsensitiveLogin { cmd.Login = strings.ToLower(cmd.Login) @@ -470,7 +511,7 @@ func (ss *sqlStore) Count(ctx context.Context) (int64, error) { } r := result{} - err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s WHERE is_service_account=%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().BooleanStr(false)) if _, err := sess.SQL(rawSQL).Get(&r); err != nil { return err @@ -648,3 +689,19 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* }) return &result, err } + +// getAnyUserType searches for a user record by ID. The user account may be a service account. +func (ss *sqlStore) getAnyUserType(ctx context.Context, userID int64) (*user.User, error) { + usr := user.User{ID: userID} + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { + has, err := sess.Get(&usr) + if err != nil { + return err + } + if !has { + return user.ErrUserNotFound + } + return nil + }) + return &usr, err +} diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 48e2d2de23b..2a58935c035 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -31,6 +30,8 @@ func TestIntegrationUserDataAccess(t *testing.T) { orgService, err := orgimpl.ProvideService(ss, ss.Cfg, quotaService) require.NoError(t, err) userStore := ProvideStore(ss, setting.NewCfg()) + usrSvc, err := ProvideService(ss, orgService, ss.Cfg, nil, nil, quotaService) + require.NoError(t, err) usr := &user.SignedInUser{ OrgID: 1, Permissions: map[int64]map[string][]string{1: {"users:read": {"global.users:*"}}}, @@ -73,12 +74,14 @@ func TestIntegrationUserDataAccess(t *testing.T) { t.Run("Testing DB - creates and loads user", func(t *testing.T) { ss := db.InitTestDB(t) + _, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg) + cmd := user.CreateUserCommand{ Email: "usertest@test.com", Name: "user name", Login: "user_test_login", } - usr, err := ss.CreateUser(context.Background(), cmd) + usr, err := usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) result, err := userStore.GetByID(context.Background(), usr.ID) @@ -147,7 +150,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { Login: "user_email_conflict", } // userEmailConflict - _, err := ss.CreateUser(context.Background(), cmd) + _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) cmd = user.CreateUserCommand{ @@ -155,7 +158,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { Name: "user name", Login: "user_email_conflict_two", } - _, err = ss.CreateUser(context.Background(), cmd) + _, err := usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) cmd = user.CreateUserCommand{ @@ -164,7 +167,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { Login: "user_test_login_conflict", } // userLoginConflict - _, err = ss.CreateUser(context.Background(), cmd) + _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) cmd = user.CreateUserCommand{ @@ -172,7 +175,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { Name: "user name", Login: "user_test_login_CONFLICT", } - _, err = ss.CreateUser(context.Background(), cmd) + _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) ss.Cfg.CaseInsensitiveLogin = true @@ -262,7 +265,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { }) t.Run("get signed in user", func(t *testing.T) { - users := createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + ss := db.InitTestDB(t) + orgService, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg) + users := createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -300,26 +305,24 @@ func TestIntegrationUserDataAccess(t *testing.T) { }) t.Run("Testing DB - grafana admin users", func(t *testing.T) { - ss = db.InitTestDB(t) - + ss := db.InitTestDB(t) + _, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg) createUserCmd := user.CreateUserCommand{ Email: fmt.Sprint("admin", "@test.com"), Name: "admin", Login: "admin", IsAdmin: true, } - usr, err := ss.CreateUser(context.Background(), createUserCmd) + usr, err := usrSvc.Create(context.Background(), &createUserCmd) require.Nil(t, err) // Cannot make themselves a non-admin updatePermsError := userStore.UpdatePermissions(context.Background(), usr.ID, false) - require.Equal(t, user.ErrLastGrafanaAdmin, updatePermsError) query := user.GetUserByIDQuery{ID: usr.ID} queryResult, getUserError := userStore.GetByID(context.Background(), query.ID) require.Nil(t, getUserError) - require.True(t, queryResult.IsAdmin) // One user @@ -330,7 +333,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { Name: "user", Login: username, } - _, err = ss.CreateUser(context.Background(), createUserCmd) + _, err = usrSvc.Create(context.Background(), &createUserCmd) require.Nil(t, err) // When trying to create a new user with the same email, an error is returned @@ -340,8 +343,8 @@ func TestIntegrationUserDataAccess(t *testing.T) { Login: "user2", SkipOrgSetup: true, } - _, err = ss.CreateUser(context.Background(), createUserCmd) - require.Equal(t, err, user.ErrUserAlreadyExists) + _, err = usrSvc.Create(context.Background(), &createUserCmd) + require.Equal(t, user.ErrUserAlreadyExists, err) // When trying to create a new user with the same login, an error is returned createUserCmd = user.CreateUserCommand{ @@ -350,8 +353,8 @@ func TestIntegrationUserDataAccess(t *testing.T) { Login: username, SkipOrgSetup: true, } - _, err = ss.CreateUser(context.Background(), createUserCmd) - require.Equal(t, err, user.ErrUserAlreadyExists) + _, err = usrSvc.Create(context.Background(), &createUserCmd) + require.Equal(t, user.ErrUserAlreadyExists, err) }) t.Run("GetProfile", func(t *testing.T) { @@ -366,7 +369,10 @@ func TestIntegrationUserDataAccess(t *testing.T) { t.Run("Testing DB - return list users based on their is_disabled flag", func(t *testing.T) { ss = db.InitTestDB(t) - createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + _, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg) + userStore := ProvideStore(ss, ss.Cfg) + + createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -379,7 +385,6 @@ func TestIntegrationUserDataAccess(t *testing.T) { query := user.SearchUsersQuery{IsDisabled: &isDisabled, SignedInUser: usr} result, err := userStore.Search(context.Background(), &query) require.Nil(t, err) - require.Len(t, result.Users, 2) first, third := false, false @@ -397,8 +402,10 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.True(t, third) // Re-init DB - ss = db.InitTestDB(t) - users := createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + ss := db.InitTestDB(t) + orgService, usrSvc = createOrgAndUserSvc(t, ss, ss.Cfg) + + users := createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -432,7 +439,8 @@ func TestIntegrationUserDataAccess(t *testing.T) { // A user is an org member and has been assigned permissions // Re-init DB ss = db.InitTestDB(t) - users = createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + orgService, usrSvc = createOrgAndUserSvc(t, ss, ss.Cfg) + users = createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -487,7 +495,12 @@ func TestIntegrationUserDataAccess(t *testing.T) { t.Run("Testing DB - return list of users that the SignedInUser has permission to read", func(t *testing.T) { ss := db.InitTestDB(t) - createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + orgService, err := orgimpl.ProvideService(ss, ss.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := ProvideService(ss, orgService, ss.Cfg, nil, nil, quotaService) + require.NoError(t, err) + + createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -508,7 +521,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { ss = db.InitTestDB(t) t.Run("Testing DB - enable all users", func(t *testing.T) { - users := createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + users := createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -541,12 +554,12 @@ func TestIntegrationUserDataAccess(t *testing.T) { ac2cmd := user.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name", IsAdmin: true} serviceaccountcmd := user.CreateUserCommand{Login: "serviceaccount", Email: "service@test.com", Name: "serviceaccount name", IsAdmin: true, IsServiceAccount: true} - _, err := ss.CreateUser(context.Background(), ac1cmd) + _, err := usrSvc.Create(context.Background(), &ac1cmd) require.NoError(t, err) - _, err = ss.CreateUser(context.Background(), ac2cmd) + _, err = usrSvc.Create(context.Background(), &ac2cmd) require.NoError(t, err) // user only used for making sure we filter out the service accounts - _, err = ss.CreateUser(context.Background(), serviceaccountcmd) + _, err = usrSvc.Create(context.Background(), &serviceaccountcmd) require.NoError(t, err) query := user.SearchUsersQuery{Query: "", SignedInUser: &user.SignedInUser{ OrgID: 1, @@ -564,7 +577,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { ss = db.InitTestDB(t) t.Run("Testing DB - disable only specific users", func(t *testing.T) { - users := createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + users := createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -594,7 +607,6 @@ func TestIntegrationUserDataAccess(t *testing.T) { // Check if user id is in the userIdsToDisable list for _, disabledUserId := range userIdsToDisable { - fmt.Println(user.ID, disabledUserId) if user.ID == disabledUserId { require.True(t, user.IsDisabled) shouldBeDisabled = true @@ -612,7 +624,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { t.Run("Testing DB - search users", func(t *testing.T) { // Since previous tests were destructive - createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -636,7 +648,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { t.Run("Testing DB - multiple users", func(t *testing.T) { ss = db.InitTestDB(t) - createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@test.com"), Name: fmt.Sprint("user", i), @@ -729,8 +741,9 @@ func TestIntegrationUserUpdate(t *testing.T) { ss := db.InitTestDB(t) userStore := ProvideStore(ss, setting.NewCfg()) + _, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg) - users := createFiveTestUsers(t, ss, func(i int) *user.CreateUserCommand { + users := createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { return &user.CreateUserCommand{ Email: fmt.Sprint("USER", i, "@test.com"), Name: fmt.Sprint("USER", i), @@ -789,17 +802,15 @@ func TestIntegrationUserUpdate(t *testing.T) { ss.Cfg.CaseInsensitiveLogin = false } -func createFiveTestUsers(t *testing.T, sqlStore *sqlstore.SQLStore, fn func(i int) *user.CreateUserCommand) []user.User { +func createFiveTestUsers(t *testing.T, svc user.Service, fn func(i int) *user.CreateUserCommand) []user.User { t.Helper() - users := []user.User{} + users := make([]user.User, 5) for i := 0; i < 5; i++ { cmd := fn(i) - - user, err := sqlStore.CreateUser(context.Background(), *cmd) - users = append(users, *user) - + user, err := svc.CreateUserForTests(context.Background(), cmd) require.Nil(t, err) + users[i] = *user } return users @@ -924,3 +935,15 @@ func (ss *sqlStore) getDashboardACLInfoList(query *models.GetDashboardACLInfoLis return nil } + +func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { + t.Helper() + + quotaService := quotaimpl.ProvideService(store, cfg) + orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + require.NoError(t, err) + usrSvc, err := ProvideService(store, orgService, cfg, nil, nil, quotaService) + require.NoError(t, err) + + return orgService, usrSvc +} diff --git a/pkg/services/user/userimpl/time.go b/pkg/services/user/userimpl/time.go new file mode 100644 index 00000000000..87ded6cd9c5 --- /dev/null +++ b/pkg/services/user/userimpl/time.go @@ -0,0 +1,16 @@ +package userimpl + +import "time" + +// timeNow wraps time.Now so it can be mocked in tests. +var timeNow = time.Now + +func MockTimeNow(constTime time.Time) { + timeNow = func() time.Time { + return constTime + } +} + +func ResetTimeNow() { + timeNow = time.Now +} diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index 132f556a1c2..4f275baa598 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -82,32 +82,27 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use SkipOrgSetup: cmd.SkipOrgSetup, } orgID, err := s.orgService.GetIDForNewUser(ctx, cmdOrg) - cmd.OrgID = orgID if err != nil { return nil, err } - if cmd.Email == "" { cmd.Email = cmd.Login } - usr := &user.User{ - Login: cmd.Login, - Email: cmd.Email, - } - usr, err = s.store.Get(ctx, usr) - if err != nil && !errors.Is(err, user.ErrUserNotFound) { - return usr, err + + err = s.store.LoginConflict(ctx, cmd.Login, cmd.Email, s.cfg.CaseInsensitiveLogin) + if err != nil { + return nil, user.ErrUserAlreadyExists } // create user - usr = &user.User{ + usr := &user.User{ Email: cmd.Email, Name: cmd.Name, Login: cmd.Login, Company: cmd.Company, IsAdmin: cmd.IsAdmin, IsDisabled: cmd.IsDisabled, - OrgID: cmd.OrgID, + OrgID: orgID, EmailVerified: cmd.EmailVerified, Created: time.Now(), Updated: time.Now(), @@ -134,7 +129,7 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use usr.Password = encodedPassword } - userID, err := s.store.Insert(ctx, usr) + _, err = s.store.Insert(ctx, usr) if err != nil { return nil, err } @@ -158,11 +153,10 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use } _, err = s.orgService.InsertOrgUser(ctx, &orgUser) if err != nil { - err := s.store.Delete(ctx, userID) + err := s.store.Delete(ctx, usr.ID) return usr, err } } - return usr, nil } @@ -354,3 +348,198 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { limits.Set(globalQuotaTag, cfg.Quota.Global.User) return limits, nil } + +// CreateUserForTests creates a test user and optionally an organization. Unlike +// Create, `cmd.SkipOrgSetup` toggles whether or not to create an org for the +// test user if there isn't already an existing org. This must only be used in tests. +func (s *Service) CreateUserForTests(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { + var orgID int64 = -1 + var err error + if !cmd.SkipOrgSetup { + orgID, err = s.getOrgIDForNewUser(ctx, cmd) + if err != nil { + return nil, err + } + } + + if cmd.Email == "" { + cmd.Email = cmd.Login + } + + usr, err := s.GetByLogin(ctx, &user.GetUserByLoginQuery{LoginOrEmail: cmd.Login}) + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + return usr, err + } else if err == nil { // user exists + return usr, err + } + + // create user + usr = &user.User{ + Email: cmd.Email, + Name: cmd.Name, + Login: cmd.Login, + Company: cmd.Company, + IsAdmin: cmd.IsAdmin, + IsDisabled: cmd.IsDisabled, + OrgID: orgID, + EmailVerified: cmd.EmailVerified, + Created: timeNow(), + Updated: timeNow(), + LastSeenAt: timeNow().AddDate(-10, 0, 0), + IsServiceAccount: cmd.IsServiceAccount, + } + + salt, err := util.GetRandomString(10) + if err != nil { + return usr, err + } + usr.Salt = salt + rands, err := util.GetRandomString(10) + if err != nil { + return usr, err + } + usr.Rands = rands + + if len(cmd.Password) > 0 { + encodedPassword, err := util.EncodePassword(cmd.Password, usr.Salt) + if err != nil { + return usr, err + } + usr.Password = encodedPassword + } + + _, err = s.store.Insert(ctx, usr) + if err != nil { + return usr, err + } + + // create org user link + if !cmd.SkipOrgSetup { + orgCmd := &org.AddOrgUserCommand{ + OrgID: orgID, + UserID: usr.ID, + Role: org.RoleAdmin, + AllowAddingServiceAccount: true, + } + + if s.cfg.AutoAssignOrg && !usr.IsAdmin { + if len(cmd.DefaultOrgRole) > 0 { + orgCmd.Role = org.RoleType(cmd.DefaultOrgRole) + } else { + orgCmd.Role = org.RoleType(s.cfg.AutoAssignOrgRole) + } + } + + if err = s.orgService.AddOrgUser(ctx, orgCmd); err != nil { + return nil, err + } + } + + return usr, nil +} + +func (s *Service) getOrgIDForNewUser(ctx context.Context, cmd *user.CreateUserCommand) (int64, error) { + if s.cfg.AutoAssignOrg && cmd.OrgID != 0 { + if _, err := s.orgService.GetByID(ctx, &org.GetOrgByIdQuery{ID: cmd.OrgID}); err != nil { + return -1, err + } + return cmd.OrgID, nil + } + + orgName := cmd.OrgName + if orgName == "" { + orgName = util.StringsFallback2(cmd.Email, cmd.Login) + } + + orgID, err := s.orgService.GetOrCreate(ctx, orgName) + if err != nil { + return 0, err + } + return orgID, err +} + +// CreateServiceAccount is a copy of Create with a single difference; it will create the OrgUser service account. +func (s *Service) CreateServiceAccount(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { + cmdOrg := org.GetOrgIDForNewUserCommand{ + Email: cmd.Email, + Login: cmd.Login, + OrgID: cmd.OrgID, + OrgName: cmd.OrgName, + SkipOrgSetup: cmd.SkipOrgSetup, + } + orgID, err := s.orgService.GetIDForNewUser(ctx, cmdOrg) + if err != nil { + return nil, err + } + if cmd.Email == "" { + cmd.Email = cmd.Login + } + + err = s.store.LoginConflict(ctx, cmd.Login, cmd.Email, s.cfg.CaseInsensitiveLogin) + if err != nil { + return nil, user.ErrUserAlreadyExists + } + + // create user + usr := &user.User{ + Email: cmd.Email, + Name: cmd.Name, + Login: cmd.Login, + Company: cmd.Company, + IsAdmin: cmd.IsAdmin, + IsDisabled: cmd.IsDisabled, + OrgID: cmd.OrgID, + EmailVerified: cmd.EmailVerified, + Created: time.Now(), + Updated: time.Now(), + LastSeenAt: time.Now().AddDate(-10, 0, 0), + IsServiceAccount: cmd.IsServiceAccount, + } + + salt, err := util.GetRandomString(10) + if err != nil { + return nil, err + } + usr.Salt = salt + rands, err := util.GetRandomString(10) + if err != nil { + return nil, err + } + usr.Rands = rands + + if len(cmd.Password) > 0 { + encodedPassword, err := util.EncodePassword(cmd.Password, usr.Salt) + if err != nil { + return nil, err + } + usr.Password = encodedPassword + } + + _, err = s.store.Insert(ctx, usr) + if err != nil { + return nil, err + } + + // create org user link + if !cmd.SkipOrgSetup { + orgCmd := &org.AddOrgUserCommand{ + OrgID: orgID, + UserID: usr.ID, + Role: org.RoleAdmin, + AllowAddingServiceAccount: true, + } + + if s.cfg.AutoAssignOrg && !usr.IsAdmin { + if len(cmd.DefaultOrgRole) > 0 { + orgCmd.Role = org.RoleType(cmd.DefaultOrgRole) + } else { + orgCmd.Role = org.RoleType(s.cfg.AutoAssignOrgRole) + } + } + + if err = s.orgService.AddOrgUser(ctx, orgCmd); err != nil { + return nil, err + } + } + return usr, nil +} diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index d491bd4f848..53f63e72940 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -26,6 +26,7 @@ func TestUserService(t *testing.T) { cacheService: localcache.ProvideService(), teamService: &teamtest.FakeService{}, } + userService.cfg = setting.NewCfg() t.Run("create user", func(t *testing.T) { _, err := userService.Create(context.Background(), &user.CreateUserCommand{ @@ -44,7 +45,6 @@ func TestUserService(t *testing.T) { require.NoError(t, err) require.Equal(t, "login", u.Login) require.Equal(t, "name", u.Name) - require.Equal(t, "email", u.Email) }) @@ -229,6 +229,10 @@ func (f *FakeUserStore) CaseInsensitiveLoginConflict(context.Context, string, st return f.ExpectedError } +func (f *FakeUserStore) LoginConflict(context.Context, string, string, bool) error { + return f.ExpectedError +} + func (f *FakeUserStore) GetByLogin(ctx context.Context, query *user.GetUserByLoginQuery) (*user.User, error) { return f.ExpectedUser, f.ExpectedError } diff --git a/pkg/services/user/usertest/fake.go b/pkg/services/user/usertest/fake.go index 0041dd9e8d3..aa26ee05314 100644 --- a/pkg/services/user/usertest/fake.go +++ b/pkg/services/user/usertest/fake.go @@ -28,6 +28,14 @@ func (f *FakeUserService) Create(ctx context.Context, cmd *user.CreateUserComman return f.ExpectedUser, f.ExpectedError } +func (f *FakeUserService) CreateUserForTests(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { + return f.ExpectedUser, f.ExpectedError +} + +func (f *FakeUserService) CreateServiceAccount(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { + return f.ExpectedUser, f.ExpectedError +} + func (f *FakeUserService) Delete(ctx context.Context, cmd *user.DeleteUserCommand) error { return f.ExpectedError } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index c2a90b8f950..f9a6e1f96e1 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -1120,10 +1120,14 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cacheServer := iniFile.Section("remote_cache") dbName := valueAsString(cacheServer, "type", "database") connStr := valueAsString(cacheServer, "connstr", "") + prefix := valueAsString(cacheServer, "prefix", "") + encryption := cacheServer.Key("encryption").MustBool(false) cfg.RemoteCacheOptions = &RemoteCacheOptions{ - Name: dbName, - ConnStr: connStr, + Name: dbName, + ConnStr: connStr, + Prefix: prefix, + Encryption: encryption, } geomapSection := iniFile.Section("geomap") @@ -1157,8 +1161,10 @@ func valueAsString(section *ini.Section, keyName string, defaultValue string) st } type RemoteCacheOptions struct { - Name string - ConnStr string + Name string + ConnStr string + Prefix string + Encryption bool } func (cfg *Cfg) readLDAPConfig() { diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go index 1633da33995..aa26e9ef568 100644 --- a/pkg/tests/api/alerting/api_admin_configuration_test.go +++ b/pkg/tests/api/alerting/api_admin_configuration_test.go @@ -25,7 +25,9 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) -func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { +func TestIntegrationAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + const disableOrgID int64 = 3 dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index e3daa09d5ea..59b06869c94 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -20,7 +20,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { +func TestIntegrationAlertmanagerConfigurationIsTransactional(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, @@ -137,7 +139,9 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { } } -func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) { +func TestIntegrationAlertmanagerConfigurationPersistSecrets(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index ca79eb54301..d7aff484a71 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -20,8 +20,11 @@ import ( ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" ) @@ -31,7 +34,9 @@ type Response struct { TraceID string `json:"traceID"` } -func TestAMConfigAccess(t *testing.T) { +func TestIntegrationAMConfigAccess(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, @@ -389,7 +394,9 @@ func TestAMConfigAccess(t *testing.T) { }) } -func TestAlertAndGroupsQuery(t *testing.T) { +func TestIntegrationAlertAndGroupsQuery(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, @@ -546,7 +553,9 @@ func TestAlertAndGroupsQuery(t *testing.T) { } } -func TestRulerAccess(t *testing.T) { +func TestIntegrationRulerAccess(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -661,7 +670,9 @@ func TestRulerAccess(t *testing.T) { } } -func TestDeleteFolderWithRules(t *testing.T) { +func TestIntegrationDeleteFolderWithRules(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -821,7 +832,9 @@ func TestDeleteFolderWithRules(t *testing.T) { } } -func TestAlertRuleCRUD(t *testing.T) { +func TestIntegrationAlertRuleCRUD(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -1799,7 +1812,9 @@ func TestAlertRuleCRUD(t *testing.T) { } } -func TestAlertmanagerStatus(t *testing.T) { +func TestIntegrationAlertmanagerStatus(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -1862,7 +1877,9 @@ func TestAlertmanagerStatus(t *testing.T) { } } -func TestQuota(t *testing.T) { +func TestIntegrationQuota(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -2061,7 +2078,9 @@ func TestQuota(t *testing.T) { }) } -func TestEval(t *testing.T) { +func TestIntegrationEval(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -2534,7 +2553,13 @@ func createUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserComma store.Cfg.AutoAssignOrg = true store.Cfg.AutoAssignOrgId = 1 - u, err := store.CreateUser(context.Background(), cmd) + quotaService := quotaimpl.ProvideService(store, store.Cfg) + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(store, orgService, store.Cfg, nil, nil, quotaService) + require.NoError(t, err) + + u, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) return u.ID } diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go index bbf605de55c..bd1ba992612 100644 --- a/pkg/tests/api/alerting/api_available_channel_test.go +++ b/pkg/tests/api/alerting/api_available_channel_test.go @@ -15,7 +15,9 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) -func TestAvailableChannels(t *testing.T) { +func TestIntegrationAvailableChannels(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index d743bb4123d..3fc09ffba95 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -31,7 +31,9 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) -func TestTestReceivers(t *testing.T) { +func TestIntegrationTestReceivers(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + t.Run("assert no receivers returns 400 Bad Request", func(t *testing.T) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ @@ -423,7 +425,9 @@ func TestTestReceivers(t *testing.T) { }) } -func TestTestReceiversAlertCustomization(t *testing.T) { +func TestIntegrationTestReceiversAlertCustomization(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + t.Run("assert custom annotations and labels are sent", func(t *testing.T) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ @@ -693,7 +697,9 @@ func TestTestReceiversAlertCustomization(t *testing.T) { }) } -func TestNotificationChannels(t *testing.T) { +func TestIntegrationNotificationChannels(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 2f5672756f3..64dbfe9387f 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -24,7 +24,9 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) -func TestPrometheusRules(t *testing.T) { +func TestIntegrationPrometheusRules(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, @@ -319,7 +321,9 @@ func TestPrometheusRules(t *testing.T) { } } -func TestPrometheusRulesFilterByDashboard(t *testing.T) { +func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, DisableAnonymous: true, @@ -612,7 +616,9 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) { } } -func TestPrometheusRulesPermissions(t *testing.T) { +func TestIntegrationPrometheusRulesPermissions(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index 33c4495a608..ce115c647ee 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -13,7 +13,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestProvisioning(t *testing.T) { +func TestIntegrationProvisioning(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, @@ -59,7 +61,7 @@ func TestProvisioning(t *testing.T) { "name": "test-receiver", "type": "slack", "settings": { - "recipient": "value_recipient", + "recipient": "value_recipient", "token": "value_token" } }` @@ -157,7 +159,7 @@ func TestProvisioning(t *testing.T) { "name": "my-contact-point", "type": "slack", "settings": { - "recipient": "value_recipient", + "recipient": "value_recipient", "token": "value_token" } }` diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 4e3186c9f99..3aa8b53617b 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -22,7 +22,9 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func TestAlertRulePermissions(t *testing.T) { +func TestIntegrationAlertRulePermissions(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -311,7 +313,9 @@ func createRule(t *testing.T, client apiClient, folder string) { require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) } -func TestAlertRuleConflictingTitle(t *testing.T) { +func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -382,7 +386,9 @@ func TestAlertRuleConflictingTitle(t *testing.T) { }) } -func TestRulerRulesFilterByDashboard(t *testing.T) { +func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, DisableAnonymous: true, @@ -719,7 +725,9 @@ func TestRulerRulesFilterByDashboard(t *testing.T) { } } -func TestRuleGroupSequence(t *testing.T) { +func TestIntegrationRuleGroupSequence(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, @@ -815,7 +823,9 @@ func TestRuleGroupSequence(t *testing.T) { }) } -func TestRuleUpdate(t *testing.T) { +func TestIntegrationRuleUpdate(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index 1f8ee1c4736..aabdb761929 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -7,12 +7,16 @@ import ( "net/http" "testing" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/stretchr/testify/require" ) type errorResponseBody struct { @@ -130,11 +134,17 @@ func (c TestContext) getURL(url string, user User) string { func (c TestContext) createUser(cmd user.CreateUserCommand) { c.t.Helper() + store := c.env.SQLStore + store.Cfg.AutoAssignOrg = true + store.Cfg.AutoAssignOrgId = 1 - c.env.SQLStore.Cfg.AutoAssignOrg = true - c.env.SQLStore.Cfg.AutoAssignOrgId = 1 + quotaService := quotaimpl.ProvideService(store, store.Cfg) + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(c.t, err) + usrSvc, err := userimpl.ProvideService(store, orgService, store.Cfg, nil, nil, quotaService) + require.NoError(c.t, err) - _, err := c.env.SQLStore.CreateUser(context.Background(), cmd) + _, err = usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(c.t, err) } diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 7e8841dedb0..65bd8f4e4cc 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -20,13 +20,20 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/plugindashboards" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" ) -func TestDashboardQuota(t *testing.T) { +func TestIntegrationDashboardQuota(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + // enable quota and set low dashboard quota // Setup Grafana and its Database dashboardQuota := int64(1) @@ -100,12 +107,22 @@ func createUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserComma store.Cfg.AutoAssignOrg = true store.Cfg.AutoAssignOrgId = 1 - u, err := store.CreateUser(context.Background(), cmd) + quotaService := quotaimpl.ProvideService(store, store.Cfg) + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(store, orgService, store.Cfg, nil, nil, quotaService) + require.NoError(t, err) + + u, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) return u.ID } -func TestUpdatingProvisionionedDashboards(t *testing.T) { +func TestIntegrationUpdatingProvisionionedDashboards(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index 0d62275c4b7..251c9389310 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -11,8 +11,11 @@ import ( "path/filepath" "testing" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/stretchr/testify/assert" @@ -27,7 +30,11 @@ const ( var updateSnapshotFlag = false -func TestPlugins(t *testing.T) { +func TestIntegrationPlugins(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + dir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ PluginAdminEnabled: true, }) @@ -115,7 +122,13 @@ func createUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserComma store.Cfg.AutoAssignOrg = true store.Cfg.AutoAssignOrgId = 1 - _, err := store.CreateUser(context.Background(), cmd) + quotaService := quotaimpl.ProvideService(store, store.Cfg) + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(store, orgService, store.Cfg, nil, nil, quotaService) + require.NoError(t, err) + + _, err = usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) } diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index d5aeef74c52..9d1550f56eb 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -22,8 +22,11 @@ import ( "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" ) @@ -342,6 +345,14 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { return tmpDir, cfgPath } +func SQLiteIntegrationTest(t *testing.T) { + t.Helper() + + if testing.Short() || !db.IsTestDbSQLite() { + t.Skip("skipping integration test") + } +} + type GrafanaOpts struct { EnableCSP bool EnableFeatureToggles []string @@ -370,7 +381,13 @@ func CreateUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserComma store.Cfg.AutoAssignOrg = true store.Cfg.AutoAssignOrgId = 1 - u, err := store.CreateUser(context.Background(), cmd) + quotaService := quotaimpl.ProvideService(store, store.Cfg) + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(store, orgService, store.Cfg, nil, nil, quotaService) + require.NoError(t, err) + + u, err := usrSvc.CreateUserForTests(context.Background(), &cmd) require.NoError(t, err) return u.ID } diff --git a/pkg/tests/web/index_view_test.go b/pkg/tests/web/index_view_test.go index 72cf4376b30..e2ef840ede6 100644 --- a/pkg/tests/web/index_view_test.go +++ b/pkg/tests/web/index_view_test.go @@ -12,8 +12,12 @@ import ( "github.com/stretchr/testify/require" ) -// TestIndexView tests the Grafana index view. -func TestIndexView(t *testing.T) { +// TestIntegrationIndexView tests the Grafana index view. +func TestIntegrationIndexView(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + t.Run("CSP enabled", func(t *testing.T) { grafDir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableCSP: true, diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index a3c11572efb..d394156ec11 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/url" - "strconv" "strings" "time" @@ -20,62 +19,12 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesList) run(ctx context.Context, return runTimeSeriesRequest(ctx, timeSeriesFilter.logger, req, s, dsInfo, tracer, timeSeriesFilter.parameters.ProjectName, timeSeriesFilter.params, nil) } -func extractTimeSeriesLabels(series timeSeries, groupBys []string) (data.Labels, string) { - seriesLabels := data.Labels{} - defaultMetricName := series.Metric.Type - seriesLabels["resource.type"] = series.Resource.Type - groupBysMap := make(map[string]bool) - for _, groupBy := range groupBys { - groupBysMap[groupBy] = true - } - - for key, value := range series.Metric.Labels { - seriesLabels["metric.label."+key] = value - - if len(groupBys) == 0 || groupBysMap["metric.label."+key] { - defaultMetricName += " " + value - } - } - - for key, value := range series.Resource.Labels { - seriesLabels["resource.label."+key] = value - - if groupBysMap["resource.label."+key] { - defaultMetricName += " " + value - } - } - - for labelType, labelTypeValues := range series.MetaData { - for labelKey, labelValue := range labelTypeValues { - key := xstrings.ToSnakeCase(fmt.Sprintf("metadata.%s.%s", labelType, labelKey)) - - switch v := labelValue.(type) { - case string: - seriesLabels[key] = v - case bool: - strVal := strconv.FormatBool(v) - seriesLabels[key] = strVal - case []interface{}: - for _, v := range v { - strVal := v.(string) - if len(seriesLabels[key]) > 0 { - strVal = fmt.Sprintf("%s, %s", seriesLabels[key], strVal) - } - seriesLabels[key] = strVal - } - } - } - } - - return seriesLabels, defaultMetricName -} - func parseTimeSeriesResponse(queryRes *backend.DataResponse, response cloudMonitoringResponse, executedQueryString string, query cloudMonitoringQueryExecutor, params url.Values, groupBys []string) error { frames := data.Frames{} for _, series := range response.TimeSeries { - seriesLabels, defaultMetricName := extractTimeSeriesLabels(series, groupBys) + seriesLabels, defaultMetricName := series.getLabels(groupBys) frame := data.NewFrameOfFieldTypes("", len(series.Points), data.FieldTypeTime, data.FieldTypeFloat64) frame.RefID = query.getRefID() frame.Meta = &data.FrameMeta{ diff --git a/pkg/tsdb/cloudmonitoring/time_series_query.go b/pkg/tsdb/cloudmonitoring/time_series_query.go index 541d55067de..20965ea5145 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query.go @@ -3,13 +3,11 @@ package cloudmonitoring import ( "context" "fmt" - "strconv" "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/huandu/xstrings" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/intervalv2" @@ -42,26 +40,6 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, return runTimeSeriesRequest(ctx, timeSeriesQuery.logger, req, s, dsInfo, tracer, timeSeriesQuery.parameters.ProjectName, nil, requestBody) } -func extractTimeSeriesDataLabels(response cloudMonitoringResponse, series timeSeriesData) map[string]string { - seriesLabels := make(map[string]string) - for n, d := range response.TimeSeriesDescriptor.LabelDescriptors { - key := xstrings.ToSnakeCase(d.Key) - key = strings.Replace(key, ".", ".label.", 1) - - labelValue := series.LabelValues[n] - switch d.ValueType { - case "BOOL": - strVal := strconv.FormatBool(labelValue.BoolValue) - seriesLabels[key] = strVal - case "INT64": - seriesLabels[key] = labelValue.Int64Value - default: - seriesLabels[key] = labelValue.StringValue - } - } - return seriesLabels -} - func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *backend.DataResponse, response cloudMonitoringResponse, executedQueryString string) error { frames := data.Frames{} @@ -69,7 +47,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b for _, series := range response.TimeSeriesData { frame := data.NewFrameOfFieldTypes("", len(series.PointData), data.FieldTypeTime, data.FieldTypeFloat64) frame.RefID = timeSeriesQuery.refID - seriesLabels := extractTimeSeriesDataLabels(response, series) + seriesLabels, defaultMetricName := series.getLabels(response.TimeSeriesDescriptor.LabelDescriptors) for n, d := range response.TimeSeriesDescriptor.PointDescriptors { // If more than 1 pointdescriptor was returned, three aggregations are returned per time series - min, mean and max. @@ -81,7 +59,6 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b } seriesLabels["metric.name"] = d.Key - defaultMetricName := d.Key customFrameMeta := map[string]interface{}{} customFrameMeta["labels"] = seriesLabels diff --git a/pkg/tsdb/cloudmonitoring/time_series_query_test.go b/pkg/tsdb/cloudmonitoring/time_series_query_test.go index 025e8cde225..27d4ed79b9a 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query_test.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query_test.go @@ -5,6 +5,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + gdata "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -32,7 +34,7 @@ func TestTimeSeriesQuery(t *testing.T) { } err = query.parseResponse(res, data, "") frames := res.Frames - assert.Equal(t, "value.usage.mean", frames[0].Fields[1].Name) + assert.Equal(t, "grafana-prod asia-northeast1-c 6724404429462225363 200", frames[0].Fields[1].Name) assert.Equal(t, 843302441.9, frames[0].Fields[1].At(0)) }) @@ -105,7 +107,7 @@ func TestTimeSeriesQuery(t *testing.T) { frames := res.Frames custom, ok := frames[0].Meta.Custom.(map[string]interface{}) require.True(t, ok) - labels, ok := custom["labels"].(map[string]string) + labels, ok := custom["labels"].(gdata.Labels) require.True(t, ok) assert.Equal(t, "6724404429462225363", labels["resource.label.instance_id"]) }) diff --git a/pkg/tsdb/cloudmonitoring/types.go b/pkg/tsdb/cloudmonitoring/types.go index 421de8d2746..d42613788a9 100644 --- a/pkg/tsdb/cloudmonitoring/types.go +++ b/pkg/tsdb/cloudmonitoring/types.go @@ -2,10 +2,15 @@ package cloudmonitoring import ( "context" + "fmt" "net/url" + "strconv" + "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/huandu/xstrings" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -139,14 +144,16 @@ type point interface { } type timeSeriesDescriptor struct { - LabelDescriptors []struct { - Key string `json:"key"` - ValueType string `json:"valueType"` - Description string `json:"description"` - } `json:"labelDescriptors"` + LabelDescriptors []LabelDescriptor `json:"labelDescriptors"` PointDescriptors []timeSeriesPointDescriptor `json:"pointDescriptors"` } +type LabelDescriptor struct { + Key string `json:"key"` + ValueType string `json:"valueType"` + Description string `json:"description"` +} + type timeSeriesPointDescriptor struct { Key string `json:"key"` ValueType string `json:"valueType"` @@ -178,6 +185,35 @@ func (ts timeSeriesData) getPoint(index int) point { return &ts.PointData[index] } +func (ts timeSeriesData) getLabels(labelDescriptors []LabelDescriptor) (data.Labels, string) { + seriesLabels := make(map[string]string) + defaultMetricName := "" + + for n, d := range labelDescriptors { + key := xstrings.ToSnakeCase(d.Key) + key = strings.Replace(key, ".", ".label.", 1) + + labelValue := ts.LabelValues[n] + switch d.ValueType { + case "BOOL": + strVal := strconv.FormatBool(labelValue.BoolValue) + seriesLabels[key] = strVal + case "INT64": + seriesLabels[key] = labelValue.Int64Value + default: + seriesLabels[key] = labelValue.StringValue + } + + if strings.Contains(key, "metric.label") || strings.Contains(key, "resource.label") { + defaultMetricName += seriesLabels[key] + " " + } + } + + defaultMetricName = strings.Trim(defaultMetricName, " ") + + return seriesLabels, defaultMetricName +} + type timeSeriesDataIterator struct { timeSeriesData timeSeriesPointDescriptor @@ -250,6 +286,56 @@ func (ts timeSeries) valueType() string { return ts.ValueType } +func (ts timeSeries) getLabels(groupBys []string) (data.Labels, string) { + seriesLabels := data.Labels{} + defaultMetricName := ts.Metric.Type + seriesLabels["resource.type"] = ts.Resource.Type + groupBysMap := make(map[string]bool) + for _, groupBy := range groupBys { + groupBysMap[groupBy] = true + } + + for key, value := range ts.Metric.Labels { + seriesLabels["metric.label."+key] = value + + if len(groupBys) == 0 || groupBysMap["metric.label."+key] { + defaultMetricName += " " + value + } + } + + for key, value := range ts.Resource.Labels { + seriesLabels["resource.label."+key] = value + + if groupBysMap["resource.label."+key] { + defaultMetricName += " " + value + } + } + + for labelType, labelTypeValues := range ts.MetaData { + for labelKey, labelValue := range labelTypeValues { + key := xstrings.ToSnakeCase(fmt.Sprintf("metadata.%s.%s", labelType, labelKey)) + + switch v := labelValue.(type) { + case string: + seriesLabels[key] = v + case bool: + strVal := strconv.FormatBool(v) + seriesLabels[key] = strVal + case []interface{}: + for _, v := range v { + strVal := v.(string) + if len(seriesLabels[key]) > 0 { + strVal = fmt.Sprintf("%s, %s", seriesLabels[key], strVal) + } + seriesLabels[key] = strVal + } + } + } + } + + return seriesLabels, defaultMetricName +} + type timeSeriesPoint struct { Interval struct { StartTime time.Time `json:"startTime"` diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_complex.request.line1.json b/pkg/tsdb/elasticsearch/testdata_request/metric_complex.request.line1.json index 17904da9b67..2b57b03f27b 100644 --- a/pkg/tsdb/elasticsearch/testdata_request/metric_complex.request.line1.json +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_complex.request.line1.json @@ -29,6 +29,7 @@ }, "terms": { "field": "label", + "min_doc_count": 1, "order": { "_key": "desc" }, diff --git a/pkg/tsdb/elasticsearch/testdata_request/metric_simple.request.line1.json b/pkg/tsdb/elasticsearch/testdata_request/metric_simple.request.line1.json index f088a768ac3..c1830002a2a 100644 --- a/pkg/tsdb/elasticsearch/testdata_request/metric_simple.request.line1.json +++ b/pkg/tsdb/elasticsearch/testdata_request/metric_simple.request.line1.json @@ -17,6 +17,7 @@ }, "terms": { "field": "label", + "min_doc_count": 1, "order": { "_key": "desc" }, diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go index 7f472eb741a..649f167b891 100644 --- a/pkg/tsdb/elasticsearch/time_series_query.go +++ b/pkg/tsdb/elasticsearch/time_series_query.go @@ -227,11 +227,7 @@ func (metricAggregation MetricAgg) generateSettingsForDSL() map[string]interface } func (bucketAgg BucketAgg) generateSettingsForDSL() map[string]interface{} { - // TODO: This might also need to be applied to other bucket aggregations and other fields. - switch bucketAgg.Type { - case "date_histogram": - setIntPath(bucketAgg.Settings, "min_doc_count") - } + setIntPath(bucketAgg.Settings, "min_doc_count") return bucketAgg.Settings.MustMap() } @@ -454,7 +450,16 @@ func (p *timeSeriesQueryParser) parseMetrics(model *simplejson.Json) ([]*MetricA metric.Hide = metricJSON.Get("hide").MustBool(false) metric.ID = metricJSON.Get("id").MustString() metric.PipelineAggregate = metricJSON.Get("pipelineAgg").MustString() - metric.Settings = simplejson.NewFromAny(metricJSON.Get("settings").MustMap()) + // In legacy editors, we were storing empty settings values as "null" + // The new editor doesn't store empty strings at all + // We need to ensures backward compatibility with old queries and remove empty fields + settings := metricJSON.Get("settings").MustMap() + for k, v := range settings { + if v == "null" { + delete(settings, k) + } + } + metric.Settings = simplejson.NewFromAny(settings) metric.Meta = simplejson.NewFromAny(metricJSON.Get("meta").MustMap()) metric.Type, err = metricJSON.Get("type").String() if err != nil { diff --git a/pkg/tsdb/elasticsearch/time_series_query_test.go b/pkg/tsdb/elasticsearch/time_series_query_test.go index e3cb75019bc..40880e99a82 100644 --- a/pkg/tsdb/elasticsearch/time_series_query_test.go +++ b/pkg/tsdb/elasticsearch/time_series_query_test.go @@ -39,7 +39,6 @@ func TestExecuteTimeSeriesQuery(t *testing.T) { require.Equal(t, dateHistogramAgg.ExtendedBounds.Min, fromMs) require.Equal(t, dateHistogramAgg.ExtendedBounds.Max, toMs) }) - t.Run("Should clean settings from null values (from frontend tests)", func(t *testing.T) { c := newFakeClient() _, err := executeTsdbQuery(c, `{ @@ -52,8 +51,7 @@ func TestExecuteTimeSeriesQuery(t *testing.T) { firstLevel := sr.Aggs[0] secondLevel := firstLevel.Aggregation.Aggs[0] require.Equal(t, secondLevel.Aggregation.Aggregation.(*es.MetricAggregation).Settings["script"], "1") - // FIXME: This is a bug in implementation, missing is set to "null" instead of being removed - // require.Equal(t, secondLevel.Aggregation.Aggregation.(*es.MetricAggregation).Settings["missing"], nil) + require.NotContains(t, secondLevel.Aggregation.Aggregation.(*es.MetricAggregation).Settings, "missing") }) t.Run("With multiple bucket aggs", func(t *testing.T) { @@ -312,9 +310,9 @@ func TestExecuteTimeSeriesQuery(t *testing.T) { sr := c.multisearchRequests[0].Requests[0] firstLevel := sr.Aggs[0] require.Equal(t, firstLevel.Key, "2") - // FIXME: This is a bug in the current implementation. The min_doc_count is not set. - // termsAgg := firstLevel.Aggregation.Aggregation.(*es.TermsAggregation) - // require.Equal(t, termsAgg.MinDocCount, "1") + termsAgg := firstLevel.Aggregation.Aggregation.(*es.TermsAggregation) + expectedMinDocCount := 1 + require.Equal(t, termsAgg.MinDocCount, &expectedMinDocCount) }) t.Run("With metric percentiles", func(t *testing.T) { diff --git a/pkg/tsdb/loki/frame_test.go b/pkg/tsdb/loki/frame_test.go index e1f57f85a1d..47bbbf7f90a 100644 --- a/pkg/tsdb/loki/frame_test.go +++ b/pkg/tsdb/loki/frame_test.go @@ -95,7 +95,7 @@ func TestAdjustFrame(t *testing.T) { field2.Labels = data.Labels{"app": "Application", "tag2": "tag2"} frame := data.NewFrame("test", field1, field2) - frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMany}) + frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}) query := &lokiQuery{ Expr: "up(ALERTS)", @@ -123,7 +123,7 @@ func TestAdjustFrame(t *testing.T) { field2 := data.NewField("", nil, make([]float64, 0)) frame := data.NewFrame("test", field1, field2) - frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMany}) + frame.SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}) err := adjustFrame(frame, query) require.NoError(t, err) diff --git a/pkg/tsdb/loki/testdata/matrix_gap.golden.jsonc b/pkg/tsdb/loki/testdata/matrix_gap.golden.jsonc index 746d190cd86..42169beb718 100644 --- a/pkg/tsdb/loki/testdata/matrix_gap.golden.jsonc +++ b/pkg/tsdb/loki/testdata/matrix_gap.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {} @@ -28,7 +28,7 @@ "schema": { "name": "{}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/matrix_inf.golden.jsonc b/pkg/tsdb/loki/testdata/matrix_inf.golden.jsonc index eda8ef19b83..80da91d0b75 100644 --- a/pkg/tsdb/loki/testdata/matrix_inf.golden.jsonc +++ b/pkg/tsdb/loki/testdata/matrix_inf.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {level="info", location="moon", protocol="http"} @@ -30,7 +30,7 @@ "schema": { "name": "{level=\"info\", location=\"moon\", protocol=\"http\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/matrix_name.golden.jsonc b/pkg/tsdb/loki/testdata/matrix_name.golden.jsonc index c5b9961f404..66f4f5db496 100644 --- a/pkg/tsdb/loki/testdata/matrix_name.golden.jsonc +++ b/pkg/tsdb/loki/testdata/matrix_name.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {__name__="moon", level="error"} @@ -23,7 +23,7 @@ "schema": { "name": "{__name__=\"moon\", level=\"error\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/matrix_nan.golden.jsonc b/pkg/tsdb/loki/testdata/matrix_nan.golden.jsonc index d3f1dc6a9f0..025b0961691 100644 --- a/pkg/tsdb/loki/testdata/matrix_nan.golden.jsonc +++ b/pkg/tsdb/loki/testdata/matrix_nan.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {} @@ -25,7 +25,7 @@ "schema": { "name": "{}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/matrix_simple.golden.jsonc b/pkg/tsdb/loki/testdata/matrix_simple.golden.jsonc index 7f2295b3399..0e37c6ee222 100644 --- a/pkg/tsdb/loki/testdata/matrix_simple.golden.jsonc +++ b/pkg/tsdb/loki/testdata/matrix_simple.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {level="error", location="moon"} @@ -20,7 +20,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {level="info", location="mars"} @@ -46,7 +46,7 @@ "schema": { "name": "{level=\"error\", location=\"moon\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ @@ -97,7 +97,7 @@ "schema": { "name": "{level=\"info\", location=\"mars\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/matrix_small_step.golden.jsonc b/pkg/tsdb/loki/testdata/matrix_small_step.golden.jsonc index 8e19e4e84c7..7699d3aa09e 100644 --- a/pkg/tsdb/loki/testdata/matrix_small_step.golden.jsonc +++ b/pkg/tsdb/loki/testdata/matrix_small_step.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {level="error"} @@ -26,7 +26,7 @@ "schema": { "name": "{level=\"error\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/matrix_with_stats.golden.jsonc b/pkg/tsdb/loki/testdata/matrix_with_stats.golden.jsonc index 48eb212bfc9..8a34e8866ab 100644 --- a/pkg/tsdb/loki/testdata/matrix_with_stats.golden.jsonc +++ b/pkg/tsdb/loki/testdata/matrix_with_stats.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "stats": [ // { // "displayName": "Summary: bytes processed per second", @@ -126,7 +126,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" // } // Name: {level="info", location="mars"} @@ -149,7 +149,7 @@ "schema": { "name": "{level=\"error\", location=\"moon\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "stats": [ { "displayName": "Summary: bytes processed per second", @@ -304,7 +304,7 @@ "schema": { "name": "{level=\"info\", location=\"mars\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: up(ALERTS)\nStep: 42s" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/vector_simple.golden.jsonc b/pkg/tsdb/loki/testdata/vector_simple.golden.jsonc index 300cb9a6457..759e47d5a55 100644 --- a/pkg/tsdb/loki/testdata/vector_simple.golden.jsonc +++ b/pkg/tsdb/loki/testdata/vector_simple.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: query1" // } // Name: {level="error", location="moon"} @@ -17,7 +17,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: query1" // } // Name: {level="info", location="moon"} @@ -39,7 +39,7 @@ "schema": { "name": "{level=\"error\", location=\"moon\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: query1" }, "fields": [ @@ -81,7 +81,7 @@ "schema": { "name": "{level=\"info\", location=\"moon\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: query1" }, "fields": [ diff --git a/pkg/tsdb/loki/testdata/vector_special_values.golden.jsonc b/pkg/tsdb/loki/testdata/vector_special_values.golden.jsonc index d362bc4d649..25025839664 100644 --- a/pkg/tsdb/loki/testdata/vector_special_values.golden.jsonc +++ b/pkg/tsdb/loki/testdata/vector_special_values.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: query1" // } // Name: {level="error", location="moon"} @@ -17,7 +17,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: query1" // } // Name: {level="info", location="moon"} @@ -33,7 +33,7 @@ // // // Frame[2] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "executedQueryString": "Expr: query1" // } // Name: {level="debug", location="moon"} @@ -55,7 +55,7 @@ "schema": { "name": "{level=\"error\", location=\"moon\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: query1" }, "fields": [ @@ -105,7 +105,7 @@ "schema": { "name": "{level=\"info\", location=\"moon\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: query1" }, "fields": [ @@ -155,7 +155,7 @@ "schema": { "name": "{level=\"debug\", location=\"moon\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "executedQueryString": "Expr: query1" }, "fields": [ diff --git a/pkg/tsdb/prometheus/buffered/time_series_query.go b/pkg/tsdb/prometheus/buffered/time_series_query.go index fa1cdc107ae..a6861ce5a96 100644 --- a/pkg/tsdb/prometheus/buffered/time_series_query.go +++ b/pkg/tsdb/prometheus/buffered/time_series_query.go @@ -688,7 +688,7 @@ func deviation(values []float64) float64 { func newDataFrame(name string, typ string, fields ...*data.Field) *data.Frame { frame := data.NewFrame(name, fields...) frame.Meta = &data.FrameMeta{ - Type: data.FrameTypeTimeSeriesMany, + Type: data.FrameTypeTimeSeriesMulti, Custom: map[string]string{ "resultType": typ, // Note: SSE depends on this property and map type }, diff --git a/pkg/tsdb/prometheus/testdata/range_auto.result.golden.jsonc b/pkg/tsdb/prometheus/testdata/range_auto.result.golden.jsonc index 658c52ef38e..52e2d2c7be0 100644 --- a/pkg/tsdb/prometheus/testdata/range_auto.result.golden.jsonc +++ b/pkg/tsdb/prometheus/testdata/range_auto.result.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // }, @@ -35,7 +35,7 @@ "schema": { "name": "histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[1m0s])) by (le))", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" }, diff --git a/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.jsonc b/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.jsonc index 19e2ade7b27..09ccdfe64d3 100644 --- a/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.jsonc +++ b/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // }, @@ -28,7 +28,7 @@ "schema": { "name": "1 / 0", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" }, diff --git a/pkg/tsdb/prometheus/testdata/range_missing.result.golden.jsonc b/pkg/tsdb/prometheus/testdata/range_missing.result.golden.jsonc index 9214b554fc8..b6dc63c5d68 100644 --- a/pkg/tsdb/prometheus/testdata/range_missing.result.golden.jsonc +++ b/pkg/tsdb/prometheus/testdata/range_missing.result.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // }, @@ -28,7 +28,7 @@ "schema": { "name": "go_goroutines{job=\"prometheus\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" }, diff --git a/pkg/tsdb/prometheus/testdata/range_nan.result.golden.jsonc b/pkg/tsdb/prometheus/testdata/range_nan.result.golden.jsonc index b94f094e1e6..44aa5af58df 100644 --- a/pkg/tsdb/prometheus/testdata/range_nan.result.golden.jsonc +++ b/pkg/tsdb/prometheus/testdata/range_nan.result.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // }, @@ -28,7 +28,7 @@ "schema": { "name": "{handler=\"/api/v1/query_range\", job=\"prometheus\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" }, diff --git a/pkg/tsdb/prometheus/testdata/range_simple.result.golden.jsonc b/pkg/tsdb/prometheus/testdata/range_simple.result.golden.jsonc index 61e74919c29..bb3c642fd05 100644 --- a/pkg/tsdb/prometheus/testdata/range_simple.result.golden.jsonc +++ b/pkg/tsdb/prometheus/testdata/range_simple.result.golden.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // }, @@ -22,7 +22,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // }, @@ -48,7 +48,7 @@ "schema": { "name": "prometheus_http_requests_total{code=\"200\", handler=\"/api/v1/query_range\", job=\"prometheus\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" }, @@ -102,7 +102,7 @@ "schema": { "name": "prometheus_http_requests_total{code=\"400\", handler=\"/api/v1/query_range\", job=\"prometheus\"}", "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" }, diff --git a/pkg/util/converter/prom.go b/pkg/util/converter/prom.go index 5a51b33f2f1..4b9dda60916 100644 --- a/pkg/util/converter/prom.go +++ b/pkg/util/converter/prom.go @@ -330,7 +330,7 @@ func readString(iter *jsoniter.Iterator) *backend.DataResponse { frame := data.NewFrame("", timeField, valueField) frame.Meta = &data.FrameMeta{ - Type: data.FrameTypeTimeSeriesMany, + Type: data.FrameTypeTimeSeriesMulti, Custom: resultTypeToCustomMeta("string"), } @@ -354,7 +354,7 @@ func readScalar(iter *jsoniter.Iterator) *backend.DataResponse { frame := data.NewFrame("", timeField, valueField) frame.Meta = &data.FrameMeta{ - Type: data.FrameTypeTimeSeriesMany, + Type: data.FrameTypeTimeSeriesMulti, Custom: resultTypeToCustomMeta("scalar"), } @@ -544,7 +544,7 @@ func readMatrixOrVectorMulti(iter *jsoniter.Iterator, resultType string) *backen } else { frame := data.NewFrame("", timeField, valueField) frame.Meta = &data.FrameMeta{ - Type: data.FrameTypeTimeSeriesMany, + Type: data.FrameTypeTimeSeriesMulti, Custom: resultTypeToCustomMeta(resultType), } rsp.Frames = append(rsp.Frames, frame) diff --git a/pkg/util/converter/testdata/loki-streams-a-frame.jsonc b/pkg/util/converter/testdata/loki-streams-a-frame.jsonc index ea7680895e7..06559dd9366 100644 --- a/pkg/util/converter/testdata/loki-streams-a-frame.jsonc +++ b/pkg/util/converter/testdata/loki-streams-a-frame.jsonc @@ -54,6 +54,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/loki-streams-a-wide-frame.jsonc b/pkg/util/converter/testdata/loki-streams-a-wide-frame.jsonc index ea7680895e7..06559dd9366 100644 --- a/pkg/util/converter/testdata/loki-streams-a-wide-frame.jsonc +++ b/pkg/util/converter/testdata/loki-streams-a-wide-frame.jsonc @@ -54,6 +54,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/loki-streams-b-frame.jsonc b/pkg/util/converter/testdata/loki-streams-b-frame.jsonc index aba2b7ad6dc..fa04ae09377 100644 --- a/pkg/util/converter/testdata/loki-streams-b-frame.jsonc +++ b/pkg/util/converter/testdata/loki-streams-b-frame.jsonc @@ -53,6 +53,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/loki-streams-b-wide-frame.jsonc b/pkg/util/converter/testdata/loki-streams-b-wide-frame.jsonc index aba2b7ad6dc..fa04ae09377 100644 --- a/pkg/util/converter/testdata/loki-streams-b-wide-frame.jsonc +++ b/pkg/util/converter/testdata/loki-streams-b-wide-frame.jsonc @@ -53,6 +53,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/loki-streams-c-frame.jsonc b/pkg/util/converter/testdata/loki-streams-c-frame.jsonc index 89b7386f7f9..45c917f3e6f 100644 --- a/pkg/util/converter/testdata/loki-streams-c-frame.jsonc +++ b/pkg/util/converter/testdata/loki-streams-c-frame.jsonc @@ -15,6 +15,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/loki-streams-c-wide-frame.jsonc b/pkg/util/converter/testdata/loki-streams-c-wide-frame.jsonc index 89b7386f7f9..45c917f3e6f 100644 --- a/pkg/util/converter/testdata/loki-streams-c-wide-frame.jsonc +++ b/pkg/util/converter/testdata/loki-streams-c-wide-frame.jsonc @@ -15,6 +15,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-exemplars-a-frame.jsonc b/pkg/util/converter/testdata/prom-exemplars-a-frame.jsonc index d1c18e4d4d4..5ecc472e7f0 100644 --- a/pkg/util/converter/testdata/prom-exemplars-a-frame.jsonc +++ b/pkg/util/converter/testdata/prom-exemplars-a-frame.jsonc @@ -36,6 +36,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-exemplars-a-wide-frame.jsonc b/pkg/util/converter/testdata/prom-exemplars-a-wide-frame.jsonc index d1c18e4d4d4..5ecc472e7f0 100644 --- a/pkg/util/converter/testdata/prom-exemplars-a-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-exemplars-a-wide-frame.jsonc @@ -36,6 +36,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-exemplars-b-frame.jsonc b/pkg/util/converter/testdata/prom-exemplars-b-frame.jsonc index 2bf2b6e271e..25369baebe5 100644 --- a/pkg/util/converter/testdata/prom-exemplars-b-frame.jsonc +++ b/pkg/util/converter/testdata/prom-exemplars-b-frame.jsonc @@ -3047,6 +3047,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-exemplars-b-wide-frame.jsonc b/pkg/util/converter/testdata/prom-exemplars-b-wide-frame.jsonc index 2bf2b6e271e..25369baebe5 100644 --- a/pkg/util/converter/testdata/prom-exemplars-b-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-exemplars-b-wide-frame.jsonc @@ -3047,6 +3047,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-labels-frame.jsonc b/pkg/util/converter/testdata/prom-labels-frame.jsonc index 099022f1908..80dea07f3bb 100644 --- a/pkg/util/converter/testdata/prom-labels-frame.jsonc +++ b/pkg/util/converter/testdata/prom-labels-frame.jsonc @@ -23,6 +23,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-labels-wide-frame.jsonc b/pkg/util/converter/testdata/prom-labels-wide-frame.jsonc index 099022f1908..80dea07f3bb 100644 --- a/pkg/util/converter/testdata/prom-labels-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-labels-wide-frame.jsonc @@ -23,6 +23,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-matrix-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-frame.jsonc index dc91656053b..34962e54703 100644 --- a/pkg/util/converter/testdata/prom-matrix-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // } @@ -21,7 +21,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // } @@ -41,11 +41,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" } @@ -90,7 +91,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" } diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc index a1ffaf6edb9..f02ca0ce646 100644 --- a/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-frame.jsonc @@ -1,9 +1,9 @@ // 🌟 This was machine generated. Do not edit. 🌟 -// +// // Frame[0] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 932 Rows // +-------------------------------+------------------------+------------------------+--------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -21,10 +21,11 @@ // | 2022-04-14 19:08:20 +0000 UTC | 9.07293025972535e-06 | 9.894100606163098e-06 | 3.712286008031362 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------+------------------------+--------------------+---------------+ -// -// +// +// // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-wide-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-wide-frame.jsonc index a1ffaf6edb9..f02ca0ce646 100644 --- a/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-histogram-no-labels-wide-frame.jsonc @@ -1,9 +1,9 @@ // 🌟 This was machine generated. Do not edit. 🌟 -// +// // Frame[0] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 932 Rows // +-------------------------------+------------------------+------------------------+--------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -21,10 +21,11 @@ // | 2022-04-14 19:08:20 +0000 UTC | 9.07293025972535e-06 | 9.894100606163098e-06 | 3.712286008031362 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------+------------------------+--------------------+---------------+ -// -// +// +// // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc index dcd6c49b2c1..49c6a3829a6 100644 --- a/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-frame.jsonc @@ -1,9 +1,9 @@ // 🌟 This was machine generated. Do not edit. 🌟 -// +// // Frame[0] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -11,13 +11,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[1] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+---------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -25,13 +25,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+---------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+---------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[2] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -39,13 +39,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[3] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -53,13 +53,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[4] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+-----------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -67,13 +67,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+-----------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+-----------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[5] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -81,13 +81,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[6] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -95,13 +95,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[7] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -109,13 +109,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[8] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 1 Rows // +-------------------------------+--------------------------------------------------------------------------------------------+----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -124,13 +124,13 @@ // +-------------------------------+--------------------------------------------------------------------------------------------+----------------------+----------------------+---------------+ // | 2022-04-14 19:08:20 +0000 UTC | 0.05255602595335715 | 0.057312752700291944 | 0.003508784241348215 | 0 | // +-------------------------------+--------------------------------------------------------------------------------------------+----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[9] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -138,13 +138,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[10] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 426 Rows // +-------------------------------+------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -162,13 +162,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.002762135864009951 | 0.0030121305183748843 | 0.30175469375429953 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[11] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 1 Rows // +-------------------------------+-------------------------------------------------------------------------------------+--------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -177,13 +177,13 @@ // +-------------------------------+-------------------------------------------------------------------------------------+--------------------+-----------------------+---------------+ // | 2022-04-14 19:08:20 +0000 UTC | 1 | 1.0905077326652577 | 0.0035087596183873042 | 0 | // +-------------------------------+-------------------------------------------------------------------------------------+--------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[12] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 6 Rows // +-------------------------------+-----------------------------------------------------------------------------------+--------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -197,13 +197,13 @@ // | 2022-04-14 19:13:20 +0000 UTC | 0.8408964152537144 | 0.9170040432046711 | 0.003508771929824561 | 0 | // | 2022-04-14 19:13:20 +0000 UTC | 1 | 1.0905077326652577 | 0.003508771929824561 | 0 | // +-------------------------------+-----------------------------------------------------------------------------------+--------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[13] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 269 Rows // +-------------------------------+---------------------------------------------------------------------------+------------------------+--------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -221,13 +221,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 9.07293025972535e-06 | 9.894100606163098e-06 | 3.533338799658994 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+---------------------------------------------------------------------------+------------------------+--------------------+---------------+ -// -// -// +// +// +// // Frame[14] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 303 Rows // +-------------------------------+-----------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -245,13 +245,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.005065779510355506 | 0.005524271728019902 | 0.06666662973235489 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-----------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[15] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 56 Rows // +-------------------------------+------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -269,13 +269,13 @@ // | 2022-04-14 19:10:00 +0000 UTC | 0.005524271728019902 | 0.0060242610367497685 | 0.00713925 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[16] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 41 Rows // +-------------------------------+-----------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -293,13 +293,13 @@ // | 2022-04-14 19:10:00 +0000 UTC | 0.0060242610367497685 | 0.006569503244169644 | 0.0035300277777777778 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-----------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[17] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 29 Rows // +-------------------------------+-----------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -317,13 +317,13 @@ // | 2022-04-14 19:11:40 +0000 UTC | 0.04052623608284405 | 0.044194173824159216 | 0.003508771929824561 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-----------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[18] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 38 Rows // +-------------------------------+------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -341,13 +341,13 @@ // | 2022-04-14 19:11:40 +0000 UTC | 0.0060242610367497685 | 0.006569503244169644 | 0.003508771929824561 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[19] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 195 Rows // +-------------------------------+---------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -365,13 +365,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.0021298979153618314 | 0.0023226701464896895 | 0.07368416128056675 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+---------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[20] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 261 Rows // +-------------------------------+-------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -389,13 +389,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.0009765625 | 0.0010649489576809157 | 0.03508765774105932 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[21] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 176 Rows // +-------------------------------+-------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -413,13 +413,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.009290680585958758 | 0.010131559020711013 | 0.003508771929824561 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[22] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 255 Rows // +-------------------------------+-------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -437,13 +437,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.04052623608284405 | 0.044194173824159216 | 0 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[23] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 167 Rows // +-------------------------------+------------------------------------------------------------------------------------+------------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -461,10 +461,11 @@ // | 2022-04-14 19:08:20 +0000 UTC | 1.1766134837401892e-05 | 1.2831061023768835e-05 | 0.1578945767934209 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------+------------------------+-----------------------+---------------+ -// -// +// +// // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-wide-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-wide-frame.jsonc index dcd6c49b2c1..49c6a3829a6 100644 --- a/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-histogram-partitioned-wide-frame.jsonc @@ -1,9 +1,9 @@ // 🌟 This was machine generated. Do not edit. 🌟 -// +// // Frame[0] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -11,13 +11,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[1] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+---------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -25,13 +25,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+---------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+---------------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[2] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -39,13 +39,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[3] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -53,13 +53,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[4] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+-----------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -67,13 +67,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+-----------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+-----------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[5] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -81,13 +81,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[6] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -95,13 +95,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+------------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[7] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+--------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -109,13 +109,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+--------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+--------------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[8] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 1 Rows // +-------------------------------+--------------------------------------------------------------------------------------------+----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -124,13 +124,13 @@ // +-------------------------------+--------------------------------------------------------------------------------------------+----------------------+----------------------+---------------+ // | 2022-04-14 19:08:20 +0000 UTC | 0.05255602595335715 | 0.057312752700291944 | 0.003508784241348215 | 0 | // +-------------------------------+--------------------------------------------------------------------------------------------+----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[9] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 0 Rows // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -138,13 +138,13 @@ // | Type: []time.Time | Type: []float64 | Type: []float64 | Type: []float64 | Type: []int8 | // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ // +-------------------+---------------------------------------------------------------------------------------------+-----------------+-----------------+---------------+ -// -// -// +// +// +// // Frame[10] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 426 Rows // +-------------------------------+------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -162,13 +162,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.002762135864009951 | 0.0030121305183748843 | 0.30175469375429953 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[11] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 1 Rows // +-------------------------------+-------------------------------------------------------------------------------------+--------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -177,13 +177,13 @@ // +-------------------------------+-------------------------------------------------------------------------------------+--------------------+-----------------------+---------------+ // | 2022-04-14 19:08:20 +0000 UTC | 1 | 1.0905077326652577 | 0.0035087596183873042 | 0 | // +-------------------------------+-------------------------------------------------------------------------------------+--------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[12] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 6 Rows // +-------------------------------+-----------------------------------------------------------------------------------+--------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -197,13 +197,13 @@ // | 2022-04-14 19:13:20 +0000 UTC | 0.8408964152537144 | 0.9170040432046711 | 0.003508771929824561 | 0 | // | 2022-04-14 19:13:20 +0000 UTC | 1 | 1.0905077326652577 | 0.003508771929824561 | 0 | // +-------------------------------+-----------------------------------------------------------------------------------+--------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[13] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 269 Rows // +-------------------------------+---------------------------------------------------------------------------+------------------------+--------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -221,13 +221,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 9.07293025972535e-06 | 9.894100606163098e-06 | 3.533338799658994 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+---------------------------------------------------------------------------+------------------------+--------------------+---------------+ -// -// -// +// +// +// // Frame[14] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 303 Rows // +-------------------------------+-----------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -245,13 +245,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.005065779510355506 | 0.005524271728019902 | 0.06666662973235489 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-----------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[15] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 56 Rows // +-------------------------------+------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -269,13 +269,13 @@ // | 2022-04-14 19:10:00 +0000 UTC | 0.005524271728019902 | 0.0060242610367497685 | 0.00713925 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[16] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 41 Rows // +-------------------------------+-----------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -293,13 +293,13 @@ // | 2022-04-14 19:10:00 +0000 UTC | 0.0060242610367497685 | 0.006569503244169644 | 0.0035300277777777778 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-----------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[17] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 29 Rows // +-------------------------------+-----------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -317,13 +317,13 @@ // | 2022-04-14 19:11:40 +0000 UTC | 0.04052623608284405 | 0.044194173824159216 | 0.003508771929824561 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-----------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[18] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 38 Rows // +-------------------------------+------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -341,13 +341,13 @@ // | 2022-04-14 19:11:40 +0000 UTC | 0.0060242610367497685 | 0.006569503244169644 | 0.003508771929824561 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------------------------+----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[19] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 195 Rows // +-------------------------------+---------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -365,13 +365,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.0021298979153618314 | 0.0023226701464896895 | 0.07368416128056675 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+---------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[20] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 261 Rows // +-------------------------------+-------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -389,13 +389,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.0009765625 | 0.0010649489576809157 | 0.03508765774105932 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[21] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 176 Rows // +-------------------------------+-------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -413,13 +413,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.009290680585958758 | 0.010131559020711013 | 0.003508771929824561 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-------------------------------------------------------------------------------------------------------+-----------------------+-----------------------+---------------+ -// -// -// +// +// +// // Frame[22] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 255 Rows // +-------------------------------+-------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -437,13 +437,13 @@ // | 2022-04-14 19:08:20 +0000 UTC | 0.04052623608284405 | 0.044194173824159216 | 0 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+-------------------------------------------------------------------------------------------------------------+-----------------------+----------------------+---------------+ -// -// -// +// +// +// // Frame[23] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 167 Rows // +-------------------------------+------------------------------------------------------------------------------------+------------------------+-----------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -461,10 +461,11 @@ // | 2022-04-14 19:08:20 +0000 UTC | 1.1766134837401892e-05 | 1.2831061023768835e-05 | 0.1578945767934209 | 0 | // | ... | ... | ... | ... | ... | // +-------------------------------+------------------------------------------------------------------------------------+------------------------+-----------------------+---------------+ -// -// +// +// // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-matrix-wide-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-wide-frame.jsonc index 5ef32b61126..affb83ab5b2 100644 --- a/pkg/util/converter/testdata/prom-matrix-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-wide-frame.jsonc @@ -21,6 +21,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-matrix-with-nans-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-with-nans-frame.jsonc index 2daafd89b11..f527d47df0d 100644 --- a/pkg/util/converter/testdata/prom-matrix-with-nans-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-with-nans-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "matrix" // } @@ -21,11 +21,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "matrix" } diff --git a/pkg/util/converter/testdata/prom-matrix-with-nans-wide-frame.jsonc b/pkg/util/converter/testdata/prom-matrix-with-nans-wide-frame.jsonc index 8bb09734b4b..c53e80c0c73 100644 --- a/pkg/util/converter/testdata/prom-matrix-with-nans-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-matrix-with-nans-wide-frame.jsonc @@ -21,6 +21,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-scalar-frame.jsonc b/pkg/util/converter/testdata/prom-scalar-frame.jsonc index 898d6f03e73..bd0d2cacdd5 100644 --- a/pkg/util/converter/testdata/prom-scalar-frame.jsonc +++ b/pkg/util/converter/testdata/prom-scalar-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "scalar" // } @@ -19,11 +19,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "scalar" } diff --git a/pkg/util/converter/testdata/prom-scalar-wide-frame.jsonc b/pkg/util/converter/testdata/prom-scalar-wide-frame.jsonc index 898d6f03e73..bd0d2cacdd5 100644 --- a/pkg/util/converter/testdata/prom-scalar-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-scalar-wide-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "scalar" // } @@ -19,11 +19,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "scalar" } diff --git a/pkg/util/converter/testdata/prom-series-frame.jsonc b/pkg/util/converter/testdata/prom-series-frame.jsonc index 49e103ead0d..295b59823ed 100644 --- a/pkg/util/converter/testdata/prom-series-frame.jsonc +++ b/pkg/util/converter/testdata/prom-series-frame.jsonc @@ -16,6 +16,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-series-wide-frame.jsonc b/pkg/util/converter/testdata/prom-series-wide-frame.jsonc index 49e103ead0d..295b59823ed 100644 --- a/pkg/util/converter/testdata/prom-series-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-series-wide-frame.jsonc @@ -16,6 +16,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-string-frame.jsonc b/pkg/util/converter/testdata/prom-string-frame.jsonc index 33b3daf5d9e..6307e2916bc 100644 --- a/pkg/util/converter/testdata/prom-string-frame.jsonc +++ b/pkg/util/converter/testdata/prom-string-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "string" // } @@ -19,11 +19,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "string" } diff --git a/pkg/util/converter/testdata/prom-string-wide-frame.jsonc b/pkg/util/converter/testdata/prom-string-wide-frame.jsonc index 33b3daf5d9e..6307e2916bc 100644 --- a/pkg/util/converter/testdata/prom-string-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-string-wide-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "string" // } @@ -19,11 +19,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "string" } diff --git a/pkg/util/converter/testdata/prom-vector-frame.jsonc b/pkg/util/converter/testdata/prom-vector-frame.jsonc index 587cba760ac..cdd2532fa5f 100644 --- a/pkg/util/converter/testdata/prom-vector-frame.jsonc +++ b/pkg/util/converter/testdata/prom-vector-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // } @@ -19,7 +19,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // } @@ -37,7 +37,7 @@ // // // Frame[2] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // } @@ -55,7 +55,7 @@ // // // Frame[3] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // } @@ -73,7 +73,7 @@ // // // Frame[4] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // } @@ -91,11 +91,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" } @@ -136,7 +137,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" } @@ -177,7 +178,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" } @@ -225,7 +226,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" } @@ -273,7 +274,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" } diff --git a/pkg/util/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc b/pkg/util/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc index e6f3f1ec5bf..21f3e31e35e 100644 --- a/pkg/util/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc +++ b/pkg/util/converter/testdata/prom-vector-histogram-no-labels-frame.jsonc @@ -1,9 +1,9 @@ // 🌟 This was machine generated. Do not edit. 🌟 -// +// // Frame[0] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 134 Rows // +-----------------------------------+------------------------+------------------------+---------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -21,10 +21,11 @@ // | 2022-04-14 20:21:08.042 +0000 UTC | 9.07293025972535e-06 | 9.894100606163098e-06 | 3.392982456140351 | 0 | // | ... | ... | ... | ... | ... | // +-----------------------------------+------------------------+------------------------+---------------------+---------------+ -// -// +// +// // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-vector-histogram-no-labels-wide-frame.jsonc b/pkg/util/converter/testdata/prom-vector-histogram-no-labels-wide-frame.jsonc index e6f3f1ec5bf..21f3e31e35e 100644 --- a/pkg/util/converter/testdata/prom-vector-histogram-no-labels-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-vector-histogram-no-labels-wide-frame.jsonc @@ -1,9 +1,9 @@ // 🌟 This was machine generated. Do not edit. 🌟 -// +// // Frame[0] { // "type": "heatmap-cells" // } -// Name: +// Name: // Dimensions: 5 Fields by 134 Rows // +-----------------------------------+------------------------+------------------------+---------------------+---------------+ // | Name: xMax | Name: yMin | Name: yMax | Name: count | Name: yLayout | @@ -21,10 +21,11 @@ // | 2022-04-14 20:21:08.042 +0000 UTC | 9.07293025972535e-06 | 9.894100606163098e-06 | 3.392982456140351 | 0 | // | ... | ... | ... | ... | ... | // +-----------------------------------+------------------------+------------------------+---------------------+---------------+ -// -// +// +// // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-vector-wide-frame.jsonc b/pkg/util/converter/testdata/prom-vector-wide-frame.jsonc index de2405c1fab..e6cd4985d55 100644 --- a/pkg/util/converter/testdata/prom-vector-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-vector-wide-frame.jsonc @@ -20,6 +20,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/pkg/util/converter/testdata/prom-warnings-frame.jsonc b/pkg/util/converter/testdata/prom-warnings-frame.jsonc index 26f89f1ac1a..6f88319e60b 100644 --- a/pkg/util/converter/testdata/prom-warnings-frame.jsonc +++ b/pkg/util/converter/testdata/prom-warnings-frame.jsonc @@ -1,7 +1,7 @@ // 🌟 This was machine generated. Do not edit. 🌟 // // Frame[0] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // }, @@ -29,7 +29,7 @@ // // // Frame[1] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // }, @@ -57,7 +57,7 @@ // // // Frame[2] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // }, @@ -85,7 +85,7 @@ // // // Frame[3] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // }, @@ -113,7 +113,7 @@ // // // Frame[4] { -// "type": "timeseries-many", +// "type": "timeseries-multi", // "custom": { // "resultType": "vector" // }, @@ -141,11 +141,12 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" }, @@ -196,7 +197,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" }, @@ -247,7 +248,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" }, @@ -305,7 +306,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" }, @@ -363,7 +364,7 @@ { "schema": { "meta": { - "type": "timeseries-many", + "type": "timeseries-multi", "custom": { "resultType": "vector" }, diff --git a/pkg/util/converter/testdata/prom-warnings-wide-frame.jsonc b/pkg/util/converter/testdata/prom-warnings-wide-frame.jsonc index c4b1c7aa96f..36fac2a7969 100644 --- a/pkg/util/converter/testdata/prom-warnings-wide-frame.jsonc +++ b/pkg/util/converter/testdata/prom-warnings-wide-frame.jsonc @@ -30,6 +30,7 @@ // // 🌟 This was machine generated. Do not edit. 🌟 { + "status": 200, "frames": [ { "schema": { diff --git a/public/app/core/components/Page/usePageTitle.ts b/public/app/core/components/Page/usePageTitle.ts index 7a9e0e025a5..c2e53b23251 100644 --- a/public/app/core/components/Page/usePageTitle.ts +++ b/public/app/core/components/Page/usePageTitle.ts @@ -8,7 +8,7 @@ import { Branding } from '../Branding/Branding'; import { buildBreadcrumbs } from '../Breadcrumbs/utils'; export function usePageTitle(navModel?: NavModel, pageNav?: NavModelItem) { - const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; + const homeNav = useSelector((state) => state.navIndex)?.[HOME_NAV_ID]; useEffect(() => { const sectionNav = (navModel?.node !== navModel?.main ? navModel?.node : navModel?.main) ?? { text: 'Grafana' }; const parts: string[] = buildBreadcrumbs(sectionNav, pageNav, homeNav) diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 69816af915f..dc8702752dd 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -141,12 +141,6 @@ const getStyles = (theme: GrafanaTheme2, hasSplit: boolean) => { `; return { - singleLeftPane: css` - height: 100%; - position: absolute; - overflow: hidden; - width: 100%; - `, resizerV: cx( resizer, css` diff --git a/public/app/core/components/TimePicker/TimePickerWithHistory.test.tsx b/public/app/core/components/TimePicker/TimePickerWithHistory.test.tsx new file mode 100644 index 00000000000..c0f294619d5 --- /dev/null +++ b/public/app/core/components/TimePicker/TimePickerWithHistory.test.tsx @@ -0,0 +1,132 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { getDefaultTimeRange } from '@grafana/data'; + +import { TimePickerWithHistory } from './TimePickerWithHistory'; + +describe('TimePickerWithHistory', () => { + // In some of the tests we close and re-open the picker. When we do that we must re-find these inputs + // as new elements will have been mounted + const getFromField = () => screen.getByLabelText('Time Range from field'); + const getToField = () => screen.getByLabelText('Time Range to field'); + const getApplyButton = () => screen.getByRole('button', { name: 'Apply time range' }); + + const LOCAL_STORAGE_KEY = 'grafana.dashboard.timepicker.history'; + const OLD_LOCAL_STORAGE = [ + { + from: '2022-12-03T00:00:00.000Z', + to: '2022-12-03T23:59:59.000Z', + raw: { from: '2022-12-03T00:00:00.000Z', to: '2022-12-03T23:59:59.000Z' }, + }, + { + from: '2022-12-02T00:00:00.000Z', + to: '2022-12-02T23:59:59.000Z', + raw: { from: '2022-12-02T00:00:00.000Z', to: '2022-12-02T23:59:59.000Z' }, + }, + ]; + + const NEW_LOCAL_STORAGE = [ + { from: '2022-12-03T00:00:00.000Z', to: '2022-12-03T23:59:59.000Z' }, + { from: '2022-12-02T00:00:00.000Z', to: '2022-12-02T23:59:59.000Z' }, + ]; + + const props = { + timeZone: 'utc', + onChange: () => {}, + onChangeTimeZone: () => {}, + onMoveBackward: () => {}, + onMoveForward: () => {}, + onZoom: () => {}, + }; + + afterEach(() => window.localStorage.clear()); + + it('Should load with no history', async () => { + const timeRange = getDefaultTimeRange(); + render(); + await userEvent.click(screen.getByLabelText(/Time range selected/)); + + expect(screen.getByText(/It looks like you haven't used this time picker before/i)).toBeInTheDocument(); + }); + + it('Should load with old TimeRange history', async () => { + window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(OLD_LOCAL_STORAGE)); + + const timeRange = getDefaultTimeRange(); + render(); + await userEvent.click(screen.getByLabelText(/Time range selected/)); + + expect(screen.getByText(/2022-12-03 00:00:00 to 2022-12-03 23:59:59/i)).toBeInTheDocument(); + expect(screen.queryByText(/2022-12-02 00:00:00 to 2022-12-02 23:59:59/i)).toBeInTheDocument(); + }); + + it('Should load with new TimePickerHistoryItem history', async () => { + window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(NEW_LOCAL_STORAGE)); + + const timeRange = getDefaultTimeRange(); + render(); + await userEvent.click(screen.getByLabelText(/Time range selected/)); + + expect(screen.queryByText(/2022-12-03 00:00:00 to 2022-12-03 23:59:59/i)).toBeInTheDocument(); + expect(screen.queryByText(/2022-12-02 00:00:00 to 2022-12-02 23:59:59/i)).toBeInTheDocument(); + }); + + it('Saves changes into local storage without duplicates', async () => { + const timeRange = getDefaultTimeRange(); + render(); + await userEvent.click(screen.getByLabelText(/Time range selected/)); + + await clearAndType(getFromField(), '2022-12-03 00:00:00'); + await clearAndType(getToField(), '2022-12-03 23:59:59'); + await userEvent.click(getApplyButton()); + + await userEvent.click(screen.getByLabelText(/Time range selected/)); + + // Same range again! + await clearAndType(getFromField(), '2022-12-03 00:00:00'); + await clearAndType(getToField(), '2022-12-03 23:59:59'); + await userEvent.click(getApplyButton()); + + const newLsValue = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY) ?? '[]'); + expect(newLsValue).toEqual([{ from: '2022-12-03T00:00:00.000Z', to: '2022-12-03T23:59:59.000Z' }]); + }); + + it('Should show 4 most recently used time ranges', async () => { + const inputRanges: Array<[string, string]> = [ + ['2022-12-10 00:00:00', '2022-12-10 23:59:59'], + ['2022-12-11 00:00:00', '2022-12-11 23:59:59'], + ['2022-12-12 00:00:00', '2022-12-12 23:59:59'], + ['2022-12-13 00:00:00', '2022-12-13 23:59:59'], + ['2022-12-14 00:00:00', '2022-12-14 23:59:59'], + ]; + + const expectedLocalStorage = [ + { from: '2022-12-14T00:00:00.000Z', to: '2022-12-14T23:59:59.000Z' }, + { from: '2022-12-13T00:00:00.000Z', to: '2022-12-13T23:59:59.000Z' }, + { from: '2022-12-12T00:00:00.000Z', to: '2022-12-12T23:59:59.000Z' }, + { from: '2022-12-11T00:00:00.000Z', to: '2022-12-11T23:59:59.000Z' }, + ]; + + const timeRange = getDefaultTimeRange(); + render(); + await userEvent.click(screen.getByLabelText(/Time range selected/)); + + for (const [inputFrom, inputTo] of inputRanges) { + await userEvent.click(screen.getByLabelText(/Time range selected/)); + await clearAndType(getFromField(), inputFrom); + await clearAndType(getToField(), inputTo); + + await userEvent.click(getApplyButton()); + } + + const newLsValue = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY) ?? '[]'); + expect(newLsValue).toEqual(expectedLocalStorage); + }); +}); + +async function clearAndType(field: HTMLElement, text: string) { + await userEvent.clear(field); + return await userEvent.type(field, text); +} diff --git a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx index 4cacb3dda58..4a18ada7875 100644 --- a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx +++ b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx @@ -1,6 +1,7 @@ +import { uniqBy } from 'lodash'; import React from 'react'; -import { TimeRange, isDateTime, toUtc } from '@grafana/data'; +import { TimeRange, isDateTime, rangeUtil, TimeZone } from '@grafana/data'; import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui'; import { LocalStorageValueProvider } from '../LocalStorageValueProvider'; @@ -9,14 +10,26 @@ const LOCAL_STORAGE_KEY = 'grafana.dashboard.timepicker.history'; interface Props extends Omit {} +// Simplified object to store in local storage +interface TimePickerHistoryItem { + from: string; + to: string; +} + +// We should only be storing TimePickerHistoryItem, but in the past we also stored TimeRange +type LSTimePickerHistoryItem = TimePickerHistoryItem | TimeRange; + export const TimePickerWithHistory = (props: Props) => { return ( - storageKey={LOCAL_STORAGE_KEY} defaultValue={[]}> - {(values, onSaveToStore) => { + storageKey={LOCAL_STORAGE_KEY} defaultValue={[]}> + {(rawValues, onSaveToStore) => { + const values = migrateHistory(rawValues); + const history = deserializeHistory(values, props.timeZone); + return ( { onAppendToHistory(value, values, onSaveToStore); props.onChange(value); @@ -28,24 +41,37 @@ export const TimePickerWithHistory = (props: Props) => { ); }; -function convertIfJson(history: TimeRange[]): TimeRange[] { - return history.map((time) => { - if (isDateTime(time.from)) { - return time; - } +function deserializeHistory(values: TimePickerHistoryItem[], timeZone: TimeZone | undefined): TimeRange[] { + return values.map((item) => rangeUtil.convertRawToRange(item, timeZone)); +} + +function migrateHistory(values: LSTimePickerHistoryItem[]): TimePickerHistoryItem[] { + return values.map((item) => { + const fromValue = typeof item.from === 'string' ? item.from : item.from.toISOString(); + const toValue = typeof item.to === 'string' ? item.to : item.to.toISOString(); return { - from: toUtc(time.from), - to: toUtc(time.to), - raw: time.raw, + from: fromValue, + to: toValue, }; }); } -function onAppendToHistory(toAppend: TimeRange, values: TimeRange[], onSaveToStore: (values: TimeRange[]) => void) { - if (!isAbsolute(toAppend)) { +function onAppendToHistory( + newTimeRange: TimeRange, + values: TimePickerHistoryItem[], + onSaveToStore: (values: TimePickerHistoryItem[]) => void +) { + if (!isAbsolute(newTimeRange)) { return; } + + // Convert DateTime objects to strings + const toAppend = { + from: typeof newTimeRange.raw.from === 'string' ? newTimeRange.raw.from : newTimeRange.raw.from.toISOString(), + to: typeof newTimeRange.raw.to === 'string' ? newTimeRange.raw.to : newTimeRange.raw.to.toISOString(), + }; + const toStore = limit([toAppend, ...values]); onSaveToStore(toStore); } @@ -54,6 +80,6 @@ function isAbsolute(value: TimeRange): boolean { return isDateTime(value.raw.from) || isDateTime(value.raw.to); } -function limit(value: TimeRange[]): TimeRange[] { - return value.slice(0, 4); +function limit(value: TimePickerHistoryItem[]): TimePickerHistoryItem[] { + return uniqBy(value, (v) => v.from + v.to).slice(0, 4); } diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 3920e53eb61..39d935f07fc 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -24,7 +24,7 @@ import { AppChromeService } from '../components/AppChrome/AppChromeService'; import { HelpModal } from '../components/help/HelpModal'; import { contextSrv } from '../core'; -import { toggleTheme } from './toggleTheme'; +import { toggleTheme } from './theme'; import { withFocusedPanel } from './withFocusedPanelId'; export class KeybindingSrv { diff --git a/public/app/core/services/toggleTheme.ts b/public/app/core/services/theme.ts similarity index 90% rename from public/app/core/services/toggleTheme.ts rename to public/app/core/services/theme.ts index ee274847ce9..98d91b3837d 100644 --- a/public/app/core/services/toggleTheme.ts +++ b/public/app/core/services/theme.ts @@ -7,11 +7,10 @@ import { contextSrv } from '../core'; import { PreferencesService } from './PreferencesService'; -export async function toggleTheme(runtimeOnly: boolean) { - const currentTheme = config.theme2; +export async function changeTheme(mode: 'dark' | 'light', runtimeOnly?: boolean) { const newTheme = createTheme({ colors: { - mode: currentTheme.isDark ? 'light' : 'dark', + mode: mode, }, }); @@ -55,3 +54,8 @@ export async function toggleTheme(runtimeOnly: boolean) { theme: newTheme.colors.mode, }); } + +export async function toggleTheme(runtimeOnly: boolean) { + const currentTheme = config.theme2; + changeTheme(currentTheme.isDark ? 'light' : 'dark', runtimeOnly); +} diff --git a/public/app/core/utils/query.test.ts b/public/app/core/utils/query.test.ts index 4b2028ce544..efd5f1944bf 100644 --- a/public/app/core/utils/query.test.ts +++ b/public/app/core/utils/query.test.ts @@ -1,6 +1,10 @@ import { DataQuery } from '@grafana/data'; -import { getNextRefIdChar } from './query'; +import { getNextRefIdChar, queryIsEmpty } from './query'; + +export interface TestQuery extends DataQuery { + name?: string; +} function dataQueryHelper(ids: string[]): DataQuery[] { return ids.map((letter) => { @@ -29,3 +33,20 @@ describe('Get next refId char', () => { expect(getNextRefIdChar(singleExtendedDataQuery)).toEqual('AA'); }); }); + +describe('queryIsEmpty', () => { + it('should return true if query only includes props that are defined in the DataQuery interface', () => { + const testQuery: DataQuery = { refId: 'A' }; + expect(queryIsEmpty(testQuery)).toEqual(true); + }); + + it('should return true if query only includes props that are defined in the DataQuery interface and a label prop', () => { + const testQuery: DataQuery & { label: string } = { refId: 'A', label: '' }; + expect(queryIsEmpty(testQuery)).toEqual(true); + }); + + it('should return false if query only includes props that are not defined in the DataQuery interface', () => { + const testQuery: TestQuery = { refId: 'A', name: 'test' }; + expect(queryIsEmpty(testQuery)).toEqual(false); + }); +}); diff --git a/public/app/core/utils/query.ts b/public/app/core/utils/query.ts index 4a771ca64e3..11fca8e6898 100644 --- a/public/app/core/utils/query.ts +++ b/public/app/core/utils/query.ts @@ -9,6 +9,23 @@ export const getNextRefIdChar = (queries: DataQuery[]): string => { } }; +// This function checks if the query has defined properties beyond those defined in the DataQuery interface. +export function queryIsEmpty(query: DataQuery): boolean { + const dataQueryProps = ['refId', 'hide', 'key', 'queryType', 'datasource']; + + for (const key in query) { + // label is not a DataQuery prop, but it's defined in the query when called from the QueryRunner. + if (key === 'label') { + continue; + } + if (!dataQueryProps.includes(key)) { + return false; + } + } + + return true; +} + export function addQuery(queries: DataQuery[], query?: Partial, datasource?: DataSourceRef): DataQuery[] { const q = query || {}; q.refId = getNextRefIdChar(queries); diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx deleted file mode 100644 index bad0f3cc75d..00000000000 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ /dev/null @@ -1,687 +0,0 @@ -import { Matcher, render, waitFor, screen, within } from '@testing-library/react'; -import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { Route, Router } from 'react-router-dom'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; -import { byRole, byTestId, byText } from 'testing-library-selector'; - -import { DataSourceInstanceSettings } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; -import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker'; -import { contextSrv } from 'app/core/services/context_srv'; -import { DashboardSearchHit } from 'app/features/search/types'; -import { configureStore } from 'app/store/configureStore'; -import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; - -import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; -import { backendSrv } from '../../../core/services/backend_srv'; -import { AccessControlAction } from '../../../types'; - -import RuleEditor from './RuleEditor'; -import { discoverFeatures } from './api/buildInfo'; -import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks'; -import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; -import * as config from './utils/config'; -import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; -import { getDefaultQueries } from './utils/rule-form'; - -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ - // eslint-disable-next-line react/display-name - ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( - onChange(e.target.value)} /> - ), -})); - -jest.mock('./api/buildInfo'); -jest.mock('./api/ruler'); -jest.mock('../../../../app/features/manage-dashboards/state/actions'); - -// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. -// lets just skip it -jest.mock('app/features/query/components/QueryEditorRow', () => ({ - // eslint-disable-next-line react/display-name - QueryEditorRow: () =>

hi

, -})); - -jest.spyOn(config, 'getAllDataSources'); - -const mocks = { - getAllDataSources: jest.mocked(config.getAllDataSources), - searchFolders: jest.mocked(searchFolders), - api: { - discoverFeatures: jest.mocked(discoverFeatures), - fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), - setRulerRuleGroup: jest.mocked(setRulerRuleGroup), - fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), - fetchRulerRules: jest.mocked(fetchRulerRules), - fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), - }, -}; - -function renderRuleEditor(identifier?: string) { - const store = configureStore(); - - locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); - - return render( - - - - - - ); -} - -const ui = { - inputs: { - name: byRole('textbox', { name: /rule name name for the alert rule\./i }), - alertType: byTestId('alert-type-picker'), - dataSource: byTestId('datasource-picker'), - folder: byTestId('folder-picker'), - folderContainer: byTestId(selectors.components.FolderPicker.containerV2), - namespace: byTestId('namespace-picker'), - group: byTestId('group-picker'), - annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`), - annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), - labelKey: (idx: number) => byTestId(`label-key-${idx}`), - labelValue: (idx: number) => byTestId(`label-value-${idx}`), - expr: byTestId('expr'), - }, - buttons: { - save: byRole('button', { name: 'Save' }), - addAnnotation: byRole('button', { name: /Add info/ }), - addLabel: byRole('button', { name: /Add label/ }), - // alert type buttons - grafanaManagedAlert: byRole('button', { name: /Grafana managed/ }), - lotexAlert: byRole('button', { name: /Mimir or Loki alert/ }), - lotexRecordingRule: byRole('button', { name: /Mimir or Loki recording rule/ }), - }, -}; - -const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); - -// Until flakiness is fixed -// https://github.com/grafana/grafana/issues/58747 -describe.skip('RuleEditor', () => { - beforeEach(() => { - jest.clearAllMocks(); - contextSrv.isEditor = true; - contextSrv.hasEditPermissionInFolders = true; - }); - - disableRBAC(); - - it('can create a new cloud alert', async () => { - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - namespace1: [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([]); - - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Cortex, - features: { - rulerApiEnabled: true, - }, - }); - - await renderRuleEditor(); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - - await userEvent.click(await ui.buttons.lotexAlert.find()); - - const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); - await clickSelectOption(dataSourceSelect, 'Prom (default)'); - await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); - - await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); - - await userEvent.type(ui.inputs.name.get(), 'my great new rule'); - await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); - await clickSelectOption(ui.inputs.group.get(), 'group2'); - - await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); - await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - - await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: 'Prom', apiVersion: 'legacy' }, - 'namespace2', - { - name: 'group2', - rules: [ - { - alert: 'my great new rule', - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - expr: 'up == 1', - for: '1m', - }, - ], - } - ); - }); - - it('can create new grafana managed alert', async () => { - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - 'Folder A': [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([ - { - title: 'Folder A', - id: 1, - }, - { - title: 'Folder B', - id: 2, - }, - { - title: 'Folder / with slash', - id: 2, - }, - ] as DashboardSearchHit[]); - - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Prometheus, - features: { - rulerApiEnabled: false, - }, - }); - - // fill out the form - await renderRuleEditor(); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - - await userEvent.type(await ui.inputs.name.find(), 'my great new rule'); - - const folderInput = await ui.inputs.folder.find(); - await clickSelectOption(folderInput, 'Folder A'); - const groupInput = await ui.inputs.group.find(); - await userEvent.click(byRole('combobox').get(groupInput)); - await clickSelectOption(groupInput, 'group1 (1m)'); - - await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); - await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - - await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'Folder A', - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '5m', - grafana_alert: { - condition: 'C', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: 'NoData', - title: 'my great new rule', - }, - }, - ], - } - ); - }); - - it('can create a new cloud recording rule', async () => { - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - namespace1: [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([]); - - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Cortex, - features: { - rulerApiEnabled: true, - }, - }); - - await renderRuleEditor(); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); - await userEvent.click(await ui.buttons.lotexRecordingRule.get()); - - const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); - - await clickSelectOption(dataSourceSelect, 'Prom (default)'); - await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); - await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); - await clickSelectOption(ui.inputs.group.get(), 'group2'); - - await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - - await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); - - // try to save, find out that recording rule name is invalid - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => - expect( - byText( - 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.' - ).get() - ).toBeInTheDocument() - ); - expect(mocks.api.setRulerRuleGroup).not.toBeCalled(); - - // fix name and re-submit - await userEvent.clear(await ui.inputs.name.find()); - await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: 'Prom', apiVersion: 'legacy' }, - 'namespace2', - { - name: 'group2', - rules: [ - { - record: 'my:great:new:recording:rule', - labels: { team: 'the a-team' }, - expr: 'up == 1', - }, - ], - } - ); - }); - - it('can edit grafana managed rule', async () => { - const uid = 'FOOBAR123'; - const folder = { - title: 'Folder A', - uid: 'abcd', - id: 1, - }; - - const slashedFolder = { - title: 'Folder with /', - uid: 'abcde', - id: 2, - }; - - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ - ...mockFolder(), - accessControl: { - [AccessControlAction.AlertingRuleUpdate]: true, - }, - }); - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRules.mockResolvedValue({ - [folder.title]: [ - { - interval: '1m', - name: 'my great new rule', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '5m', - grafana_alert: { - uid, - namespace_uid: 'abcd', - namespace_id: 1, - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); - - await renderRuleEditor(uid); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - - // check that it's filled in - const nameInput = await ui.inputs.name.find(); - expect(nameInput).toHaveValue('my great new rule'); - //check that folder is in the list - expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); - expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description'); - expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary'); - - //check that slashed folders are not in the list - expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); - expect(ui.inputs.folder.get()).not.toHaveTextContent(new RegExp(slashedFolder.title)); - - //check that slashes warning is only shown once user search slashes - const folderInput = await ui.inputs.folderContainer.find(); - expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); - await userEvent.type(within(folderInput).getByRole('combobox'), 'new slashed //'); - expect(within(folderInput).getByText("Folders with '/' character are not allowed.")).toBeInTheDocument(); - await userEvent.keyboard('{backspace} {backspace}{backspace}'); - expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); - - // add an annotation - await clickSelectOption(ui.inputs.annotationKey(2).get(), /Add new/); - await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom'); - await userEvent.type(ui.inputs.annotationValue(2).get(), 'value'); - - //add a label - await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(2).get()), 'value{enter}'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - - //check that '+ Add new' option is in folders drop down even if we don't have values - const emptyFolderInput = await ui.inputs.folderContainer.find(); - mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); - await renderRuleEditor(uid); - await userEvent.click(within(emptyFolderInput).getByRole('combobox')); - expect(screen.getByText(ADD_NEW_FOLER_OPTION)).toBeInTheDocument(); - - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'Folder A', - { - interval: '1m', - name: 'my great new rule', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, - labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, - for: '5m', - grafana_alert: { - uid, - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: 'NoData', - title: 'my great new rule', - }, - }, - ], - } - ); - }); - - it('for cloud alerts, should only allow to select editable rules sources', async () => { - const dataSources: Record> = { - // can edit rules - loki: mockDataSource( - { - type: DataSourceType.Loki, - name: 'loki with ruler', - }, - { alerting: true } - ), - loki_disabled: mockDataSource( - { - type: DataSourceType.Loki, - name: 'loki disabled for alerting', - jsonData: { - manageAlerts: false, - }, - }, - { alerting: true } - ), - // can edit rules - prom: mockDataSource( - { - type: DataSourceType.Prometheus, - name: 'cortex with ruler', - }, - { alerting: true } - ), - // cannot edit rules - loki_local_rule_store: mockDataSource( - { - type: DataSourceType.Loki, - name: 'loki with local rule store', - }, - { alerting: true } - ), - // cannot edit rules - prom_no_ruler_api: mockDataSource( - { - type: DataSourceType.Loki, - name: 'cortex without ruler api', - }, - { alerting: true } - ), - // not a supported datasource type - splunk: mockDataSource( - { - type: 'splunk', - name: 'splunk', - }, - { alerting: true } - ), - }; - - mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => { - if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { - return { - application: PromApplication.Cortex, - features: { - rulerApiEnabled: true, - alertManagerConfigApi: false, - federatedRules: false, - querySharding: false, - }, - }; - } - if (dataSourceName === 'loki with local rule store') { - return { - application: PromApplication.Cortex, - features: { - rulerApiEnabled: false, - alertManagerConfigApi: false, - federatedRules: false, - querySharding: false, - }, - }; - } - if (dataSourceName === 'cortex without ruler api') { - return { - application: PromApplication.Cortex, - features: { - rulerApiEnabled: false, - alertManagerConfigApi: false, - federatedRules: false, - querySharding: false, - }, - }; - } - - throw new Error(`${dataSourceName} not handled`); - }); - - mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { - if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { - return null; - } - if (dataSourceName === 'loki with local rule store') { - throw { - status: 400, - data: { - message: 'GetRuleGroup unsupported in rule local store', - }, - }; - } - if (dataSourceName === 'cortex without ruler api') { - throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?'); - } - return null; - }); - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.searchFolders.mockResolvedValue([]); - - // render rule editor, select mimir/loki managed alerts - await renderRuleEditor(); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - - await ui.inputs.name.find(); - await userEvent.click(await ui.buttons.lotexAlert.get()); - - // check that only rules sources that have ruler available are there - const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); - expect(await byText('loki with ruler').query()).toBeInTheDocument(); - expect(byText('cortex with ruler').query()).toBeInTheDocument(); - expect(byText('loki with local rule store').query()).not.toBeInTheDocument(); - expect(byText('prom without ruler api').query()).not.toBeInTheDocument(); - expect(byText('splunk').query()).not.toBeInTheDocument(); - expect(byText('loki disabled for alerting').query()).not.toBeInTheDocument(); - }); -}); - -const clickSelectOption = async (selectElement: HTMLElement, optionText: Matcher): Promise => { - await userEvent.click(byRole('combobox').get(selectElement)); - await selectOptionInTest(selectElement, optionText as string); -}; diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx new file mode 100644 index 00000000000..01c66b8cf27 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -0,0 +1,183 @@ +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { byRole, byText } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; +import { DataSourceType } from './utils/datasource'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +function getDiscoverFeaturesMock(application: PromApplication, features?: Partial) { + return { + application: application, + features: { + rulerApiEnabled: false, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + ...features, + }, + }; +} + +describe('RuleEditor cloud: checking editable data sources', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('for cloud alerts, should only allow to select editable rules sources', async () => { + const dataSources = { + // can edit rules + loki: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki with ruler', + }, + { alerting: true } + ), + loki_disabled: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki disabled for alerting', + jsonData: { + manageAlerts: false, + }, + }, + { alerting: true } + ), + // can edit rules + prom: mockDataSource( + { + type: DataSourceType.Prometheus, + name: 'cortex with ruler', + }, + { alerting: true } + ), + // cannot edit rules + loki_local_rule_store: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki with local rule store', + }, + { alerting: true } + ), + // cannot edit rules + prom_no_ruler_api: mockDataSource( + { + type: DataSourceType.Loki, + name: 'cortex without ruler api', + }, + { alerting: true } + ), + // not a supported datasource type + splunk: mockDataSource( + { + type: 'splunk', + name: 'splunk', + }, + { alerting: true } + ), + }; + + mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => { + if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + return getDiscoverFeaturesMock(PromApplication.Cortex, { rulerApiEnabled: true }); + } + if (dataSourceName === 'loki with local rule store') { + return getDiscoverFeaturesMock(PromApplication.Cortex); + } + if (dataSourceName === 'cortex without ruler api') { + return getDiscoverFeaturesMock(PromApplication.Cortex); + } + + throw new Error(`${dataSourceName} not handled`); + }); + + mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { + if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + return null; + } + if (dataSourceName === 'loki with local rule store') { + throw { + status: 400, + data: { + message: 'GetRuleGroup unsupported in rule local store', + }, + }; + } + if (dataSourceName === 'cortex without ruler api') { + throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?'); + } + return null; + }); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.searchFolders.mockResolvedValue([]); + + // render rule editor, select mimir/loki managed alerts + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await ui.inputs.name.find(); + await userEvent.click(await ui.buttons.lotexAlert.get()); + + // check that only rules sources that have ruler available are there + const dataSourceSelect = ui.inputs.dataSource.get(); + await userEvent.click(byRole('combobox').get(dataSourceSelect)); + expect(await byText('loki with ruler').query()).toBeInTheDocument(); + expect(byText('cortex with ruler').query()).toBeInTheDocument(); + expect(byText('loki with local rule store').query()).not.toBeInTheDocument(); + expect(byText('prom without ruler api').query()).not.toBeInTheDocument(); + expect(byText('splunk').query()).not.toBeInTheDocument(); + expect(byText('loki disabled for alerting').query()).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx new file mode 100644 index 00000000000..3443e9b1422 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -0,0 +1,153 @@ +import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { byRole } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); + +describe('RuleEditor cloud', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('can create a new cloud alert', async () => { + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: true } + ), + }; + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + namespace1: [ + { + name: 'group1', + rules: [], + }, + ], + namespace2: [ + { + name: 'group2', + rules: [], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await userEvent.click(await ui.buttons.lotexAlert.find()); + + const dataSourceSelect = ui.inputs.dataSource.get(); + await userEvent.click(byRole('combobox').get(dataSourceSelect)); + await clickSelectOption(dataSourceSelect, 'Prom (default)'); + await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); + + await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); + + await userEvent.type(ui.inputs.name.get(), 'my great new rule'); + await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); + await clickSelectOption(ui.inputs.group.get(), 'group2'); + + await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); + await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + + await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + 'namespace2', + { + name: 'group2', + rules: [ + { + alert: 'my great new rule', + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn' }, + expr: 'up == 1', + for: '1m', + }, + ], + } + ); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx new file mode 100644 index 00000000000..1debcd414ab --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx @@ -0,0 +1,219 @@ +import { render, waitFor, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Route, Router } from 'react-router-dom'; +import { ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOptionMatch } from 'test/helpers/selectOptionInTest'; +import { byRole } from 'testing-library-selector'; + +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker'; +import { contextSrv } from 'app/core/services/context_srv'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { configureStore } from 'app/store/configureStore'; +import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; +import { backendSrv } from '../../../core/services/backend_srv'; +import { AccessControlAction } from '../../../types'; + +import RuleEditor from './RuleEditor'; +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { getDefaultQueries } from './utils/rule-form'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +function renderRuleEditor(identifier?: string) { + const store = configureStore(); + + locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); + + return render( + + + + + + ); +} + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); +describe('RuleEditor grafana managed rules', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('can edit grafana managed rule', async () => { + const uid = 'FOOBAR123'; + const folder = { + title: 'Folder A', + uid: 'abcd', + id: 1, + }; + + const slashedFolder = { + title: 'Folder with /', + uid: 'abcde', + id: 2, + }; + + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: false } + ), + }; + + jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ + ...mockFolder(), + accessControl: { + [AccessControlAction.AlertingRuleUpdate]: true, + }, + }); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRules.mockResolvedValue({ + [folder.title]: [ + { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid, + namespace_uid: 'abcd', + namespace_id: 1, + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); + + renderRuleEditor(uid); + + // check that it's filled in + const nameInput = await ui.inputs.name.find(); + expect(nameInput).toHaveValue('my great new rule'); + //check that folder is in the list + expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); + expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description'); + expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary'); + + //check that slashed folders are not in the list + expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); + expect(ui.inputs.folder.get()).not.toHaveTextContent(new RegExp(slashedFolder.title)); + + //check that slashes warning is only shown once user search slashes + //todo: move this test to a unit test in FolderAndGroup unit test + // const folderInput = await ui.inputs.folderContainer.find(); + // expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); + // await userEvent.type(within(folderInput).getByRole('combobox'), 'new slashed //'); + // expect(within(folderInput).getByText("Folders with '/' character are not allowed.")).toBeInTheDocument(); + // await userEvent.keyboard('{backspace} {backspace}{backspace}'); + // expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); + + // add an annotation + await clickSelectOptionMatch(ui.inputs.annotationKey(2).get(), /Add new/); + await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom'); + await userEvent.type(ui.inputs.annotationValue(2).get(), 'value'); + + //add a label + await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(2).get()), 'value{enter}'); + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + + //check that '+ Add new' option is in folders drop down even if we don't have values + const emptyFolderInput = await ui.inputs.folderContainer.find(); + mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); + await renderRuleEditor(uid); + await userEvent.click(within(emptyFolderInput).getByRole('combobox')); + expect(screen.getByText(ADD_NEW_FOLER_OPTION)).toBeInTheDocument(); + + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'Folder A', + { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, + labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, + for: '5m', + grafana_alert: { + uid, + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: 'NoData', + title: 'my great new rule', + }, + }, + ], + } + ); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx new file mode 100644 index 00000000000..83022dd54f8 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -0,0 +1,169 @@ +import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { byRole } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { getDefaultQueries } from './utils/rule-form'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); +describe('RuleEditor grafana managed rules', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('can create new grafana managed alert', async () => { + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: false } + ), + }; + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + 'Folder A': [ + { + name: 'group1', + rules: [], + }, + ], + namespace2: [ + { + name: 'group2', + rules: [], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([ + { + title: 'Folder A', + id: 1, + }, + { + title: 'Folder B', + id: 2, + }, + { + title: 'Folder / with slash', + id: 2, + }, + ] as DashboardSearchHit[]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }); + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await userEvent.type(await ui.inputs.name.find(), 'my great new rule'); + + const folderInput = await ui.inputs.folder.find(); + await clickSelectOption(folderInput, 'Folder A'); + const groupInput = await ui.inputs.group.find(); + await userEvent.click(byRole('combobox').get(groupInput)); + await clickSelectOption(groupInput, 'group1 (1m)'); + await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + + await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); + //8 segons + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + // 9seg + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + // 9seg + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'Folder A', + { + interval: '1m', + name: 'group1', + rules: [ + { + annotations: { description: 'some description' }, + labels: { severity: 'warn' }, + for: '5m', + grafana_alert: { + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: 'NoData', + title: 'my great new rule', + }, + }, + ], + } + ); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx new file mode 100644 index 00000000000..bce401c0d92 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx @@ -0,0 +1,160 @@ +import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { byRole, byText } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); + +describe('RuleEditor recording rules', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + it.skip('can create a new cloud recording rule', async () => { + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: true } + ), + }; + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + namespace1: [ + { + name: 'group1', + rules: [], + }, + ], + namespace2: [ + { + name: 'group2', + rules: [], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); + await userEvent.click(await ui.buttons.lotexRecordingRule.get()); + + const dataSourceSelect = ui.inputs.dataSource.get(); + await userEvent.click(byRole('combobox').get(dataSourceSelect)); + + await clickSelectOption(dataSourceSelect, 'Prom (default)'); + await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); + await clickSelectOption(ui.inputs.group.get(), 'group2'); + + await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + + await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); + + // try to save, find out that recording rule name is invalid + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => + expect( + byText( + 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.' + ).get() + ).toBeInTheDocument() + ); + expect(mocks.api.setRulerRuleGroup).not.toBeCalled(); + + // fix name and re-submit + await userEvent.clear(await ui.inputs.name.find()); + await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule'); + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + 'namespace2', + { + name: 'group2', + rules: [ + { + record: 'my:great:new:recording:rule', + labels: { team: 'the a-team' }, + expr: 'up == 1', + }, + ], + } + ); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index b55647a0878..466ba2a0431 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -451,8 +451,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ tableWrapper: css` margin-top: ${theme.spacing(2)}; margin-bottom: ${theme.spacing(2)}; - height: 225px; - overflow: auto; + height: 100%; `, evalRequiredLabel: css` font-size: ${theme.typography.bodySmall.fontSize}; diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx index 0f2c8d3f112..b5dc9e6a228 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx +++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx @@ -81,8 +81,8 @@ describe('useExternalDataSourceAlertmanagers', () => { const wrapper: React.FC = ({ children }) => {children}; // Act - const { result, waitForNextUpdate } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); - await waitForNextUpdate(); + const { result, waitForValueToChange } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + await waitForValueToChange(() => result.current[0].status); // Assert const { current } = result; @@ -114,8 +114,8 @@ describe('useExternalDataSourceAlertmanagers', () => { const wrapper: React.FC = ({ children }) => {children}; // Act - const { result, waitForNextUpdate } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); - await waitForNextUpdate(); + const { result, waitForValueToChange } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + await waitForValueToChange(() => result.current[0].status); // Assert const { current } = result; @@ -180,8 +180,8 @@ describe('useExternalDataSourceAlertmanagers', () => { const wrapper: React.FC = ({ children }) => {children}; // Act - const { result, waitForNextUpdate } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); - await waitForNextUpdate(); + const { result, waitForValueToChange } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper }); + await waitForValueToChange(() => result.current[0].status); // Assert const { current } = result; @@ -213,11 +213,11 @@ describe('useExternalDataSourceAlertmanagers', () => { const wrapper: React.FC = ({ children }) => {children}; // Act - const { result, waitForNextUpdate } = renderHook(() => useExternalDataSourceAlertmanagers(), { + const { result, waitForValueToChange } = renderHook(() => useExternalDataSourceAlertmanagers(), { wrapper, }); - await waitForNextUpdate(); + await waitForValueToChange(() => result.current[0].status); // Assert expect(result.current).toHaveLength(1); diff --git a/public/app/features/commandPalette/actions/global.static.actions.tsx b/public/app/features/commandPalette/actions/global.static.actions.tsx index 505b89fb798..3ab6ca206fe 100644 --- a/public/app/features/commandPalette/actions/global.static.actions.tsx +++ b/public/app/features/commandPalette/actions/global.static.actions.tsx @@ -1,9 +1,10 @@ import { Action, Priority } from 'kbar'; import React from 'react'; -import { isIconName, NavModelItem } from '@grafana/data'; +import { isIconName, locationUtil, NavModelItem } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { Icon } from '@grafana/ui'; +import { changeTheme } from 'app/core/services/theme'; const SECTION_PAGES = 'Pages'; const SECTION_ACTIONS = 'Actions'; @@ -34,7 +35,7 @@ function navTreeToActions(navTree: NavModelItem[], parent?: NavModelItem): Actio id: idForNavItem(navItem), name: text, // TODO: translate section: isCreateAction ? SECTION_ACTIONS : SECTION_PAGES, - perform: url ? () => locationService.push(url) : undefined, + perform: url ? () => locationService.push(locationUtil.stripBaseFromUrl(url)) : undefined, parent: parent && idForNavItem(parent), // Only show icons for top level items @@ -82,10 +83,7 @@ export default (navBarTree: NavModelItem[]) => { name: 'Dark', keywords: 'dark theme', section: '', - perform: () => { - locationService.push({ search: '?theme=dark' }); - location.reload(); - }, + perform: () => changeTheme('dark'), parent: 'preferences/theme', }, { @@ -93,10 +91,7 @@ export default (navBarTree: NavModelItem[]) => { name: 'Light', keywords: 'light theme', section: '', - perform: () => { - locationService.push({ search: '?theme=light' }); - location.reload(); - }, + perform: () => changeTheme('light'), parent: 'preferences/theme', }, ]; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 8dea8470fbe..f6ba01a7ede 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -241,32 +241,45 @@ export class PanelEditorUnconnected extends PureComponent { ); } - renderPanelAndEditor(styles: EditorStyles) { + renderPanelAndEditor(uiState: PanelEditorUIState, 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 panelPane; + return
{panelPane}
; } - return [ - panelPane, -
{ + if (size) { + updatePanelEditorUIState({ topPaneSize: size / window.innerHeight }); + } + }} > - -
, - ]; + {panelPane} +
+ +
+ + ); } renderTemplateVariables(styles: EditorStyles) { @@ -433,25 +446,6 @@ export class PanelEditorUnconnected extends PureComponent { ); } - renderHorizontalSplit(uiState: PanelEditorUIState, styles: EditorStyles) { - return ( - { - if (size) { - updatePanelEditorUIState({ topPaneSize: size / window.innerHeight }); - } - }} - > - {this.renderPanelAndEditor(styles)} - - ); - } - render() { const { initDone, uiState, theme, sectionNav, pageNav, className, updatePanelEditorUIState } = this.props; const styles = getStyles(theme, this.props); @@ -472,7 +466,7 @@ export class PanelEditorUnconnected extends PureComponent {
{!uiState.isPanelOptionsVisible ? ( - this.renderHorizontalSplit(uiState, styles) + this.renderPanelAndEditor(uiState, styles) ) : ( { } }} > - {this.renderHorizontalSplit(uiState, styles)} + {this.renderPanelAndEditor(uiState, styles)} {this.renderOptionsPane()} )} @@ -569,6 +563,12 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2, props: Props) => { position: relative; flex-direction: column; `, + onlyPanel: css` + height: 100%; + position: absolute; + overflow: hidden; + width: 100%; + `, }; }); diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveToStorageForm.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveToStorageForm.tsx index 052f293733c..5b1c71d2532 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveToStorageForm.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveToStorageForm.tsx @@ -96,7 +96,8 @@ export function SaveToStorageForm(props: Props) { } setSaving(true); - let uid = saveModel.clone.uid; + // Save dashboard without the UID + let { uid, ...body } = saveModel.clone; if (isNew || isCopy) { uid = path; if (!uid.endsWith('-dash.json')) { @@ -104,7 +105,7 @@ export function SaveToStorageForm(props: Props) { } } const rsp = await getGrafanaStorage().write(uid, { - body: saveModel.clone, + body, kind: 'dashboard', title: data.title, message: data.message, diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 1783d7d34bd..717f79579de 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -392,7 +392,12 @@ export class UnthemedDashboardPage extends PureComponent { )} - + {inspectPanel && } diff --git a/public/app/features/dashboard/containers/DatasourceOnboarding.tsx b/public/app/features/dashboard/containers/DatasourceOnboarding.tsx new file mode 100644 index 00000000000..36abb698e6c --- /dev/null +++ b/public/app/features/dashboard/containers/DatasourceOnboarding.tsx @@ -0,0 +1,209 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useAsync } from 'react-use'; + +import { DataSourcePluginMeta, GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { Icon, useStyles2 } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; +import { contextSrv } from 'app/core/core'; +import { t } from 'app/core/internationalization'; +import { useAddDatasource } from 'app/features/datasources/state'; + +const topDatasources = [ + 'prometheus', + 'mysql', + 'elasticsearch', + 'influxdb', + 'graphite', + 'stackdriver', + 'cloudwatch', + 'grafana-azure-monitor-datasource', +]; + +export function DatasourceOnboarding({ + onNewDashboard, + loading = false, +}: { + onNewDashboard?: () => void; + loading?: boolean; +}) { + const styles = useStyles2(getStyles); + const { value: datasources, loading: loadingDatasources } = useAsync(async () => { + const datasourceMeta: DataSourcePluginMeta[] = await getBackendSrv().get('/api/plugins', { + enabled: 1, + type: 'datasource', + }); + + const byId = datasourceMeta.reduce>((prev, cur) => { + prev[cur.id] = cur; + return prev; + }, {}); + + return topDatasources.map((d) => byId[d]); + }, []); + + const onAddDatasource = useAddDatasource(); + + if (loading) { + return null; + } + + return ( + +
+

{t('datasource-onboarding.welcome', 'Welcome to Grafana dashboards!')}

+
+

+ {t('datasource-onboarding.explanation', "To visualize your data, you'll need to connect it first.")} +

+
+ {contextSrv.hasRole('Admin') ? ( + <> +

+ {t('datasource-onboarding.preferred', 'Connect your preferred data source:')} +

+ {!loadingDatasources && datasources !== undefined && ( + + )} + + ) : ( +

+ {t('datasource-onboarding.contact-admin', 'Please contact your administrator to configure data sources.')} +

+ )} + +
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + wrapper: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + }), + title: css({ + textAlign: 'center', + fontSize: theme.typography.pxToRem(32), + fontWeight: theme.typography.fontWeightBold, + }), + description: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacing(1), + maxWidth: '50vw', + marginBottom: theme.spacing(8), + color: theme.colors.text.secondary, + }), + explanation: css({ + marginBottom: '0px', + textAlign: 'center', + fontSize: theme.typography.pxToRem(19), + }), + preferredDataSource: css({ + marginBottom: theme.spacing(3), + fontSize: theme.typography.pxToRem(19), + fontWeight: theme.typography.fontWeightRegular, + }), + datasources: css({ + display: 'grid', + gridTemplateColumns: `repeat(auto-fit, minmax(${theme.spacing(28)}, 1fr))`, + gap: theme.spacing(2), + listStyle: 'none', + width: '100%', + maxWidth: theme.spacing(88), + + '> li': { + display: 'flex', + alignItems: 'center', + '> button': { + display: 'flex', + alignItems: 'center', + width: '100%', + height: theme.spacing(7), + gap: theme.spacing(2), + margin: '0px', + padding: `calc(${theme.spacing(2)} - 1px)`, + lineHeight: theme.spacing(3), + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.borderRadius(1), + background: theme.colors.background.primary, + fontSize: theme.typography.pxToRem(19), + color: 'inherit', + }, + }, + }), + logo: css({ + width: theme.spacing(2), + height: theme.spacing(2), + objectFit: 'contain', + }), + datasourceName: css({ + marginRight: 'auto', + }), + arrowIcon: css({ + color: theme.colors.text.link, + }), + viewAll: css({ + display: 'flex', + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(2), + lineHeight: theme.spacing(3), + fontSize: theme.typography.pxToRem(19), + color: theme.colors.text.link, + textDecoration: 'underline', + textUnderlinePosition: 'under', + }), + createNew: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + margin: '0px', + marginTop: theme.spacing(8), + padding: theme.spacing(0), + border: 'none', + background: 'inherit', + fontSize: theme.typography.h6.fontSize, + color: theme.colors.text.secondary, + }), + }; +} diff --git a/public/app/features/dashboard/containers/NewDashboardPage.tsx b/public/app/features/dashboard/containers/NewDashboardPage.tsx new file mode 100644 index 00000000000..2e1575d8e3b --- /dev/null +++ b/public/app/features/dashboard/containers/NewDashboardPage.tsx @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; +import { useEffectOnce } from 'react-use'; + +import { config } from '@grafana/runtime'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { loadDataSources } from 'app/features/datasources/state'; +import { useDispatch, useSelector } from 'app/types'; + +import DashboardPage from './DashboardPage'; +import { DatasourceOnboarding } from './DatasourceOnboarding'; + +export default function NewDashboardPage(props: GrafanaRouteComponentProps) { + const dispatch = useDispatch(); + useEffectOnce(() => { + dispatch(loadDataSources()); + }); + + const { hasDatasource, loading } = useSelector((state) => ({ + hasDatasource: state.dataSources.dataSourcesCount > 0, + loading: !state.dataSources.hasFetched, + })); + const [createDashboard, setCreateDashboard] = useState(false); + const showDashboardPage = hasDatasource || createDashboard || !config.featureToggles.datasourceOnboarding; + + return showDashboardPage ? ( + + ) : ( + setCreateDashboard(true)} loading={loading} /> + ); +} diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx index 05af4a5831e..111583e7745 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -57,6 +57,7 @@ describe('DashboardGrid', () => { const props: Props = { editPanel: null, viewPanel: null, + isEditable: true, dashboard: getTestDashboard(), }; expect(() => render()).not.toThrow(); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 74f11cfea55..02a2204fbb7 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -17,6 +17,7 @@ import { DashboardPanel } from './DashboardPanel'; export interface Props { dashboard: DashboardModel; + isEditable: boolean; editPanel: PanelModel | null; viewPanel: PanelModel | null; } @@ -200,7 +201,7 @@ export class DashboardGrid extends PureComponent { } render() { - const { dashboard } = this.props; + const { isEditable } = this.props; /** * We have a parent with "flex: 1 1 0" we need to reset it to "flex: 1 1 auto" to have the AutoSizer @@ -215,14 +216,13 @@ export class DashboardGrid extends PureComponent { return null; } - const draggable = width <= 769 ? false : dashboard.meta.canEdit; + const draggable = width <= 769 ? false : isEditable; /* Disable draggable if mobile device, solving an issue with unintentionally moving panels. https://github.com/grafana/grafana/issues/18497 theme.breakpoints.md = 769 */ - return ( /** * The children is using a width of 100% so we need to guarantee that it is wrapped @@ -233,7 +233,7 @@ export class DashboardGrid extends PureComponent { { const dsSettings = this.dataSourceSrv.getInstanceSettings(options.dataSource); const defaultDataSource = await this.dataSourceSrv.get(); const datasource = ds.getRef(); - const queries = options.queries.map((q) => (q.datasource ? q : { ...q, datasource })); + const queries = options.queries.map((q) => ({ + ...(queryIsEmpty(q) && ds?.getDefaultQuery?.(CoreApp.PanelEditor)), + datasource, + ...q, + })); this.setState({ queries, dataSource: ds, diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index 3f3a2c480b8..9509d089e13 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -58,6 +58,7 @@ export interface QueryRunnerOptions< scopedVars?: ScopedVars; cacheTimeout?: string | null; transformations?: DataTransformerConfig[]; + app?: CoreApp; } let counter = 100; @@ -213,6 +214,7 @@ export class PanelQueryRunner { maxDataPoints, scopedVars, minInterval, + app, } = options; if (isSharedDashboardQuery(datasource)) { @@ -221,7 +223,7 @@ export class PanelQueryRunner { } const request: DataQueryRequest = { - app: CoreApp.Dashboard, + app: app ?? CoreApp.Dashboard, requestId: getNextRequestId(), timezone, panelId, diff --git a/public/app/features/query/state/queryAnalytics.test.ts b/public/app/features/query/state/queryAnalytics.test.ts index 6c847338bc0..d23a537ac6d 100644 --- a/public/app/features/query/state/queryAnalytics.test.ts +++ b/public/app/features/query/state/queryAnalytics.test.ts @@ -1,4 +1,13 @@ -import { CoreApp, DataFrame, DataQueryRequest, DataSourceApi, dateTime, LoadingState, PanelData } from '@grafana/data'; +import { + CoreApp, + DataFrame, + DataQueryError, + DataQueryRequest, + DataSourceApi, + dateTime, + LoadingState, + PanelData, +} from '@grafana/data'; import { MetaAnalyticsEventName, reportMetaAnalytics } from '@grafana/runtime'; import { DashboardModel } from '../../dashboard/state'; @@ -12,6 +21,7 @@ beforeEach(() => { const datasource = { name: 'test', id: 1, + uid: 'test', } as DataSourceApi; const dashboardModel = new DashboardModel( @@ -94,6 +104,28 @@ function getTestData(requestApp: string, series: DataFrame[] = []): PanelData { }; } +function getTestDataForExplore(requestApp: string, series: DataFrame[] = []): PanelData { + const now = dateTime(); + const error: DataQueryError = { message: 'test error' }; + + return { + request: { + app: requestApp, + dashboardId: 0, + startTime: now.unix(), + endTime: now.add(1, 's').unix(), + } as DataQueryRequest, + series, + state: LoadingState.Done, + timeRange: { + from: dateTime(), + to: dateTime(), + raw: { from: '1h', to: 'now' }, + }, + error: error, + }; +} + describe('emitDataRequestEvent - from a dashboard panel', () => { it('Should report meta analytics', () => { const data = getTestData(CoreApp.Dashboard); @@ -185,10 +217,35 @@ describe('emitDataRequestEvent - from a dashboard panel', () => { }); }); +// Previously we filtered out Explore events due to too many errors being generated while a user is building a query +// This tests that we send an event for Explore queries but do not record errors describe('emitDataRequestEvent - from Explore', () => { - const data = getTestData(CoreApp.Explore); - it('Should not report meta analytics', () => { + it('Should report meta analytics', () => { + const data = getTestDataForExplore(CoreApp.Explore); emitDataRequestEvent(datasource)(data); - expect(reportMetaAnalytics).not.toBeCalled(); + + expect(reportMetaAnalytics).toBeCalledTimes(1); + expect(reportMetaAnalytics).toBeCalledWith( + expect.objectContaining({ + eventName: MetaAnalyticsEventName.DataRequest, + source: 'explore', + datasourceName: 'test', + datasourceId: 1, + datasourceUid: 'test', + dataSize: 0, + duration: 1, + totalQueries: 0, + }) + ); + }); + + describe('emitDataRequestEvent - from Explore', () => { + it('Should not report errors', () => { + const data = getTestDataForExplore(CoreApp.Explore); + emitDataRequestEvent(datasource)(data); + + expect(reportMetaAnalytics).toBeCalledTimes(1); + expect(reportMetaAnalytics).toBeCalledWith(expect.not.objectContaining({ error: 'test error' })); + }); }); }); diff --git a/public/app/features/query/state/queryAnalytics.ts b/public/app/features/query/state/queryAnalytics.ts index ae70999ff61..8d977555b78 100644 --- a/public/app/features/query/state/queryAnalytics.ts +++ b/public/app/features/query/state/queryAnalytics.ts @@ -8,7 +8,7 @@ export function emitDataRequestEvent(datasource: DataSourceApi) { let done = false; return (data: PanelData) => { - if (!data.request || done || data.request.app === CoreApp.Explore) { + if (!data.request || done) { return; } @@ -21,6 +21,41 @@ export function emitDataRequestEvent(datasource: DataSourceApi) { return; } + const eventData: DataRequestEventPayload = { + eventName: MetaAnalyticsEventName.DataRequest, + source: data.request.app, + datasourceName: datasource.name, + datasourceId: datasource.id, + datasourceUid: datasource.uid, + datasourceType: datasource.type, + dataSize: 0, + duration: data.request.endTime! - data.request.startTime, + }; + + if (data.request.app === CoreApp.Explore) { + enrichWithExploreInfo(eventData, data); + } else { + enrichWithDashboardInfo(eventData, data); + } + + if (data.series && data.series.length > 0) { + // estimate size + eventData.dataSize = data.series.length; + } + + reportMetaAnalytics(eventData); + + // this done check is to make sure we do not double emit events in case + // there are multiple responses with done state + done = true; + }; + + function enrichWithExploreInfo(eventData: DataRequestEventPayload, data: PanelData) { + const totalQueries = Object.keys(data.series).length; + eventData.totalQueries = totalQueries; + } + + function enrichWithDashboardInfo(eventData: DataRequestEventPayload, data: PanelData) { const queryCacheStatus: { [key: string]: boolean } = {}; for (let i = 0; i < data.series.length; i++) { const refId = data.series[i].refId; @@ -31,21 +66,11 @@ export function emitDataRequestEvent(datasource: DataSourceApi) { const totalQueries = Object.keys(queryCacheStatus).length; const cachedQueries = Object.values(queryCacheStatus).filter((val) => val === true).length; - const eventData: DataRequestEventPayload = { - eventName: MetaAnalyticsEventName.DataRequest, - datasourceName: datasource.name, - datasourceId: datasource.id, - datasourceUid: datasource.uid, - datasourceType: datasource.type, - panelId: data.request.panelId, - dashboardId: data.request.dashboardId, - dataSize: 0, - duration: data.request.endTime! - data.request.startTime, - totalQueries, - cachedQueries, - }; + eventData.panelId = data.request!.panelId; + eventData.dashboardId = data.request!.dashboardId; + eventData.totalQueries = totalQueries; + eventData.cachedQueries = cachedQueries; - // enrich with dashboard info const dashboard = getDashboardSrv().getCurrent(); if (dashboard) { eventData.dashboardId = dashboard.id; @@ -58,19 +83,8 @@ export function emitDataRequestEvent(datasource: DataSourceApi) { } } - if (data.series && data.series.length > 0) { - // estimate size - eventData.dataSize = data.series.length; - } - if (data.error) { eventData.error = data.error.message; } - - reportMetaAnalytics(eventData); - - // this done check is to make sure we do not double emit events in case - // there are multiple responses with done state - done = true; - }; + } } diff --git a/public/app/features/query/state/runRequest.ts b/public/app/features/query/state/runRequest.ts index 9f0f0490707..440bf4f4e6c 100644 --- a/public/app/features/query/state/runRequest.ts +++ b/public/app/features/query/state/runRequest.ts @@ -6,6 +6,7 @@ import { catchError, map, mapTo, share, takeUntil, tap } from 'rxjs/operators'; // Utils & Services // Types import { + CoreApp, DataFrame, DataQueryError, DataQueryRequest, @@ -23,6 +24,7 @@ import { import { toDataQueryError } from '@grafana/runtime'; import { isExpressionReference } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { backendSrv } from 'app/core/services/backend_srv'; +import { queryIsEmpty } from 'app/core/utils/query'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { ExpressionQuery } from 'app/features/expressions/types'; @@ -174,6 +176,11 @@ export function callQueryMethod( request: DataQueryRequest, queryFunction?: typeof datasource.query ) { + // If the datasource has defined a default query, make sure it's applied if the query is empty + request.targets = request.targets.map((t) => + queryIsEmpty(t) ? { ...datasource?.getDefaultQuery?.(CoreApp.PanelEditor), ...t } : t + ); + // If its a public datasource, just return the result. Expressions will be handled on the backend. if (datasource.type === 'public-ds') { return from(datasource.query(request)); diff --git a/public/app/features/query/state/updateQueries.test.ts b/public/app/features/query/state/updateQueries.test.ts index fd95693c388..ce6ac115453 100644 --- a/public/app/features/query/state/updateQueries.test.ts +++ b/public/app/features/query/state/updateQueries.test.ts @@ -5,6 +5,7 @@ import { DataSourceWithQueryImportSupport, } from '@grafana/data'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; +import { TestQuery } from 'app/core/utils/query.test'; import { updateQueries } from './updateQueries'; @@ -41,6 +42,9 @@ const newUidSameTypeDS = { } as DataSourceApi; describe('updateQueries', () => { + afterEach(() => { + jest.clearAllMocks(); + }); it('Should update all queries except expression query when changing data source with same type', async () => { const updated = await updateQueries( newUidSameTypeDS, @@ -111,6 +115,53 @@ describe('updateQueries', () => { expect(updated[0].datasource).toEqual({ type: 'new-type', uid: 'new-uid' }); }); + it('Should clear queries and get default query from ds when changing type', async () => { + newUidDS.getDefaultQuery = jest.fn().mockReturnValue({ test: 'default-query1' } as Partial); + const updated = await updateQueries( + newUidDS, + 'new-uid', + [ + { + refId: 'A', + datasource: { + uid: 'old-uid', + type: 'old-type', + }, + }, + { + refId: 'B', + datasource: { + uid: 'old-uid', + type: 'old-type', + }, + }, + ], + oldUidDS + ); + + expect(newUidDS.getDefaultQuery).toHaveBeenCalled(); + expect(updated as TestQuery[]).toEqual([ + { + datasource: { type: 'new-type', uid: 'new-uid' }, + refId: 'A', + test: 'default-query1', + }, + ]); + }); + + it('Should return default query from ds when changing type and no new queries exist', async () => { + newUidDS.getDefaultQuery = jest.fn().mockReturnValue({ test: 'default-query2' } as Partial); + const updated = await updateQueries(newUidDS, 'new-uid', [], oldUidDS); + expect(newUidDS.getDefaultQuery).toHaveBeenCalled(); + expect(updated as TestQuery[]).toEqual([ + { + datasource: { type: 'new-type', uid: 'new-uid' }, + refId: 'A', + test: 'default-query2', + }, + ]); + }); + it('Should preserve query data source when changing to mixed', async () => { const updated = await updateQueries( mixedDS, diff --git a/public/app/features/query/state/updateQueries.ts b/public/app/features/query/state/updateQueries.ts index 6e3015f6b67..b97a54ef280 100644 --- a/public/app/features/query/state/updateQueries.ts +++ b/public/app/features/query/state/updateQueries.ts @@ -1,4 +1,4 @@ -import { DataQuery, DataSourceApi, hasQueryExportSupport, hasQueryImportSupport } from '@grafana/data'; +import { CoreApp, DataQuery, DataSourceApi, hasQueryExportSupport, hasQueryImportSupport } from '@grafana/data'; import { isExpressionReference } from '@grafana/runtime/src/utils/DataSourceWithBackend'; export async function updateQueries( @@ -9,6 +9,7 @@ export async function updateQueries( ): Promise { let nextQueries = queries; const datasource = { type: nextDS.type, uid: nextDSUidOrVariableExpression }; + const DEFAULT_QUERY = { ...nextDS?.getDefaultQuery?.(CoreApp.PanelEditor), datasource, refId: 'A' }; // we are changing data source type if (currentDS?.meta.id !== nextDS.meta.id) { @@ -27,12 +28,12 @@ export async function updateQueries( } // Otherwise clear queries else { - return [{ refId: 'A', datasource }]; + return [DEFAULT_QUERY]; } } if (nextQueries.length === 0) { - return [{ refId: 'A', datasource }]; + return [DEFAULT_QUERY]; } // Set data source on all queries except expression queries diff --git a/public/app/features/scenes/core/SceneTimeRange.test.tsx b/public/app/features/scenes/core/SceneTimeRange.test.tsx index 76baeb651d1..aba98e42832 100644 --- a/public/app/features/scenes/core/SceneTimeRange.test.tsx +++ b/public/app/features/scenes/core/SceneTimeRange.test.tsx @@ -13,7 +13,6 @@ describe('SceneTimeRange', () => { timeRange.onRefresh(); const diff = timeRange.state.value.from.valueOf() - startTime; expect(diff).toBeGreaterThan(1); - expect(diff).toBeLessThan(100); }); it('toUrlValues with relative range', () => { diff --git a/public/app/features/storage/RootView.tsx b/public/app/features/storage/RootView.tsx index d667758fdcc..e1c56fe1ee4 100644 --- a/public/app/features/storage/RootView.tsx +++ b/public/app/features/storage/RootView.tsx @@ -133,9 +133,6 @@ function getTags(v: StorageInfo) { if (v.builtin) { tags.push('Builtin'); } - if (!v.editable) { - tags.push('Read only'); - } // Error if (!v.ready) { diff --git a/public/app/features/storage/StoragePage.tsx b/public/app/features/storage/StoragePage.tsx index 5e94332cd56..dfd86c879cb 100644 --- a/public/app/features/storage/StoragePage.tsx +++ b/public/app/features/storage/StoragePage.tsx @@ -149,7 +149,7 @@ export default function StoragePage(props: Props) { // Lets only apply permissions to folders (for now) if (isFolder) { - opts.push({ what: StorageView.Perms, text: 'Permissions' }); + // opts.push({ what: StorageView.Perms, text: 'Permissions' }); } else { // TODO: only if the file exists in a storage engine with opts.push({ what: StorageView.History, text: 'History' }); @@ -181,7 +181,7 @@ export default function StoragePage(props: Props) { {canViewDashboard && ( - + Dashboard )} diff --git a/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.test.tsx b/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.test.tsx index 7286356811b..1829732b5d8 100644 --- a/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.test.tsx @@ -55,6 +55,24 @@ describe('Dimensions', () => { }); }); + describe('when rendered with two existing dimensions and values are represented as arrays', () => { + it('should render two filter items', async () => { + props.query.dimensions = { + InstanceId: ['*'], + InstanceGroup: ['Group1'], + }; + render(); + const filterItems = screen.getAllByTestId('cloudwatch-dimensions-filter-item'); + expect(filterItems.length).toBe(2); + + expect(within(filterItems[0]).getByText('InstanceId')).toBeInTheDocument(); + expect(within(filterItems[0]).getByText('*')).toBeInTheDocument(); + + expect(within(filterItems[1]).getByText('InstanceGroup')).toBeInTheDocument(); + expect(within(filterItems[1]).getByText('Group1')).toBeInTheDocument(); + }); + }); + describe('when adding a new filter item', () => { it('it should add the new item but not call onChange', async () => { props.query.dimensions = {}; diff --git a/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.tsx b/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.tsx index 4dc13d44744..c95365b3430 100644 --- a/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/Dimensions/Dimensions.tsx @@ -25,15 +25,32 @@ export interface DimensionFilterCondition { const dimensionsToFilterConditions = (dimensions: DimensionsType | undefined) => Object.entries(dimensions ?? {}).reduce((acc, [key, value]) => { - if (value && typeof value === 'string') { - const filter = { - key, - value, - operator: '=', - }; - return [...acc, filter]; + if (!value) { + return acc; } - return acc; + + // Previously, we only appended to the `acc`umulated dimensions if the value was a string. + // However, Cloudwatch can present dimensions with single-value arrays, e.g. + // k: FunctionName + // v: ['MyLambdaFunction'] + // in which case we grab the single-value from the Array and use that as the value. + let v = ''; + if (typeof value === 'string') { + v = value; + } else if (Array.isArray(value) && typeof value[0] === 'string') { + v = value[0]; + } + + if (!v) { + return acc; + } + + const filter = { + key: key, + value: v, + operator: '=', + }; + return [...acc, filter]; }, []); const filterConditionsToDimensions = (filters: DimensionFilterCondition[]) => { diff --git a/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx b/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx index 697720b3c35..1e1a1cf4ba2 100644 --- a/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/LogsCheatSheet.tsx @@ -223,10 +223,10 @@ export default class LogsCheatSheet extends PureComponent< onClickExample(query: CloudWatchQuery) { this.props.onClickExample(query); } - renderExpression(expr: string, keyPrefix: string) { return ( -
@@ -241,7 +241,7 @@ export default class LogsCheatSheet extends PureComponent< } >
{renderHighlightedMarkup(expr, keyPrefix)}
-
+ ); } diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index e15fb3da10d..42e1f6cdf62 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -19,23 +19,83 @@ describe('datasource', () => { jest.clearAllMocks(); }); describe('query', () => { - it('should return error if log query and log groups is not specified', async () => { - const { datasource } = setupMockedDataSource(); - const observable = datasource.query({ - targets: [{ queryMode: 'Logs', id: '', refId: '', region: '' }], - requestId: '', - interval: '', - intervalMs: 0, - range: timeRange, - scopedVars: {}, - timezone: '', - app: '', - startTime: 0, - }); + it('should not run a query if log groups is not specified', async () => { + const { datasource, fetchMock } = setupMockedDataSource(); + await lastValueFrom( + datasource.query({ + targets: [ + { + queryMode: 'Logs', + id: '', + refId: '', + region: '', + expression: 'some query string', // missing logGroups and logGroupNames, this query will be not be run + }, + { + queryMode: 'Logs', + id: '', + refId: '', + region: '', + logGroupNames: ['/some/group'], + expression: 'some query string', + }, + ], + requestId: '', + interval: '', + intervalMs: 0, + range: timeRange, + scopedVars: {}, + timezone: '', + app: '', + startTime: 0, + }) + ); - await expect(observable).toEmitValuesWith((received) => { - const response = received[0]; - expect(response.error?.message).toBe('Log group is required'); + expect(fetchMock.mock.calls[0][0].data.queries).toHaveLength(1); + expect(fetchMock.mock.calls[0][0].data.queries[0]).toMatchObject({ + queryString: 'some query string', + logGroupNames: ['/some/group'], + region: 'us-west-1', + }); + }); + + it('should not run a query if query expression is not specified', async () => { + const { datasource, fetchMock } = setupMockedDataSource(); + await lastValueFrom( + datasource.query({ + targets: [ + { + queryMode: 'Logs', + id: '', + refId: '', + region: '', + logGroupNames: ['/some/group'], // missing query expression, this query will be not be run + }, + { + queryMode: 'Logs', + id: '', + refId: '', + region: '', + logGroupNames: ['/some/group'], + expression: 'some query string', + }, + ], + requestId: '', + interval: '', + intervalMs: 0, + range: timeRange, + scopedVars: {}, + timezone: '', + app: '', + startTime: 0, + }) + ); + + expect(fetchMock.mock.calls[0][0].data.queries).toHaveLength(1); + expect(fetchMock.mock.calls[0][0].data.queries[0]).toMatchObject({ + queryString: 'some query string', + logGroupNames: ['/some/group'], + region: 'us-west-1', }); }); diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts index 73660dd5a08..80c8e3d59e7 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.test.ts @@ -1,18 +1,10 @@ import { interval, lastValueFrom, of } from 'rxjs'; -import { - dataFrameToJSON, - DataQueryErrorType, - DataQueryRequest, - FieldType, - LogLevel, - LogRowModel, - MutableDataFrame, -} from '@grafana/data'; +import { dataFrameToJSON, DataQueryErrorType, FieldType, LogLevel, LogRowModel, MutableDataFrame } from '@grafana/data'; import { genMockFrames, setupMockedLogsQueryRunner } from '../__mocks__/LogsQueryRunner'; import { validLogsQuery } from '../__mocks__/queries'; -import { CloudWatchQuery, LogAction } from '../types'; +import { LogAction } from '../types'; import * as rxjsUtils from '../utils/rxjs/increasingInterval'; import { LOG_IDENTIFIER_INTERNAL, LOGSTREAM_IDENTIFIER_INTERNAL } from './CloudWatchLogsQueryRunner'; @@ -221,32 +213,4 @@ describe('CloudWatchLogsQueryRunner', () => { expect(i).toBe(3); }); }); - - describe('handleLogQueries', () => { - it('should return error message when missing query string', async () => { - const { runner } = setupMockedLogsQueryRunner(); - const response = await lastValueFrom( - runner.handleLogQueries( - [ - { - datasource: { type: 'cloudwatch', uid: 'Zne6OZIVk' }, - id: '', - logGroups: [{ label: '', text: '', value: '' }], - queryMode: 'Logs', - refId: 'A', - region: 'default', - }, - ], - { scopedVars: {} } as DataQueryRequest - ) - ); - - expect(response).toEqual({ - data: [], - error: { - message: 'Query is required', - }, - }); - }); - }); }); diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts index 254f2e32ac2..2516cf85b73 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts @@ -8,7 +8,6 @@ import { map, mergeMap, Observable, - of, repeat, scan, share, @@ -85,7 +84,9 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest { logQueries: CloudWatchLogsQuery[], options: DataQueryRequest ): Observable => { - const queryParams = logQueries.map((target: CloudWatchLogsQuery) => ({ + const validLogQueries = logQueries.filter(this.filterQuery); + + const startQueryRequests: StartQueryRequest[] = validLogQueries.map((target: CloudWatchLogsQuery) => ({ queryString: target.expression || '', refId: target.refId, logGroupNames: target.logGroupNames || this.defaultLogGroups, @@ -98,24 +99,6 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest { ), })); - const hasQueryWithMissingLogGroupSelection = queryParams.some((qp) => { - const missingLogGroupNames = qp.logGroupNames.length === 0; - const missingLogGroups = qp.logGroups.length === 0; - return missingLogGroupNames && missingLogGroups; - }); - - if (hasQueryWithMissingLogGroupSelection) { - return of({ data: [], error: { message: 'Log group is required' } }); - } - - const hasQueryWithMissingQueryString = queryParams.some((qp) => { - return qp.queryString.length === 0; - }); - - if (hasQueryWithMissingQueryString) { - return of({ data: [], error: { message: 'Query is required' } }); - } - const startTime = new Date(); const timeoutFunc = () => { return Date.now() >= startTime.valueOf() + rangeUtil.intervalToMs(this.logsTimeout); @@ -129,7 +112,7 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest { skipCache: true, }); }, - queryParams, + startQueryRequests, timeoutFunc ).pipe( mergeMap(({ frames, error }: { frames: DataFrame[]; error?: DataQueryError }) => @@ -445,6 +428,18 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest { return getLogGroupFieldsResponse; } + + private filterQuery(query: CloudWatchLogsQuery) { + const hasMissingLegacyLogGroupNames = !query.logGroupNames?.length; + const hasMissingLogGroups = !query.logGroups?.length; + const hasMissingQueryString = !query.expression?.length; + + if ((hasMissingLogGroups && hasMissingLegacyLogGroupNames) || hasMissingQueryString) { + return false; + } + + return true; + } } function withTeardown(observable: Observable, onUnsubscribe: () => void): Observable { diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts index d656913493d..3f17b651ead 100644 --- a/public/app/plugins/datasource/cloudwatch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -331,7 +331,8 @@ export interface StartQueryRequest { /** * The list of log groups to be queried. You can include up to 20 log groups. A StartQuery operation must include a logGroupNames or a logGroupName parameter, but not both. */ - logGroupNames?: string[]; + logGroupNames?: string[] /* not quite deprecated yet, but will be soon */; + logGroups?: SelectableResourceValue[]; /** * The query string to use. For more information, see CloudWatch Logs Insights Query Syntax. */ diff --git a/public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts b/public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts index f83c4829c5b..9f146fd90a7 100644 --- a/public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts +++ b/public/app/plugins/datasource/cloudwatch/utils/datalinks.test.ts @@ -35,6 +35,7 @@ describe('addDataLinksToLogsResponse', () => { refId: 'A', expression: 'stats count(@message) by bin(1h)', logGroupNames: ['fake-log-group-one', 'fake-log-group-two'], + logGroups: [{}], // empty log groups should be ignored and fall back to logGroupNames region: 'us-east-1', }, ], @@ -115,6 +116,7 @@ describe('addDataLinksToLogsResponse', () => { { refId: 'A', expression: 'stats count(@message) by bin(1h)', + logGroupNames: [''], logGroups: [ { value: 'arn:aws:logs:us-east-1:111111111111:log-group:/aws/lambda/test:*' }, { value: 'arn:aws:logs:us-east-2:222222222222:log-group:/ecs/prometheus:*' }, @@ -174,6 +176,7 @@ describe('addDataLinksToLogsResponse', () => { { refId: 'A', expression: 'stats count(@message) by bin(1h)', + logGroupNames: [''], logGroups: [{ value: 'arn:aws:logs:us-east-1:111111111111:log-group:/aws/lambda/test' }], region: 'us-east-1', } as CloudWatchQuery, diff --git a/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts b/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts index 3cfcb7cd7d4..8d29e0338a6 100644 --- a/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts +++ b/public/app/plugins/datasource/cloudwatch/utils/datalinks.ts @@ -70,16 +70,13 @@ function createAwsConsoleLink( replace: (target: string, fieldName?: string) => string, getVariableValue: (value: string) => string[] ) { - const arns = target.logGroups?.flatMap((group) => { - if (group.value === undefined) { - return []; - } - return [group.value.replace(/:\*$/, '')]; // remove `:*` from end of arn - }); - const logGroupNames = target.logGroupNames; - const sources = arns ?? logGroupNames; + const arns = (target.logGroups ?? []) + .filter((group) => group?.value) + .map((group) => (group.value ?? '').replace(/:\*$/, '')); // remove `:*` from end of arn + const logGroupNames = target.logGroupNames ?? []; + const sources = arns?.length ? arns : logGroupNames; const interpolatedExpression = target.expression ? replace(target.expression) : ''; - const interpolatedGroups = sources?.flatMap(getVariableValue) ?? []; + const interpolatedGroups = sources?.flatMap(getVariableValue); const urlProps: AwsUrl = { end: range.to.toISOString(), diff --git a/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts b/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts index e05cc478f34..b091f5a9c00 100644 --- a/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts +++ b/public/app/plugins/datasource/elasticsearch/QueryBuilder.test.ts @@ -22,7 +22,7 @@ describe('ElasticQueryBuilder', () => { // The following `missing: null as any` is because previous versions of the DS where // storing null in the query model when inputting an empty string, // which were then removed in the query builder. - // The new version doesn't store empty strings at all. This tests ensures backward compatinility. + // The new version doesn't store empty strings at all. This tests ensures backward compatibility. metrics: [{ type: 'avg', id: '0', settings: { missing: null as any, script: '1' } }], timeField: '@timestamp', bucketAggs: [{ type: 'date_histogram', field: '@timestamp', id: '1' }], diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index 2c582baca57..09b6dc8d122 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -18,7 +18,7 @@ import { TimeRange, toUtc, } from '@grafana/data'; -import { BackendSrvRequest, FetchResponse } from '@grafana/runtime'; +import { BackendSrvRequest, FetchResponse, reportInteraction } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TemplateSrv } from 'app/features/templating/template_srv'; @@ -35,6 +35,7 @@ const ELASTICSEARCH_MOCK_URL = 'http://elasticsearch.local'; jest.mock('@grafana/runtime', () => ({ ...(jest.requireActual('@grafana/runtime') as unknown as object), getBackendSrv: () => backendSrv, + reportInteraction: jest.fn(), getDataSourceSrv: () => { return { getInstanceSettings: () => { @@ -275,6 +276,23 @@ describe('ElasticDatasource', () => { const { body } = await runScenario(); expect(body.query.bool.filter[1].query_string.query).toBe('escape\\:test'); }); + + it('should report query interaction', async () => { + await runScenario(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_elasticsearch_query_executed', + expect.objectContaining({ + alias: '$varAlias', + app: 'test', + has_data: true, + has_error: false, + line_limit: undefined, + query_type: 'metric', + simultaneously_sent_query_count: 1, + with_lucene_query: true, + }) + ); + }); }); describe('When issuing logs query with interval pattern', () => { @@ -344,6 +362,23 @@ describe('ElasticDatasource', () => { expect(links[0].url).toBe('http://localhost:3000/${__value.raw}'); expect(links[0].title).toBe('Custom Label'); }); + + it('should report query interaction', async () => { + await setupDataSource(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_elasticsearch_query_executed', + expect.objectContaining({ + alias: '$varAlias', + app: undefined, + has_data: true, + has_error: false, + line_limit: undefined, + query_type: 'logs', + simultaneously_sent_query_count: 1, + with_lucene_query: true, + }) + ); + }); }); describe('When issuing document query', () => { @@ -380,6 +415,22 @@ describe('ElasticDatasource', () => { const { body } = await runScenario(); expect(body.size).toBe(500); }); + it('should report query interaction', async () => { + await runScenario(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_elasticsearch_query_executed', + expect.objectContaining({ + alias: undefined, + app: 'test', + has_data: false, + has_error: false, + line_limit: undefined, + query_type: 'raw_document', + simultaneously_sent_query_count: 1, + with_lucene_query: true, + }) + ); + }); }); describe('When getting an error on response', () => { diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 33e11d60eff..49e775194c9 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -1,6 +1,6 @@ import { cloneDeep, find, first as _first, isNumber, isObject, isString, map as _map } from 'lodash'; import { generate, lastValueFrom, Observable, of, throwError } from 'rxjs'; -import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty } from 'rxjs/operators'; +import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty, tap } from 'rxjs/operators'; import { DataFrame, @@ -50,9 +50,11 @@ import { } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; import { defaultBucketAgg, hasMetricOfType } from './queryDef'; +import { trackQuery } from './tracking'; import { DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery, TermsQuery } from './types'; import { coerceESVersion, getScriptValue, isSupportedVersion } from './utils'; +export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-'; // Those are metadata fields as defined in https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-fields.html#_identity_metadata_fields. // custom fields can start with underscores, therefore is not safe to exclude anything that starts with one. const ELASTIC_META_FIELDS = [ @@ -616,7 +618,7 @@ export class ElasticDatasource }); const logsVolumeQuery: ElasticsearchQuery = { - refId: target.refId, + refId: `${REF_ID_STARTER_LOG_VOLUME}${target.refId}`, query: target.query, metrics: [{ type: 'count', id: '1' }], timeField, @@ -636,7 +638,7 @@ export class ElasticDatasource const shouldRunTroughBackend = request.app === CoreApp.Explore && config.featureToggles.elasticsearchBackendMigration; if (shouldRunTroughBackend) { - return super.query(request); + return super.query(request).pipe(tap((response) => trackQuery(response, request.targets, request.app))); } let payload = ''; const targets = this.interpolateVariablesInQueries(cloneDeep(request.targets), request.scopedVars); @@ -718,7 +720,8 @@ export class ElasticDatasource } return er.getTimeSeries(); - }) + }), + tap((response) => trackQuery(response, request.targets, request.app)) ); } diff --git a/public/app/plugins/datasource/elasticsearch/module.test.ts b/public/app/plugins/datasource/elasticsearch/module.test.ts new file mode 100644 index 00000000000..d86a40ff06f --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/module.test.ts @@ -0,0 +1,152 @@ +import { DashboardLoadedEvent } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import './module'; + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + getAppEvents: () => ({ + subscribe: jest.fn((_, handler) => { + // Trigger test event + handler( + new DashboardLoadedEvent({ + dashboardId: 'dashboard123', + orgId: 1, + userId: 2, + grafanaVersion: 'v9.0.0', + queries: { + elasticsearch: [ + { + alias: '', + bucketAggs: [], + datasource: { + type: 'elasticsearch', + uid: 'PE50363A9B6833EE7', + }, + metrics: [ + { + id: '1', + settings: { + limit: '501', + }, + type: 'logs', + }, + ], + query: 'abc:def', + refId: 'A', + timeField: '@timestamp', + }, + { + alias: '', + bucketAggs: [], + datasource: { + type: 'elasticsearch', + uid: 'es1', + }, + metrics: [ + { + id: '1', + settings: { + size: '600', + }, + type: 'raw_data', + }, + ], + query: '', + }, + { + alias: 'alias', + bucketAggs: [ + { + field: '@timestamp', + id: '2', + settings: { + interval: 'auto', + }, + type: 'date_histogram', + }, + ], + datasource: { + type: 'elasticsearch', + uid: 'es1', + }, + metrics: [ + { + id: '3', + type: 'count', + }, + ], + query: 'abc:def', + }, + { + alias: '', + bucketAggs: [], + datasource: { + type: 'elasticsearch', + uid: 'PE50363A9B6833EE7', + }, + metrics: [ + { + id: '1', + settings: { + size: '600', + }, + type: 'raw_document', + }, + ], + query: '', + refId: 'A', + timeField: '@timestamp', + }, + { + alias: '', + bucketAggs: [ + { + field: '@timestamp', + id: '2', + settings: { + interval: 'auto', + }, + type: 'date_histogram', + }, + ], + datasource: { + type: 'elasticsearch', + uid: 'es1', + }, + metrics: [ + { + field: 'counter', + id: '1', + type: 'avg', + }, + ], + query: '$test:abc', + }, + ], + }, + }) + ); + }), + }), + }; +}); + +describe('queriesOnInitDashboard', () => { + it('should report a grafana_elasticsearch_dashboard_loaded interaction ', () => { + expect(reportInteraction).toHaveBeenCalledWith('grafana_elasticsearch_dashboard_loaded', { + grafana_version: 'v9.0.0', + dashboard_id: 'dashboard123', + org_id: 1, + queries_count: 5, + queries_with_changed_line_limit_count: 1, + queries_with_lucene_query_count: 3, + queries_with_template_variables_count: 1, + raw_data_queries_count: 1, + raw_document_queries_count: 1, + logs_queries_count: 1, + metric_queries_count: 2, + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/module.ts b/public/app/plugins/datasource/elasticsearch/module.ts index f1b5a850cb4..d6c0b285eb1 100644 --- a/public/app/plugins/datasource/elasticsearch/module.ts +++ b/public/app/plugins/datasource/elasticsearch/module.ts @@ -1,7 +1,13 @@ -import { DataSourcePlugin } from '@grafana/data'; +import { DashboardLoadedEvent, DataSourcePlugin } from '@grafana/data'; +import { getAppEvents } from '@grafana/runtime'; import { QueryEditor } from './components/QueryEditor'; import { ConfigEditor } from './configuration/ConfigEditor'; import { ElasticDatasource } from './datasource'; +import { onDashboardLoadedHandler } from './tracking'; +import { ElasticsearchQuery } from './types'; export const plugin = new DataSourcePlugin(ElasticDatasource).setQueryEditor(QueryEditor).setConfigEditor(ConfigEditor); + +// Subscribe to on dashboard loaded event so that we can track plugin adoption +getAppEvents().subscribe>(DashboardLoadedEvent, onDashboardLoadedHandler); diff --git a/public/app/plugins/datasource/elasticsearch/tracking.ts b/public/app/plugins/datasource/elasticsearch/tracking.ts new file mode 100644 index 00000000000..733d946eba7 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/tracking.ts @@ -0,0 +1,133 @@ +import { CoreApp, DashboardLoadedEvent, DataQueryResponse } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { variableRegex } from 'app/features/variables/utils'; + +import { REF_ID_STARTER_LOG_VOLUME } from './datasource'; +import pluginJson from './plugin.json'; +import { ElasticsearchQuery } from './types'; + +type ElasticSearchOnDashboardLoadedTrackingEvent = { + grafana_version?: string; + dashboard_id?: string; + org_id?: number; + + /* The number of Elasticsearch queries present in the dashboard*/ + queries_count: number; + + /* The number of Elasticsearch logs queries present in the dashboard*/ + logs_queries_count: number; + + /* The number of Elasticsearch metric queries present in the dashboard*/ + metric_queries_count: number; + + /* The number of Elasticsearch raw data queries present in the dashboard*/ + raw_data_queries_count: number; + + /* The number of Elasticsearch raw documents queries present in the dashboard*/ + raw_document_queries_count: number; + + /* The number of Elasticsearch queries with used template variables present in the dashboard*/ + queries_with_template_variables_count: number; + + /* The number of Elasticsearch queries with changed line limit present in the dashboard*/ + queries_with_changed_line_limit_count: number; + + /* The number of Elasticsearch queries with lucene query present in the dashboard*/ + queries_with_lucene_query_count: number; +}; + +export const onDashboardLoadedHandler = ({ + payload: { dashboardId, orgId, grafanaVersion, queries }, +}: DashboardLoadedEvent) => { + try { + // We only want to track visible ElasticSearch queries + const elasticsearchQueries = queries[pluginJson.id].filter((query) => !query.hide); + if (!elasticsearchQueries?.length) { + return; + } + + const queriesWithTemplateVariables = elasticsearchQueries.filter(isQueryWithTemplateVariables); + const queriesWithLuceneQuery = elasticsearchQueries.filter((query) => !!query.query); + const logsQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'logs'); + const metricQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'metric'); + const rawDataQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'raw_data'); + const rawDocumentQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'raw_document'); + const queriesWithChangedLineLimit = elasticsearchQueries.filter(isQueryWithChangedLineLimit); + + const event: ElasticSearchOnDashboardLoadedTrackingEvent = { + grafana_version: grafanaVersion, + dashboard_id: dashboardId, + org_id: orgId, + queries_count: elasticsearchQueries.length, + logs_queries_count: logsQueries.length, + metric_queries_count: metricQueries.length, + raw_data_queries_count: rawDataQueries.length, + raw_document_queries_count: rawDocumentQueries.length, + queries_with_template_variables_count: queriesWithTemplateVariables.length, + queries_with_changed_line_limit_count: queriesWithChangedLineLimit.length, + queries_with_lucene_query_count: queriesWithLuceneQuery.length, + }; + + reportInteraction('grafana_elasticsearch_dashboard_loaded', event); + } catch (error) { + console.error('error in elasticsearch tracking handler', error); + } +}; + +const getQueryType = (query: ElasticsearchQuery): string | undefined => { + if (!query.metrics || !query.metrics.length) { + return undefined; + } + const nonMetricQueryTypes = ['logs', 'raw_data', 'raw_document']; + if (nonMetricQueryTypes.includes(query.metrics[0].type)) { + return query.metrics[0].type; + } + return 'metric'; +}; + +const getLineLimit = (query: ElasticsearchQuery): number | undefined => { + if (query.metrics?.[0]?.type !== 'logs') { + return undefined; + } + + const lineLimit = query.metrics?.[0].settings?.limit; + return lineLimit ? parseInt(lineLimit, 10) : undefined; +}; + +const isQueryWithChangedLineLimit = (query: ElasticsearchQuery): boolean => { + const lineLimit = getLineLimit(query); + return lineLimit !== undefined && lineLimit !== 500; +}; + +const isQueryWithTemplateVariables = (query: ElasticsearchQuery): boolean => { + return variableRegex.test(query.query ?? ''); +}; + +const shouldNotReportBasedOnRefId = (refId: string): boolean => { + if (refId.startsWith(REF_ID_STARTER_LOG_VOLUME)) { + return true; + } + return false; +}; + +export function trackQuery(response: DataQueryResponse, queries: ElasticsearchQuery[], app: string): void { + if (app === CoreApp.Dashboard || app === CoreApp.PanelViewer) { + return; + } + + for (const query of queries) { + if (shouldNotReportBasedOnRefId(query.refId)) { + return; + } + reportInteraction('grafana_elasticsearch_query_executed', { + app, + with_lucene_query: query.query ? true : false, + query_type: getQueryType(query), + line_limit: getLineLimit(query), + alias: query.alias, + has_error: response.error !== undefined, + has_data: response.data.some((frame) => frame.length > 0), + simultaneously_sent_query_count: queries.length, + }); + } +} diff --git a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx index 94e53cef3bb..b6c75f526a4 100644 --- a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx +++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx @@ -81,9 +81,14 @@ export default class LokiCheatSheet extends PureComponent onClick({ refId: 'A', expr })}> +
+ ); } diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 0b9046ce348..eff45c8ea56 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -6,6 +6,7 @@ import { AbstractLabelOperator, AnnotationQueryRequest, ArrayVector, + CoreApp, DataFrame, dataFrameToJSON, DataQueryResponse, @@ -15,17 +16,32 @@ import { LogRowModel, MutableDataFrame, } from '@grafana/data'; -import { BackendSrvRequest, FetchResponse, setBackendSrv, getBackendSrv, BackendSrv } from '@grafana/runtime'; +import { + BackendSrv, + BackendSrvRequest, + FetchResponse, + getBackendSrv, + reportInteraction, + setBackendSrv, +} from '@grafana/runtime'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { initialCustomVariableModelState } from '../../../features/variables/custom/reducer'; import { CustomVariableModel } from '../../../features/variables/types'; -import { LokiDatasource } from './datasource'; -import { createMetadataRequest, createLokiDatasource } from './mocks'; +import { LokiDatasource, REF_ID_DATA_SAMPLES } from './datasource'; +import { createLokiDatasource, createMetadataRequest } from './mocks'; +import { parseToNodeNamesArray } from './queryUtils'; import { LokiOptions, LokiQuery, LokiQueryType, LokiVariableQueryType } from './types'; import { LokiVariableSupport } from './variables'; +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); + const templateSrvStub = { getAdhocFilters: jest.fn(() => [] as unknown[]), replace: jest.fn((a: string, ...rest: unknown[]) => a), @@ -112,13 +128,15 @@ describe('LokiDatasource', () => { afterEach(() => { setBackendSrv(origBackendSrv); + (reportInteraction as jest.Mock).mockClear(); }); describe('when doing logs queries with limits', () => { const runTest = async ( queryMaxLines: number | undefined, dsMaxLines: string | undefined, - expectedMaxLines: number + expectedMaxLines: number, + app: CoreApp | undefined ) => { const settings = { jsonData: { @@ -134,6 +152,7 @@ describe('LokiDatasource', () => { const options = getQueryOptions({ targets: [{ expr: '{a="b"}', refId: 'B', maxLines: queryMaxLines }], + app: app ?? CoreApp.Dashboard, }); const fetchMock = jest.fn().mockReturnValue(of({ data: testLogsResponse })); @@ -146,15 +165,44 @@ describe('LokiDatasource', () => { }; it('should use datasource max lines when no query max lines', async () => { - await runTest(undefined, '40', 40); + await runTest(undefined, '40', 40, undefined); }); it('should use query max lines, if exists', async () => { - await runTest(80, undefined, 80); + await runTest(80, undefined, 80, undefined); }); it('should use query max lines, if both exist, even if it is higher than ds max lines', async () => { - await runTest(80, '40', 80); + await runTest(80, '40', 80, undefined); + }); + + it('should report query interaction', async () => { + await runTest(80, '40', 80, CoreApp.Explore); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_loki_query_executed', + expect.objectContaining({ + query_type: 'logs', + line_limit: 80, + parsed_query: parseToNodeNamesArray('{a="b"}').join(','), + }) + ); + }); + + it('should not report query interaction for dashboard query', async () => { + await runTest(80, '40', 80, CoreApp.Dashboard); + expect(reportInteraction).not.toBeCalled(); + }); + + it('should not report query interaction for panel edit query', async () => { + await runTest(80, '40', 80, CoreApp.PanelEditor); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_loki_query_executed', + expect.objectContaining({ + query_type: 'logs', + line_limit: 80, + parsed_query: parseToNodeNamesArray('{a="b"}').join(','), + }) + ); }); }); @@ -918,7 +966,7 @@ describe('LokiDatasource', () => { expect(spy).toHaveBeenCalledWith( expect.objectContaining({ hideFromInspector: true, - requestId: 'log-samples', + requestId: REF_ID_DATA_SAMPLES, }) ); }); diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index c323694fa5d..6510fae6b2c 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -1,8 +1,9 @@ import { cloneDeep, map as lodashMap } from 'lodash'; import { lastValueFrom, merge, Observable, of, throwError } from 'rxjs'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { + AbstractQuery, AnnotationEvent, AnnotationQueryRequest, CoreApp, @@ -19,21 +20,20 @@ import { dateMath, DateTime, FieldCache, - AbstractQuery, FieldType, + getDefaultTimeRange, Labels, LoadingState, LogLevel, LogRowModel, + QueryFixAction, + QueryHint, + rangeUtil, ScopedVars, TimeRange, - rangeUtil, toUtc, - QueryHint, - getDefaultTimeRange, - QueryFixAction, } from '@grafana/data'; -import { FetchError, config, DataSourceWithBackend } from '@grafana/runtime'; +import { config, DataSourceWithBackend, FetchError } from '@grafana/runtime'; import { queryLogsVolume } from 'app/core/logsModel'; import { convertToWebSocketUrl } from 'app/core/utils/explore'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -62,6 +62,7 @@ import { getQueryHints } from './queryHints'; import { getNormalizedLokiQuery, isLogsQuery, isValidQuery } from './queryUtils'; import { sortDataFrameByTime } from './sortDataFrame'; import { doLokiChannelStream } from './streaming'; +import { trackQuery } from './tracking'; import { LokiOptions, LokiQuery, @@ -75,6 +76,10 @@ import { LokiVariableSupport } from './variables'; export type RangeQueryOptions = DataQueryRequest | AnnotationQueryRequest; export const DEFAULT_MAX_LINES = 1000; export const LOKI_ENDPOINT = '/loki/api/v1'; +export const REF_ID_DATA_SAMPLES = 'loki-data-samples'; +export const REF_ID_STARTER_ANNOTATION = 'annotation-'; +export const REF_ID_STARTER_LOG_ROW_CONTEXT = 'log-row-context-query-'; +export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-'; const NS_IN_MS = 1000000; function makeRequest( @@ -146,6 +151,7 @@ export class LokiDatasource const query = removeCommentsFromQuery(target.expr); return { ...target, + refId: `${REF_ID_STARTER_LOG_VOLUME}${target.refId}`, instant: false, volumeQuery: true, expr: `sum by (level) (count_over_time(${query}[$__interval]))`, @@ -191,13 +197,13 @@ export class LokiDatasource if (fixedRequest.liveStreaming) { return this.runLiveQueryThroughBackend(fixedRequest); } else { - return super - .query(fixedRequest) - .pipe( - map((response) => - transformBackendResult(response, fixedRequest.targets, this.instanceSettings.jsonData.derivedFields ?? []) - ) - ); + return super.query(fixedRequest).pipe( + // in case of an empty query, this is somehow run twice. `share()` is no workaround here as the observable is generated from `of()`. + map((response) => + transformBackendResult(response, fixedRequest.targets, this.instanceSettings.jsonData.derivedFields ?? []) + ), + tap((response) => trackQuery(response, fixedRequest.targets, fixedRequest.app)) + ); } } @@ -418,13 +424,13 @@ export class LokiDatasource const lokiLogsQuery: LokiQuery = { expr: query.expr, queryType: LokiQueryType.Range, - refId: 'log-samples', + refId: REF_ID_DATA_SAMPLES, maxLines: 10, }; // For samples, we use defaultTimeRange (now-6h/now) and limit od 10 lines so queries are small and fast const timeRange = getDefaultTimeRange(); - const request = makeRequest(lokiLogsQuery, timeRange, CoreApp.Explore, 'log-samples', true); + const request = makeRequest(lokiLogsQuery, timeRange, CoreApp.Unknown, REF_ID_DATA_SAMPLES, true); return await lastValueFrom(this.query(request).pipe(switchMap((res) => of(res.data)))); } @@ -555,7 +561,7 @@ export class LokiDatasource const app = CoreApp.Explore; return lastValueFrom( - this.query(makeRequest(query, range, app, `log-row-context-query-${direction}`)).pipe( + this.query(makeRequest(query, range, app, `${REF_ID_STARTER_LOG_ROW_CONTEXT}${direction}`)).pipe( catchError((err) => { const error: DataQueryError = { message: 'Error during context query. Please check JS console logs.', @@ -596,7 +602,7 @@ export class LokiDatasource const query: LokiQuery = { expr: `{${expr}}`, queryType: LokiQueryType.Range, - refId: row.dataFrame.refId ?? '', + refId: `${REF_ID_STARTER_LOG_ROW_CONTEXT}${row.dataFrame.refId || ''}`, maxLines: limit, direction: queryDirection, }; @@ -674,7 +680,7 @@ export class LokiDatasource return []; } - const id = `annotation-${options.annotation.name}`; + const id = `${REF_ID_STARTER_ANNOTATION}${options.annotation.name}`; const query: LokiQuery = { refId: id, diff --git a/public/app/plugins/datasource/loki/languageUtils.test.ts b/public/app/plugins/datasource/loki/languageUtils.test.ts index f9fa3c975e7..80d7b2aa7e6 100644 --- a/public/app/plugins/datasource/loki/languageUtils.test.ts +++ b/public/app/plugins/datasource/loki/languageUtils.test.ts @@ -1,4 +1,4 @@ -import { isBytesString } from './languageUtils'; +import { escapeLabelValueInExactSelector, isBytesString, unescapeLabelValue } from './languageUtils'; describe('isBytesString', () => { it('correctly matches bytes string with integers', () => { @@ -18,3 +18,27 @@ describe('isBytesString', () => { expect(isBytesString('1.234')).toBe(false); }); }); + +describe('escapeLabelValueInExactSelector', () => { + it.each` + value | escapedValue + ${'nothing to escape'} | ${'nothing to escape'} + ${'escape quote: "'} | ${'escape quote: \\"'} + ${'escape newline: \nend'} | ${'escape newline: \\nend'} + ${'escape slash: \\'} | ${'escape slash: \\\\'} + `('when called with $value', ({ value, escapedValue }) => { + expect(escapeLabelValueInExactSelector(value)).toEqual(escapedValue); + }); +}); + +describe('unescapeLabelValueInExactSelector', () => { + it.each` + value | unescapedValue + ${'nothing to unescape'} | ${'nothing to unescape'} + ${'escape quote: \\"'} | ${'escape quote: "'} + ${'escape newline: \\nend'} | ${'escape newline: \nend'} + ${'escape slash: \\\\'} | ${'escape slash: \\'} + `('when called with $value', ({ value, unescapedValue }) => { + expect(unescapeLabelValue(value)).toEqual(unescapedValue); + }); +}); diff --git a/public/app/plugins/datasource/loki/languageUtils.ts b/public/app/plugins/datasource/loki/languageUtils.ts index 0dfcd1b35e2..581d8a991a1 100644 --- a/public/app/plugins/datasource/loki/languageUtils.ts +++ b/public/app/plugins/datasource/loki/languageUtils.ts @@ -35,6 +35,10 @@ export function escapeLabelValueInExactSelector(labelValue: string): string { return labelValue.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); } +export function unescapeLabelValue(labelValue: string): string { + return labelValue.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); +} + export function escapeLabelValueInRegexSelector(labelValue: string): string { return escapeLabelValueInExactSelector(escapeLokiRegexp(labelValue)); } diff --git a/public/app/plugins/datasource/loki/modifyQuery.test.ts b/public/app/plugins/datasource/loki/modifyQuery.test.ts index 171b5b02a57..8fcc107c373 100644 --- a/public/app/plugins/datasource/loki/modifyQuery.test.ts +++ b/public/app/plugins/datasource/loki/modifyQuery.test.ts @@ -8,42 +8,50 @@ import { describe('addLabelToQuery()', () => { it.each` - query | description | label | operator | value | expectedResult - ${'{x="y"}'} | ${'no label and value'} | ${''} | ${'='} | ${''} | ${''} - ${'{x="yy"}'} | ${'simple query'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="yy", bar="baz"}'} - ${'{x="yy"}'} | ${'simple query'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="yy", bar="baz"}'} - ${'{x="yy"}'} | ${'custom operator'} | ${'bar'} | ${'!='} | ${'baz'} | ${'{x="yy", bar!="baz"}'} - ${'rate({}[1m])'} | ${'do not modify ranges'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({bar="baz"}[1m])'} - ${'sum by (host) (rate({} [1m]))'} | ${'detect in-order function use'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum by (host) (rate({bar="baz"}[1m]))'} - ${'{instance="my-host.com:9100"}'} | ${'selectors with punctuation'} | ${'bar'} | ${'='} | ${'baz'} | ${'{instance="my-host.com:9100", bar="baz"}'} - ${'{list="a,b,c"}'} | ${'selectors with punctuation'} | ${'bar'} | ${'='} | ${'baz'} | ${'{list="a,b,c", bar="baz"}'} - ${'rate({}[5m]) + rate({}[5m])'} | ${'arithmetical expressions'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({bar="baz"}[5m]) + rate({bar="baz"}[5m])'} - ${'avg(rate({x="y"} [$__interval]))+ sum(rate({}[5m]))'} | ${'arithmetical expressions'} | ${'bar'} | ${'='} | ${'baz'} | ${'avg(rate({x="y", bar="baz"} [$__interval]))+ sum(rate({bar="baz"}[5m]))'} - ${'rate({x="yy"}[5m]) * rate({y="zz",a="bb"}[5m]) * rate({}[5m])'} | ${'arithmetical expressions'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({x="yy", bar="baz"}[5m]) * rate({y="zz", a="bb", bar="baz"}[5m]) * rate({bar="baz"}[5m])'} - ${'{x="yy", bar!="baz"}'} | ${'do not add duplicate labels'} | ${'bar'} | ${'!='} | ${'baz'} | ${'{x="yy", bar!="baz"}'} - ${'rate({bar="baz"}[1m])'} | ${'do not add duplicate labels'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({bar="baz"}[1m])'} - ${'{list="a,b,c", bar="baz"}'} | ${'do not add duplicate labels'} | ${'bar'} | ${'='} | ${'baz'} | ${'{list="a,b,c", bar="baz"}'} - ${'avg(rate({bar="baz"} [$__interval]))+ sum(rate({bar="baz"}[5m]))'} | ${'do not add duplicate labels'} | ${'bar'} | ${'='} | ${'baz'} | ${'avg(rate({bar="baz"} [$__interval]))+ sum(rate({bar="baz"}[5m]))'} - ${'{x="y"} |="yy"'} | ${'do not remove filters'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} |="yy"'} - ${'{x="y"} |="yy" !~"xx"'} | ${'do not remove filters'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} |="yy" !~"xx"'} - ${'{x="y"} or {}'} | ${'metric with logical operators'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} or {bar="baz"}'} - ${'{x="y"} and {}'} | ${'metric with logical operators'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} and {bar="baz"}'} - ${'sum(rate({job="foo"}[2m])) by (value $variable)'} | ${'template variables'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum(rate({job="foo", bar="baz"}[2m])) by (value $variable)'} - ${'rate({x="y"}[${__range_s}s])'} | ${'metric query with range grafana variable'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({x="y", bar="baz"}[${__range_s}s])'} - ${'max by (id, name, type) ({type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (rate({} [5m])) * 1000'} | ${'metric query with labels in label list with the group modifier'} | ${'bar'} | ${'='} | ${'baz'} | ${'max by (id, name, type) ({type=~"foo|bar|baz-test", bar="baz"}) * on(id) group_right(id, type, name) sum by (id) (rate({bar="baz"}[5m])) * 1000'} - ${'{foo="bar"} | logfmt'} | ${'query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | bar=`baz`'} - ${'{foo="bar"} | logfmt | json'} | ${'query with multiple parsers'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | json | bar=`baz`'} - ${'{foo="bar"} | logfmt | x="y"'} | ${'query with parser and label filter'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | x="y" | bar=`baz`'} - ${'rate({foo="bar"} | logfmt [5m])'} | ${'metric query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({foo="bar"} | logfmt | bar=`baz` [5m])'} - ${'sum by(host) (rate({foo="bar"} | logfmt | x="y" | line_format "{{.status}}" [5m]))'} | ${'metric query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum by(host) (rate({foo="bar"} | logfmt | x="y" | bar=`baz` | line_format "{{.status}}" [5m]))'} - ${'{foo="bar"} | logfmt | line_format "{{.status}}"'} | ${'do not add filter to line_format expressions in query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | bar=`baz` | line_format "{{.status}}"'} - ${'{foo="bar"} | logfmt | line_format "{{status}}"'} | ${'do not add filter to line_format expressions in query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | bar=`baz` | line_format "{{status}}"'} - ${'{}'} | ${'query without stream selector'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}'} - ${'{} | logfmt'} | ${'query without stream selector and with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}| logfmt'} - ${'{} | x="y"'} | ${'query without stream selector and with label filter'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}| x="y"'} - ${'{} | logfmt | x="y"'} | ${'query without stream selector and with parser and label filter'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}| logfmt | x="y"'} - ${'sum(rate({x="y"} [5m])) + sum(rate({} | logfmt [5m]))'} | ${'metric query with 1 empty and 1 not empty stream selector with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum(rate({x="y", bar="baz"} [5m])) + sum(rate({bar="baz"}| logfmt [5m]))'} - ${'sum(rate({x="y"} | logfmt [5m])) + sum(rate({} [5m]))'} | ${'metric query with 1 non-empty and 1 not empty stream selector with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum(rate({x="y", bar="baz"} | logfmt [5m])) + sum(rate({bar="baz"}[5m]))'} + query | description | label | operator | value | expectedResult + ${'{x="y"}'} | ${'no label and value'} | ${''} | ${'='} | ${''} | ${''} + ${'{x="yy"}'} | ${'simple query'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="yy", bar="baz"}'} + ${'{x="yy"}'} | ${'simple query'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="yy", bar="baz"}'} + ${'{x="yy"}'} | ${'custom operator'} | ${'bar'} | ${'!='} | ${'baz'} | ${'{x="yy", bar!="baz"}'} + ${'rate({}[1m])'} | ${'do not modify ranges'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({bar="baz"}[1m])'} + ${'sum by (host) (rate({} [1m]))'} | ${'detect in-order function use'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum by (host) (rate({bar="baz"}[1m]))'} + ${'{instance="my-host.com:9100"}'} | ${'selectors with punctuation'} | ${'bar'} | ${'='} | ${'baz'} | ${'{instance="my-host.com:9100", bar="baz"}'} + ${'{list="a,b,c"}'} | ${'selectors with punctuation'} | ${'bar'} | ${'='} | ${'baz'} | ${'{list="a,b,c", bar="baz"}'} + ${'rate({}[5m]) + rate({}[5m])'} | ${'arithmetical expressions'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({bar="baz"}[5m]) + rate({bar="baz"}[5m])'} + ${'avg(rate({x="y"} [$__interval]))+ sum(rate({}[5m]))'} | ${'arithmetical expressions'} | ${'bar'} | ${'='} | ${'baz'} | ${'avg(rate({x="y", bar="baz"} [$__interval]))+ sum(rate({bar="baz"}[5m]))'} + ${'rate({x="yy"}[5m]) * rate({y="zz",a="bb"}[5m]) * rate({}[5m])'} | ${'arithmetical expressions'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({x="yy", bar="baz"}[5m]) * rate({y="zz", a="bb", bar="baz"}[5m]) * rate({bar="baz"}[5m])'} + ${'{x="yy", bar!="baz"}'} | ${'do not add duplicate labels'} | ${'bar'} | ${'!='} | ${'baz'} | ${'{x="yy", bar!="baz"}'} + ${'rate({bar="baz"}[1m])'} | ${'do not add duplicate labels'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({bar="baz"}[1m])'} + ${'{list="a,b,c", bar="baz"}'} | ${'do not add duplicate labels'} | ${'bar'} | ${'='} | ${'baz'} | ${'{list="a,b,c", bar="baz"}'} + ${'avg(rate({bar="baz"} [$__interval]))+ sum(rate({bar="baz"}[5m]))'} | ${'do not add duplicate labels'} | ${'bar'} | ${'='} | ${'baz'} | ${'avg(rate({bar="baz"} [$__interval]))+ sum(rate({bar="baz"}[5m]))'} + ${'{x="y"} |="yy"'} | ${'do not remove filters'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} |="yy"'} + ${'{x="y"} |="yy" !~"xx"'} | ${'do not remove filters'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} |="yy" !~"xx"'} + ${'{x="y"} or {}'} | ${'metric with logical operators'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} or {bar="baz"}'} + ${'{x="y"} and {}'} | ${'metric with logical operators'} | ${'bar'} | ${'='} | ${'baz'} | ${'{x="y", bar="baz"} and {bar="baz"}'} + ${'sum(rate({job="foo"}[2m])) by (value $variable)'} | ${'template variables'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum(rate({job="foo", bar="baz"}[2m])) by (value $variable)'} + ${'rate({x="y"}[${__range_s}s])'} | ${'metric query with range grafana variable'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({x="y", bar="baz"}[${__range_s}s])'} + ${'max by (id, name, type) ({type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (rate({} [5m])) * 1000'} | ${'metric query with labels in label list with the group modifier'} | ${'bar'} | ${'='} | ${'baz'} | ${'max by (id, name, type) ({type=~"foo|bar|baz-test", bar="baz"}) * on(id) group_right(id, type, name) sum by (id) (rate({bar="baz"}[5m])) * 1000'} + ${'{foo="bar"} | logfmt'} | ${'query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | bar=`baz`'} + ${'{foo="bar"} | logfmt | json'} | ${'query with multiple parsers'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | json | bar=`baz`'} + ${'{foo="bar"} | logfmt | x="y"'} | ${'query with parser and label filter'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | x="y" | bar=`baz`'} + ${'rate({foo="bar"} | logfmt [5m])'} | ${'metric query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'rate({foo="bar"} | logfmt | bar=`baz` [5m])'} + ${'sum by(host) (rate({foo="bar"} | logfmt | x="y" | line_format "{{.status}}" [5m]))'} | ${'metric query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum by(host) (rate({foo="bar"} | logfmt | x="y" | bar=`baz` | line_format "{{.status}}" [5m]))'} + ${'{foo="bar"} | logfmt | line_format "{{.status}}"'} | ${'do not add filter to line_format expressions in query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | bar=`baz` | line_format "{{.status}}"'} + ${'{foo="bar"} | logfmt | line_format "{{status}}"'} | ${'do not add filter to line_format expressions in query with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{foo="bar"} | logfmt | bar=`baz` | line_format "{{status}}"'} + ${'{}'} | ${'query without stream selector'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}'} + ${'{} | logfmt'} | ${'query without stream selector and with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}| logfmt'} + ${'{} | x="y"'} | ${'query without stream selector and with label filter'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}| x="y"'} + ${'{} | logfmt | x="y"'} | ${'query without stream selector and with parser and label filter'} | ${'bar'} | ${'='} | ${'baz'} | ${'{bar="baz"}| logfmt | x="y"'} + ${'sum(rate({x="y"} [5m])) + sum(rate({} | logfmt [5m]))'} | ${'metric query with 1 empty and 1 not empty stream selector with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum(rate({x="y", bar="baz"} [5m])) + sum(rate({bar="baz"}| logfmt [5m]))'} + ${'sum(rate({x="y"} | logfmt [5m])) + sum(rate({} [5m]))'} | ${'metric query with 1 non-empty and 1 not empty stream selector with parser'} | ${'bar'} | ${'='} | ${'baz'} | ${'sum(rate({x="y", bar="baz"} | logfmt [5m])) + sum(rate({bar="baz"}[5m]))'} + ${'{x="yy"}'} | ${'simple query with escaped value'} | ${'bar'} | ${'='} | ${'"baz"'} | ${'{x="yy", bar=""baz""}'} + ${'{x="yy"}'} | ${'simple query with escaped value'} | ${'bar'} | ${'='} | ${'\\"baz\\"'} | ${'{x="yy", bar="\\"baz\\""}'} + ${'{x="yy"}'} | ${'simple query with an other escaped value'} | ${'bar'} | ${'='} | ${'baz\\\\'} | ${'{x="yy", bar="baz\\\\"}'} + ${'{x="yy"}'} | ${'simple query with escaped value and regex operator'} | ${'bar'} | ${'~='} | ${'baz\\\\'} | ${'{x="yy", bar~="baz\\\\"}'} + ${'{foo="bar"} | logfmt'} | ${'query with parser with escaped value'} | ${'bar'} | ${'='} | ${'\\"baz\\"'} | ${'{foo="bar"} | logfmt | bar=`"baz"`'} + ${'{foo="bar"} | logfmt'} | ${'query with parser with an other escaped value'} | ${'bar'} | ${'='} | ${'baz\\\\'} | ${'{foo="bar"} | logfmt | bar=`baz\\`'} + ${'{foo="bar"} | logfmt'} | ${'query with parser with escaped value and regex operator'} | ${'bar'} | ${'~='} | ${'\\"baz\\"'} | ${'{foo="bar"} | logfmt | bar~=`"baz"`'} + ${'{foo="bar"} | logfmt'} | ${'query with parser with escaped value and regex operator'} | ${'bar'} | ${'~='} | ${'\\"baz\\"'} | ${'{foo="bar"} | logfmt | bar~=`"baz"`'} `( 'should add label to query: $query, description: $description', ({ query, description, label, operator, value, expectedResult }) => { diff --git a/public/app/plugins/datasource/loki/modifyQuery.ts b/public/app/plugins/datasource/loki/modifyQuery.ts index 2b3b2a3d021..4329eacb142 100644 --- a/public/app/plugins/datasource/loki/modifyQuery.ts +++ b/public/app/plugins/datasource/loki/modifyQuery.ts @@ -18,6 +18,7 @@ import { import { QueryBuilderLabelFilter } from '../prometheus/querybuilder/shared/types'; +import { unescapeLabelValue } from './languageUtils'; import { LokiQueryModeller } from './querybuilder/LokiQueryModeller'; import { buildVisualQueryFromString } from './querybuilder/parsing'; @@ -329,7 +330,9 @@ function addFilterAsLabelFilter( const start = query.substring(prev, match.to); const end = isLast ? query.substring(match.to) : ''; - const labelFilter = ` | ${filter.label}${filter.op}\`${filter.value}\``; + // we now unescape all escaped values again, because we are using backticks which can handle those cases. + // we also don't care about the operator here, because we need to unescape for both, regex and equal. + const labelFilter = ` | ${filter.label}${filter.op}\`${unescapeLabelValue(filter.value)}\``; newQuery += start + labelFilter + end; prev = match.to; } diff --git a/public/app/plugins/datasource/loki/module.test.ts b/public/app/plugins/datasource/loki/module.test.ts new file mode 100644 index 00000000000..a99b7fb1987 --- /dev/null +++ b/public/app/plugins/datasource/loki/module.test.ts @@ -0,0 +1,90 @@ +import { DashboardLoadedEvent } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import './module'; + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + getAppEvents: () => ({ + subscribe: jest.fn((_, handler) => { + // Trigger test event + handler( + new DashboardLoadedEvent({ + dashboardId: 'dashboard123', + orgId: 1, + userId: 2, + grafanaVersion: 'v9.0.0', + queries: { + loki: [ + { + datasource: { type: 'loki', uid: 'abc' }, + editorMode: 'builder', + expr: '{place="$place"} |= ``', + queryType: 'range', + refId: 'A', + }, + { + datasource: { type: 'loki', uid: 'abc' }, + editorMode: 'builder', + expr: '{place="$place"} |= `error`', + maxLines: 60, + queryType: 'instant', + refId: 'A', + }, + { + datasource: { type: 'loki', uid: 'abc' }, + editorMode: 'builder', + expr: 'count_over_time({place="$place"} [5m])', + legendFormat: '{{place}}', + maxLines: 60, + queryType: 'range', + refId: 'A', + resolution: 1, + }, + { + datasource: { type: 'loki', uid: 'abc' }, + editorMode: 'code', + expr: 'count_over_time({place="moon"} [5m])', + legendFormat: '{{place}}', + queryType: 'range', + refId: 'A', + }, + { + datasource: { type: 'loki', uid: 'abc' }, + editorMode: 'code', + expr: 'count_over_time({place="luna"} [5m])', + legendFormat: '{{place}}', + queryType: 'range', + refId: 'A', + }, + ], + }, + }) + ); + }), + }), + }; +}); + +describe('queriesOnInitDashboard', () => { + it('should report a grafana_loki_dashboard_loaded interaction ', () => { + // subscribeDashboardLoadedEvent(); + expect(reportInteraction).toHaveBeenCalledWith('grafana_loki_dashboard_loaded', { + builder_mode_queries_count: 3, + grafana_version: 'v9.0.0', + dashboard_id: 'dashboard123', + org_id: 1, + code_mode_queries_count: 2, + instant_queries_count: 1, + logs_queries_count: 2, + metric_queries_count: 3, + queries_count: 5, + queries_with_changed_legend_count: 3, + queries_with_changed_line_limit_count: 5, + queries_with_changed_resolution_count: 0, + queries_with_template_variables_count: 2, + range_queries_count: 4, + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts index dddfa023aed..10ee51588d3 100644 --- a/public/app/plugins/datasource/loki/module.ts +++ b/public/app/plugins/datasource/loki/module.ts @@ -1,11 +1,17 @@ -import { DataSourcePlugin } from '@grafana/data'; +import { DashboardLoadedEvent, DataSourcePlugin } from '@grafana/data'; +import { getAppEvents } from '@grafana/runtime'; import LokiCheatSheet from './components/LokiCheatSheet'; import LokiQueryEditorByApp from './components/LokiQueryEditorByApp'; import { ConfigEditor } from './configuration/ConfigEditor'; import { LokiDatasource } from './datasource'; +import { onDashboardLoadedHandler } from './tracking'; +import { LokiQuery } from './types'; export const plugin = new DataSourcePlugin(LokiDatasource) .setQueryEditor(LokiQueryEditorByApp) .setConfigEditor(ConfigEditor) .setQueryEditorHelp(LokiCheatSheet); + +// Subscribe to on dashboard loaded event so that we can track plugin adoption +getAppEvents().subscribe>(DashboardLoadedEvent, onDashboardLoadedHandler); diff --git a/public/app/plugins/datasource/loki/queryUtils.test.ts b/public/app/plugins/datasource/loki/queryUtils.test.ts index 8650682cabd..449d30d5ae2 100644 --- a/public/app/plugins/datasource/loki/queryUtils.test.ts +++ b/public/app/plugins/datasource/loki/queryUtils.test.ts @@ -5,6 +5,7 @@ import { isQueryWithLabelFormat, isQueryWithParser, isValidQuery, + parseToNodeNamesArray, } from './queryUtils'; import { LokiQuery, LokiQueryType } from './types'; @@ -166,6 +167,39 @@ describe('isValidQuery', () => { }); }); +describe('parseToArray', () => { + it('returns on empty query', () => { + expect(parseToNodeNamesArray('{}')).toEqual(['LogQL', 'Expr', 'LogExpr', 'Selector', '⚠']); + }); + it('returns on invalid query', () => { + expect(parseToNodeNamesArray('{job="grafana"')).toEqual([ + 'LogQL', + 'Expr', + 'LogExpr', + 'Selector', + 'Matchers', + 'Matcher', + 'Identifier', + 'Eq', + 'String', + '⚠', + ]); + }); + it('returns on valid query', () => { + expect(parseToNodeNamesArray('{job="grafana"}')).toEqual([ + 'LogQL', + 'Expr', + 'LogExpr', + 'Selector', + 'Matchers', + 'Matcher', + 'Identifier', + 'Eq', + 'String', + ]); + }); +}); + describe('isLogsQuery', () => { it('returns false if metrics query', () => { expect(isLogsQuery('rate({job="grafana"}[5m])')).toBe(false); diff --git a/public/app/plugins/datasource/loki/queryUtils.ts b/public/app/plugins/datasource/loki/queryUtils.ts index 93613f3f2df..6b5c69777f0 100644 --- a/public/app/plugins/datasource/loki/queryUtils.ts +++ b/public/app/plugins/datasource/loki/queryUtils.ts @@ -108,6 +108,17 @@ export function getNormalizedLokiQuery(query: LokiQuery): LokiQuery { return { ...rest, queryType: LokiQueryType.Range }; } +export function parseToNodeNamesArray(query: string): string[] { + const queryParts: string[] = []; + const tree = parser.parse(query); + tree.iterate({ + enter: ({ name }): false | void => { + queryParts.push(name); + }, + }); + return queryParts; +} + export function isValidQuery(query: string): boolean { let isValid = true; const tree = parser.parse(query); diff --git a/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts b/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts index f67c5cca42f..4ffe4957364 100644 --- a/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/parsing.test.ts @@ -571,6 +571,21 @@ describe('buildVisualQueryFromString', () => { }) ); }); + + it('parses simple query with quotes in label value', () => { + expect(buildVisualQueryFromString('{app="\\"frontend\\""}')).toEqual( + noErrors({ + labels: [ + { + op: '=', + value: '\\"frontend\\"', + label: 'app', + }, + ], + operations: [], + }) + ); + }); }); function noErrors(query: LokiVisualQuery) { diff --git a/public/app/plugins/datasource/loki/querybuilder/parsing.ts b/public/app/plugins/datasource/loki/querybuilder/parsing.ts index 561be84facf..cd428b02c1e 100644 --- a/public/app/plugins/datasource/loki/querybuilder/parsing.ts +++ b/public/app/plugins/datasource/loki/querybuilder/parsing.ts @@ -212,7 +212,9 @@ function getLabel(expr: string, node: SyntaxNode): QueryBuilderLabelFilter { const labelNode = node.getChild(Identifier); const label = getString(expr, labelNode); const op = getString(expr, labelNode!.nextSibling); - const value = getString(expr, node.getChild(String)).replace(/"/g, ''); + let value = getString(expr, node.getChild(String)); + // `value` is wrapped in double quotes, so we need to remove them. As a value can contain double quotes, we can't use RegEx here. + value = value.substring(1, value.length - 1); return { label, diff --git a/public/app/plugins/datasource/loki/tracking.ts b/public/app/plugins/datasource/loki/tracking.ts new file mode 100644 index 00000000000..eb3eaf4c233 --- /dev/null +++ b/public/app/plugins/datasource/loki/tracking.ts @@ -0,0 +1,159 @@ +import { CoreApp, DashboardLoadedEvent, DataQueryResponse } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { variableRegex } from 'app/features/variables/utils'; + +import { QueryEditorMode } from '../prometheus/querybuilder/shared/types'; + +import { + REF_ID_STARTER_ANNOTATION, + REF_ID_DATA_SAMPLES, + REF_ID_STARTER_LOG_ROW_CONTEXT, + REF_ID_STARTER_LOG_VOLUME, +} from './datasource'; +import pluginJson from './plugin.json'; +import { getNormalizedLokiQuery, isLogsQuery, parseToNodeNamesArray } from './queryUtils'; +import { LokiQuery, LokiQueryType } from './types'; + +type LokiOnDashboardLoadedTrackingEvent = { + grafana_version?: string; + dashboard_id?: string; + org_id?: number; + + /* The number of Loki queries present in the dashboard*/ + queries_count: number; + + /* The number of Loki logs queries present in the dashboard*/ + logs_queries_count: number; + + /* The number of Loki metric queries present in the dashboard*/ + metric_queries_count: number; + + /* The number of Loki instant queries present in the dashboard*/ + instant_queries_count: number; + + /* The number of Loki range queries present in the dashboard*/ + range_queries_count: number; + + /* The number of Loki queries created in builder mode present in the dashboard*/ + builder_mode_queries_count: number; + + /* The number of Loki queries created in code mode present in the dashboard*/ + code_mode_queries_count: number; + + /* The number of Loki queries with used template variables present in the dashboard*/ + queries_with_template_variables_count: number; + + /* The number of Loki queries with changed resolution present in the dashboard*/ + queries_with_changed_resolution_count: number; + + /* The number of Loki queries with changed line limit present in the dashboard*/ + queries_with_changed_line_limit_count: number; + + /* The number of Loki queries with changed legend present in the dashboard*/ + queries_with_changed_legend_count: number; +}; + +export const onDashboardLoadedHandler = ({ + payload: { dashboardId, orgId, grafanaVersion, queries }, +}: DashboardLoadedEvent) => { + try { + // We only want to track visible Loki queries + const lokiQueries = queries[pluginJson.id] + .filter((query) => !query.hide) + .map((query) => getNormalizedLokiQuery(query)); + + if (!lokiQueries?.length) { + return; + } + + const logsQueries = lokiQueries.filter((query) => isLogsQuery(query.expr)); + const metricQueries = lokiQueries.filter((query) => !isLogsQuery(query.expr)); + const instantQueries = lokiQueries.filter((query) => query.queryType === LokiQueryType.Instant); + const rangeQueries = lokiQueries.filter((query) => query.queryType === LokiQueryType.Range); + const builderModeQueries = lokiQueries.filter((query) => query.editorMode === QueryEditorMode.Builder); + const codeModeQueries = lokiQueries.filter((query) => query.editorMode === QueryEditorMode.Code); + const queriesWithTemplateVariables = lokiQueries.filter(isQueryWithTemplateVariables); + const queriesWithChangedResolution = lokiQueries.filter(isQueryWithChangedResolution); + const queriesWithChangedLineLimit = lokiQueries.filter(isQueryWithChangedLineLimit); + const queriesWithChangedLegend = lokiQueries.filter(isQueryWithChangedLegend); + + const event: LokiOnDashboardLoadedTrackingEvent = { + grafana_version: grafanaVersion, + dashboard_id: dashboardId, + org_id: orgId, + queries_count: lokiQueries.length, + logs_queries_count: logsQueries.length, + metric_queries_count: metricQueries.length, + instant_queries_count: instantQueries.length, + range_queries_count: rangeQueries.length, + builder_mode_queries_count: builderModeQueries.length, + code_mode_queries_count: codeModeQueries.length, + queries_with_template_variables_count: queriesWithTemplateVariables.length, + queries_with_changed_resolution_count: queriesWithChangedResolution.length, + queries_with_changed_line_limit_count: queriesWithChangedLineLimit.length, + queries_with_changed_legend_count: queriesWithChangedLegend.length, + }; + + reportInteraction('grafana_loki_dashboard_loaded', event); + } catch (error) { + console.error('error in loki tracking handler', error); + } +}; + +const isQueryWithTemplateVariables = (query: LokiQuery): boolean => { + return variableRegex.test(query.expr); +}; + +const isQueryWithChangedResolution = (query: LokiQuery): boolean => { + if (!query.resolution) { + return false; + } + // 1 is the default resolution + return query.resolution !== 1; +}; + +const isQueryWithChangedLineLimit = (query: LokiQuery): boolean => { + return query.maxLines !== null || query.maxLines !== undefined; +}; + +const isQueryWithChangedLegend = (query: LokiQuery): boolean => { + if (!query.legendFormat) { + return false; + } + return query.legendFormat !== ''; +}; + +const shouldNotReportBasedOnRefId = (refId: string): boolean => { + const starters = [REF_ID_STARTER_ANNOTATION, REF_ID_STARTER_LOG_ROW_CONTEXT, REF_ID_STARTER_LOG_VOLUME]; + + if (refId === REF_ID_DATA_SAMPLES || starters.some((starter) => refId.startsWith(starter))) { + return true; + } + return false; +}; + +export function trackQuery(response: DataQueryResponse, queries: LokiQuery[], app: string): void { + // We only want to track usage for these specific apps + if (app === CoreApp.Dashboard || app === CoreApp.PanelViewer) { + return; + } + + for (const query of queries) { + if (shouldNotReportBasedOnRefId(query.refId)) { + return; + } + reportInteraction('grafana_loki_query_executed', { + app, + editor_mode: query.editorMode, + has_data: response.data.some((frame) => frame.length > 0), + has_error: response.error !== undefined, + legend: query.legendFormat, + line_limit: query.maxLines, + parsed_query: parseToNodeNamesArray(query.expr).join(','), + query_type: isLogsQuery(query.expr) ? 'logs' : 'metric', + query_vector_type: query.queryType, + resolution: query.resolution, + simultaneously_sent_query_count: queries.length, + }); + } +} diff --git a/public/app/plugins/datasource/parca/img/logo-small.svg b/public/app/plugins/datasource/parca/img/logo-small.svg index b2f64efb565..6e31daa1c63 100644 --- a/public/app/plugins/datasource/parca/img/logo-small.svg +++ b/public/app/plugins/datasource/parca/img/logo-small.svg @@ -1 +1 @@ - + diff --git a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx index c26952aae2d..9142be3e1f7 100644 --- a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx @@ -35,12 +35,13 @@ const PromCheatSheet = (props: QueryEditorHelpProps) => (
{item.title}
{item.expression ? ( -
props.onClickExample({ refId: 'A', expr: item.expression })} > {item.expression} -
+ ) : null}
{item.label}
diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx deleted file mode 100644 index f8b9d9486ae..00000000000 --- a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { LoadingState, PanelData, toUtc, TimeRange } from '@grafana/data'; - -import { PrometheusDatasource } from '../datasource'; -import { PromQuery } from '../types'; - -import { testIds as extraFieldTestIds } from './PromExploreExtraField'; -import { PromExploreQueryEditor, testIds } from './PromExploreQueryEditor'; - -// the monaco-based editor uses lazy-loading and that does not work -// well with this test, and we do not need the monaco-related -// functionality in this test anyway, so we mock it out. -jest.mock('./monaco-query-field/MonacoQueryFieldWrapper', () => { - const fakeQueryField = () =>
prometheus query field
; - return { - MonacoQueryFieldWrapper: fakeQueryField, - }; -}); - -const setup = (propOverrides?: object) => { - const datasourceMock: unknown = { - languageProvider: { - syntax: () => {}, - getLabelKeys: () => [], - metrics: [], - start: () => Promise.resolve([]), - }, - getInitHints: () => [], - exemplarsAvailable: true, - }; - const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource; - const onRunQuery = jest.fn(); - const onChange = jest.fn(); - const query: PromQuery = { expr: '', refId: 'A', interval: '1s', exemplar: true }; - const range: TimeRange = { - from: toUtc('2020-01-01', 'YYYY-MM-DD'), - to: toUtc('2020-01-02', 'YYYY-MM-DD'), - raw: { - from: toUtc('2020-01-01', 'YYYY-MM-DD'), - to: toUtc('2020-01-02', 'YYYY-MM-DD'), - }, - }; - const data: PanelData = { - state: LoadingState.NotStarted, - series: [], - request: { - requestId: '1', - dashboardId: 1, - intervalMs: 1000, - interval: '1s', - panelId: 1, - range: { - from: toUtc('2020-01-01', 'YYYY-MM-DD'), - to: toUtc('2020-01-02', 'YYYY-MM-DD'), - raw: { - from: toUtc('2020-01-01', 'YYYY-MM-DD'), - to: toUtc('2020-01-02', 'YYYY-MM-DD'), - }, - }, - scopedVars: {}, - targets: [], - timezone: 'GMT', - app: 'Grafana', - startTime: 0, - }, - timeRange: { - from: toUtc('2020-01-01', 'YYYY-MM-DD'), - to: toUtc('2020-01-02', 'YYYY-MM-DD'), - raw: { - from: toUtc('2020-01-01', 'YYYY-MM-DD'), - to: toUtc('2020-01-02', 'YYYY-MM-DD'), - }, - }, - }; - const history: any[] = []; - const exploreMode = 'Metrics'; - - const props: any = { - query, - data, - range, - datasource, - exploreMode, - history, - onChange, - onRunQuery, - }; - - Object.assign(props, propOverrides); - - return ; -}; - -describe('PromExploreQueryEditor', () => { - it('should render component', () => { - render(setup()); - expect(screen.getByTestId(testIds.editor)).toBeInTheDocument(); - }); - - it('should render PromQueryField with ExtraFieldElement', async () => { - render(setup()); - expect(screen.getByTestId(extraFieldTestIds.extraFieldEditor)).toBeInTheDocument(); - }); - - it('should set default value for expr if it is undefined', async () => { - const onChange = jest.fn(); - const query = { expr: undefined, exemplar: false, instant: false, range: true }; - render(setup({ onChange, query })); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ expr: '' })); - }); - - it('should set default value for exemplars if it is undefined', async () => { - const onChange = jest.fn(); - const query = { expr: '', instant: false, range: true }; - render(setup({ onChange, query })); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ exemplar: true })); - }); - - it('should set default value for instant and range if expr is falsy', async () => { - const onChange = jest.fn(); - let query = { expr: '', exemplar: true }; - render(setup({ onChange, query })); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ instant: true, range: true })); - }); - - it('should not set default value for instant and range with truthy expr', async () => { - const onChange = jest.fn(); - let query = { expr: 'foo', exemplar: true }; - render(setup({ onChange, query })); - expect(onChange).toHaveBeenCalledTimes(0); - }); - - it('should add default values for multiple missing values', async () => { - const onChange = jest.fn(); - let query = {}; - render(setup({ onChange, query })); - expect(onChange).toHaveBeenCalledTimes(3); - }); -}); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx deleted file mode 100644 index 5517ef1c8da..00000000000 --- a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { memo, useEffect } from 'react'; - -import { QueryEditorProps, CoreApp } from '@grafana/data'; - -import { PrometheusDatasource } from '../datasource'; -import { PromQuery, PromOptions } from '../types'; - -import { PromExploreExtraField } from './PromExploreExtraField'; -import PromQueryField from './PromQueryField'; - -export type Props = QueryEditorProps; - -export const PromExploreQueryEditor = memo((props: Props) => { - const { range, query, data, datasource, history, onChange, onRunQuery } = props; - - // Setting default values - useEffect(() => { - if (query.expr === undefined) { - onChange({ ...query, expr: '' }); - } - if (query.exemplar === undefined) { - onChange({ ...query, exemplar: true }); - } - - // Override query type to "Both" only for new queries (no query.expr). - if (!query.instant && !query.range && !query.expr) { - onChange({ ...query, instant: true, range: true }); - } - }, [onChange, query]); - - return ( - {}} - history={history} - data={data} - data-testid={testIds.editor} - ExtraFieldElement={ - - } - /> - ); -}); - -PromExploreQueryEditor.displayName = 'PromExploreQueryEditor'; - -export const testIds = { - editor: 'prom-editor-explore', -}; diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx deleted file mode 100644 index b54fe94a74d..00000000000 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { screen, render } from '@testing-library/react'; -import React from 'react'; - -import { dateTime, CoreApp } from '@grafana/data'; - -import { PrometheusDatasource } from '../datasource'; -import { PromQuery } from '../types'; - -import { PromQueryEditor, testIds } from './PromQueryEditor'; - -jest.mock('app/features/dashboard/services/TimeSrv', () => { - return { - getTimeSrv: () => ({ - timeRange: () => ({ - from: dateTime(), - to: dateTime(), - }), - }), - }; -}); - -jest.mock('./monaco-query-field/MonacoQueryFieldWrapper', () => { - const fakeQueryField = () =>
prometheus query field
; - return { - MonacoQueryFieldWrapper: fakeQueryField, - }; -}); - -const setup = (propOverrides?: object) => { - const datasourceMock: unknown = { - createQuery: jest.fn((q) => q), - getPrometheusTime: jest.fn((date, roundup) => 123), - languageProvider: { - start: () => Promise.resolve([]), - syntax: () => {}, - getLabelKeys: () => [], - metrics: [], - }, - getInitHints: () => [], - }; - const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource; - const onRunQuery = jest.fn(); - const onChange = jest.fn(); - const query: PromQuery = { expr: '', refId: 'A' }; - - const props: any = { - datasource, - onChange, - onRunQuery, - query, - }; - - Object.assign(props, propOverrides); - - return render(); -}; - -describe('Render PromQueryEditor with basic options', () => { - it('should render editor', () => { - setup(); - expect(screen.getByTestId(testIds.editor)).toBeInTheDocument(); - }); - - it('should render exemplar editor for dashboard', () => { - setup({ app: CoreApp.Dashboard }); - expect(screen.getByTestId(testIds.editor)).toBeInTheDocument(); - expect(screen.getByTestId(testIds.exemplar)).toBeInTheDocument(); - }); - - it('should not render exemplar editor for unified alerting', () => { - setup({ app: CoreApp.UnifiedAlerting }); - expect(screen.getByTestId(testIds.editor)).toBeInTheDocument(); - expect(screen.queryByTestId(testIds.exemplar)).not.toBeInTheDocument(); - }); -}); diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx deleted file mode 100644 index 0ba79277e90..00000000000 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { map } from 'lodash'; -import React, { PureComponent } from 'react'; - -// Types -import { CoreApp, SelectableValue } from '@grafana/data'; -import { InlineFormLabel, LegacyForms, Select } from '@grafana/ui'; - -import { PromQuery } from '../types'; - -import { PromExemplarField } from './PromExemplarField'; -import PromLink from './PromLink'; -import PromQueryField from './PromQueryField'; -import { PromQueryEditorProps } from './types'; - -const { Switch } = LegacyForms; - -export const FORMAT_OPTIONS: Array> = [ - { label: 'Time series', value: 'time_series' }, - { label: 'Table', value: 'table' }, - { label: 'Heatmap', value: 'heatmap' }, -]; - -export const INTERVAL_FACTOR_OPTIONS: Array> = map([1, 2, 3, 4, 5, 10], (value: number) => ({ - value, - label: '1/' + value, -})); - -interface State { - legendFormat?: string; - formatOption: SelectableValue; - interval?: string; - intervalFactorOption: SelectableValue; - instant: boolean; - exemplar: boolean; -} - -export class PromQueryEditor extends PureComponent { - // Query target to be modified and used for queries - query: PromQuery; - - constructor(props: PromQueryEditorProps) { - super(props); - // Use default query to prevent undefined input values - const defaultQuery: Partial = { - expr: '', - legendFormat: '', - interval: '', - // Set exemplar to false for alerting queries - exemplar: props.app === CoreApp.UnifiedAlerting ? false : true, - }; - const query = Object.assign({}, defaultQuery, props.query); - this.query = query; - // Query target properties that are fully controlled inputs - this.state = { - // Fully controlled text inputs - interval: query.interval, - legendFormat: query.legendFormat, - // Select options - formatOption: FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0], - intervalFactorOption: - INTERVAL_FACTOR_OPTIONS.find((option) => option.value === query.intervalFactor) || INTERVAL_FACTOR_OPTIONS[0], - // Switch options - instant: Boolean(query.instant), - exemplar: Boolean(query.exemplar), - }; - } - - onFieldChange = (query: PromQuery, override?: any) => { - this.query.expr = query.expr; - }; - - onFormatChange = (option: SelectableValue) => { - this.query.format = option.value; - this.setState({ formatOption: option }, this.onRunQuery); - }; - - onInstantChange = (e: React.SyntheticEvent) => { - const instant = e.currentTarget.checked; - this.query.instant = instant; - this.setState({ instant }, this.onRunQuery); - }; - - onIntervalChange = (e: React.SyntheticEvent) => { - const interval = e.currentTarget.value; - this.query.interval = interval; - this.setState({ interval }); - }; - - onIntervalFactorChange = (option: SelectableValue) => { - this.query.intervalFactor = option.value; - this.setState({ intervalFactorOption: option }, this.onRunQuery); - }; - - onLegendChange = (e: React.SyntheticEvent) => { - const legendFormat = e.currentTarget.value; - this.query.legendFormat = legendFormat; - this.setState({ legendFormat }); - }; - - onExemplarChange = (isEnabled: boolean) => { - this.query.exemplar = isEnabled; - this.setState({ exemplar: isEnabled }, this.onRunQuery); - }; - - onRunQuery = () => { - const { query } = this; - // Change of query.hide happens outside of this component and is just passed as prop. We have to update it when running queries. - const { hide } = this.props.query; - this.props.onChange({ ...query, hide }); - this.props.onRunQuery(); - }; - - render() { - const { datasource, query, range, data } = this.props; - const { formatOption, instant, interval, intervalFactorOption, legendFormat } = this.state; - //We want to hide exemplar field for unified alerting as exemplars in alerting don't make sense and are source of confusion - const showExemplarField = this.props.app !== CoreApp.UnifiedAlerting; - - return ( - -
- - Legend - - -
- -
- - An additional lower limit for the step parameter of the Prometheus query and for the{' '} - $__interval and $__rate_interval variables. The limit is absolute and not - modified by the "Resolution" setting. - - } - > - Min step - - -
- -
-
Resolution
- - - - - - -
- {showExemplarField && ( - - )} -
- } - /> - ); - } -} - -export const testIds = { - editor: 'prom-editor', - exemplar: 'exemplar-editor', -}; diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx index 9d80cddb841..fdc69769f89 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.test.tsx @@ -6,7 +6,6 @@ import { CoreApp } from '@grafana/data'; import { PrometheusDatasource } from '../datasource'; -import { testIds as regularTestIds } from './PromQueryEditor'; import { PromQueryEditorByApp } from './PromQueryEditorByApp'; import { testIds as alertingTestIds } from './PromQueryEditorForAlerting'; @@ -70,10 +69,9 @@ function setup(app: CoreApp): RenderResult & { onRunQuery: jest.Mock } { describe('PromQueryEditorByApp', () => { it('should render simplified query editor for cloud alerting', () => { - const { getByTestId, queryByTestId } = setup(CoreApp.CloudAlerting); + const { getByTestId } = setup(CoreApp.CloudAlerting); expect(getByTestId(alertingTestIds.editor)).toBeInTheDocument(); - expect(queryByTestId(regularTestIds.editor)).toBeNull(); }); it('should render editor selector for unkown apps', () => { diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx index 3e15b9a297d..f10a6e3279a 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditorByApp.tsx @@ -1,12 +1,9 @@ import React, { memo } from 'react'; import { CoreApp } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { PromQueryEditorSelector } from '../querybuilder/components/PromQueryEditorSelector'; -import { PromExploreQueryEditor } from './PromExploreQueryEditor'; -import { PromQueryEditor } from './PromQueryEditor'; import { PromQueryEditorForAlerting } from './PromQueryEditorForAlerting'; import { PromQueryEditorProps } from './types'; @@ -16,16 +13,8 @@ export function PromQueryEditorByApp(props: PromQueryEditorProps) { switch (app) { case CoreApp.CloudAlerting: return ; - case CoreApp.Explore: - if (config.featureToggles.promQueryBuilder) { - return ; - } - return ; default: - if (config.featureToggles.promQueryBuilder) { - return ; - } - return ; + return ; } } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx index bb1ce8c8bfd..d5e31f675e3 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/LabelFilterItem.tsx @@ -6,9 +6,10 @@ import { selectors } from '@grafana/e2e-selectors'; import { AccessoryButton, InputGroup } from '@grafana/experimental'; import { AsyncSelect, Select } from '@grafana/ui'; -import { PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from '../components/MetricSelect'; import { QueryBuilderLabelFilter } from '../shared/types'; +import { PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './MetricSelect'; + export interface Props { defaultOp: string; item: Partial; @@ -89,6 +90,7 @@ export function LabelFilterItem({ {/* Operator select i.e. = =~ != !~ */}